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

No files matched your search

+3
View File
@@ -9,6 +9,9 @@ data9
.venv
.venv/*
# run.sh: pid files و لاگ‌های اجرای محلی
.run/
# graphify: ignore regenerable cache and dated backups, keep the graph itself
graphify-out/cache/
graphify-out/2026-*/
+296
View File
@@ -0,0 +1,296 @@
## **راهنمای استفاده از لینک ارتباطی و وب‌سرویس سامانه پیامک (0098vip.com و 0098sms.com)**
### **فهرست مطالب**
* **مقدمات اتصال** (صفحه ۳)
* **راهنمای استفاده از لینک ارتباطی** (صفحه ۴)
* **کدهای خطای لینک ارتباطی** (صفحه ۵)
* **راهنمای استفاده از وب‌سرویس** (صفحه ۶)
* **کدهای خطای وب‌سرویس** (صفحه ۸)
* **نمونه کدها**:
* نمونه کدهای ASP (صفحه ۹)
* نمونه کدهای PHP (صفحه ۱۱)
* نمونه کد Python (صفحه ۱۴)
### **مقدمات اتصال**
جهت فعال‌سازی سرویس ابتدا وارد حساب کاربری خود در سامانه 0098vip شوید و از منوی سمت راست گزینه «لینک ارتباطی \- وب سرویس (otp)» را انتخاب نمایید:
1. **مرحله اول (ورود به بخش مربوطه):** از منوی سمت راست پنل، بر روی گزینه «لینک ارتباطی \- وب سرویس (otp)» کلیک کنید.
2. **مرحله دوم (انتخاب شماره و فعال‌سازی):** شماره اختصاصی پنل خود را در بخش فعال‌سازی انتخاب کرده و دکمه «فعال‌سازی» را بزنید.
3. **مرحله سوم (انتخاب رمز وب‌سرویس):** در پایین صفحه و در بخش «انتخاب رمز برای لینک ارتباطی \- وب سرویس»، رمز اصلی ورود به پنل را وارد نموده و بر روی «ایجاد رمز وب سرویس» کلیک کنید. سامانه رمزی اختصاصی جهت اتصال به API و وب‌سرویس تولید می‌کند.
> **نکته امنیتی:** پیشنهاد می‌شود از منوی تنظیمات، در بخش «IP های امن»، آدرس آی‌پی سرور خود را درج فرمایید تا ارسال پیامک منحصراً از سمت سرور شما مجاز باشد.
### **راهنمای استفاده از لینک ارتباطی (URL GET)**
آدرس پایه جهت فراخوانی لینک ارسال پیامک به شرح زیر است:
Plaintext
https://0098vip.com/sendsmslink.aspx?FROM=$FROM\&TO=$TO\&TEXT=$TEXT\&USERNAME=$USERNAME\&PASSWORD=$PASSWORD\&DOMAIN=$DOMAIN
پارامترهای متغیر در لینک:
* FROM: شماره اختصاصی فرستنده متصل به لینک
* TO: شماره مقصد (گیرنده پیامک)
* TEXT: متن ارسالی پیامک
* USERNAME: نام کاربری ورود به پنل
* PASSWORD: رمز عبور اتصال
* DOMAIN: عبارت 0098
### **جدول کدهای خطای لینک ارتباطی**
| کد خطا | شرح خطا |
| ----: | ----: |
| ۰ | عملیات با موفقیت به پایان رسید. |
| ۱ | شماره گیرنده اشتباه است. |
| ۲ | گیرنده تعریف نشده است. |
| ۳ | فرستنده تعریف نشده است. |
| ۴ | متن تنظیم نشده است. |
| ۵ | نام کاربری تنظیم نشده است. |
| ۶ | کلمه عبور تنظیم نشده است. |
| ۷ | نام دامین تنظیم نشده است. |
| ۸ | مجوز شما باطل شده است. |
| ۹ | اعتبار پیامک شما کافی نیست. |
| ۱۰ | برای این شماره لینک تعریف نشده است. |
| ۱۱ | عدم مجوز برای اتصال لینک. |
| ۱۲ | نام کاربری و کلمه عبور اشتباه است. |
| ۱۳ | کاراکتر غیر مجاز در متن وجود دارد. |
| ۱۴ | سقف ارسال روزانه پر شده است. |
| ۱۶ | عدم مجوز شماره برای ارسال از لینک. |
| ۱۷ | خطا در شماره پنل، لطفاً با پشتیبانی تماس بگیرید. |
| ۱۸ | اتمام تاریخ اعتبار شماره پنل؛ برای استفاده تمدید شود. |
| ۱۹ | تنظیمات کد opt انجام نشده است. |
| ۲۰ | فرمت کد opt صحیح نیست. |
| ۲۱ | تنظیمات کد opt توسط ادمین تایید نشده است. |
| ۲۲ | اطلاعات مالک شماره ثبت و تایید نشده است. |
| ۲۳ | هنوز اجازه ارسال به این شماره پنل داده نشده است. |
| ۲۴ | ارسال از IP غیر مجاز انجام شده است. |
### **راهنمای استفاده از Web Service**
آدرس وب‌سرویس سامانه:
Plaintext
https://webservice.0098vip.com/service.asmx
*نکته:* پس از افزودن ماژول وب‌سرویس، توصیه می‌شود Namespace اولیه را تغییر داده و نامی بدون نقطه (.) تنظیم کنید.
توابع وب‌سرویس:
1. SendSMS: ارسال پیامک تکی به‌صورت آنی.
2. SendSMSWithTime: ارسال پیامک تکی زمان‌بندی‌شده (با قابلیت تعیین تاریخ و زمان).
3. SendSMSWithID: ارسال پیامک تکی همراه با بازگرداندن شناسه پیامک (smsid) جهت رهگیری وضعیت.
4. smsdeliveryState: استعلام وضعیت دلیوری پیامک ارسالی با استفاده از شناسه پیامک.
5. RemainSms: دریافت مانده موجودی پیامک‌های پنل.
### **پارامترهای ورودی توابع وب‌سرویس**
| نام پارامتر | نوع داده | توضیح |
| ----: | ----: | ----: |
| Username | String | نام کاربری پنل |
| password | String | پسورد پنل |
| text | String | متن پیامک |
| MobileNo | String | شماره موبایل گیرنده |
| PnlNo (PniNo) | String | شماره اختصاصی پنل در سامانه |
| Timestmp | String | تاریخ و زمان ارسال در قالب Timestamp (۱۰ رقمی) |
| smsid | String | شناسه پیامک (عدد حداقل ۹ رقمی در فرمت string) |
### **جدول کدهای خطای وب‌سرویس و وضعیت‌های دلیوری**
| کد برگشتی | توضیحات |
| ----: | ----: |
| ۰ | ارسال با موفقیت انجام شد. |
| ۲ (یا عدد ۹+ رقمی) | ارسال با موفقیت انجام شد (خروجی تابع دریافت شناسه/دلیوری). |
| ۴ | مخاطب دریافت پیام‌های تبلیغاتی خود را بسته است (بلک‌لیست). |
| ۵ | رسیده به گوشی. |
| ۶ | نرسیده به گوشی. |
| ۷ | وضعیت دریافت پیامک منقضی شده است. |
| ۸ | وضعیت پیامک نامشخص است. |
| ۹ | اطلاعات مالک شماره ثبت و تایید نشده است. |
| ۱۰ | عدم تطابق نام کاربری و کلمه عبور؛ لطفاً با پشتیبانی تماس بگیرید. |
| ۱۲ | هنوز اجازه ارسال به این شماره پنل داده نشده است. |
| ۱۳ | ارسال از IP غیر مجاز انجام شده است. |
| ۱۷ | نام کاربری یا کلمه عبور اشتباه است؛ لطفاً با پشتیبانی تماس بگیرید. |
| ۱۸ | کاراکتر غیر مجاز در متن وجود دارد. |
| ۱۹ | متن پیامک خالی است. |
| \-۲۲ | خطای شارژ؛ لطفاً با پشتیبانی تماس بگیرید. |
| \-23 | حساب کاربری شما مسدود است؛ لطفاً با پشتیبانی تماس بگیرید. |
| \-24 | مرحله دوم ثبت‌نام شما انجام نگرفته است؛ لطفاً ثبت‌نام را کامل نمایید. |
| \-25 | عدم مجوز شماره برای ارسال از لینک. |
| \-26 | خطا در شماره پنل؛ لطفاً با پشتیبانی تماس بگیرید. |
| \-27 | اتمام تاریخ اعتبار شماره پنل؛ برای استفاده تمدید شود. |
| \-28 | تنظیمات کد opt انجام نشده است. |
| \-29 | فرمت کد opt صحیح نیست. |
| \-31 | smsid یافت نشد. |
| \-32 | smsid مطابقت ندارد. |
| ۶۶ | شارژ پنل شما برای ارسال کافی نیست؛ لطفاً اقدام به شارژ نمایید. |
| ۱۱۱۱ | شماره موبایل صحیح نیست. |
| Hang | عدم تطابق نام کاربری و کلمه عبور؛ لطفاً با پشتیبانی تماس بگیرید. |
| No Doc | کاراکتر غیر مجاز در متن وجود دارد. |
| Wrong timestamp | تایم‌استمپ ارسال‌شده صحیح نیست. |
| Wrong time | زمان در بازه صحیح نیست (تنظیم ارسال از همان روز تا ۱ سال بعد مجاز است). |
### **نمونه کدهای پیاده‌سازی**
#### **۱. نمونه کدهای C\# / ASP.NET**
**ارسال پیامک ساده (SendSMS):**
C\#
ServiceReference1.ServiceSoapClient ss \= new ServiceReference1.ServiceSoapClient();
string username \= "username";
string password \= "password";
string smstext \= "smstext";
string mobileno \= "091XXXXXXXX";
string panelno \= "3000XXXX";
string responsecode \= ss.SendSMS(username, password, smstext, mobileno, panelno);
**ارسال پیامک زمان‌دار (SendSMSWithTime):**
C\#
ServiceReference1.ServiceSoapClient ss \= new ServiceReference1.ServiceSoapClient();
string username \= "username";
string password \= "password";
string smstext \= "smstext";
string mobileno \= "091XXXXXXXX";
string panelno \= "3000XXXX";
string timestamp \= "1658939130";
string responsecode \= ss.SendSMSwithtime(username, password, smstext, mobileno, panelno, timestamp);
**ارسال پیامک با دریافت شناسه (SendSMSWithID):**
C\#
ServiceReference1.ServiceSoapClient ss \= new ServiceReference1.ServiceSoapClient();
string username \= "username";
string password \= "password";
string smstext \= "smstext";
string mobileno \= "091XXXXXXXX";
string panelno \= "3000XXXX";
string responsecode \= ss.SendSMSWithID(username, password, smstext, mobileno, panelno);
**استعلام دلیوری (smsdeliveryState):**
C\#
ServiceReference1.ServiceSoapClient ss \= new ServiceReference1.ServiceSoapClient();
string username \= "username";
string password \= "password";
string smsid \= "XXXXXXXXXX";
string deliverystate \= ss.smsdeliveryState(username, password, smsid);
**استعلام مانده اعتبار (RemainSms):**
C\#
ServiceReference1.ServiceSoapClient ss \= new ServiceReference1.ServiceSoapClient();
string username \= "username";
string password \= "password";
string remain \= ss.RemainSms(username, password);
#### **۲. نمونه کدهای PHP**
**ارسال ساده (SendSMS):**
PHP
\<?php
// Enable extension=php\_soap.dll
ini\_set("soap.wsdl\_cache\_enabled", "0");
$sms\_client \= new SoapClient('https://webservice.0098vip.com/service.asmx?wsdl', array('encoding'\=\>'UTF-8'));
$parameters\['username'\] \= "username";
$parameters\['password'\] \= "password";
$parameters\['mobileno'\] \= "0912XXXXXXXX";
$parameters\['pnlno'\] \= "3000XXXX";
$parameters\['text'\] \= "test";
$parameters\['isflash'\] \= false;
echo $sms\_client\-\>SendSMS($parameters)-\>SendSMSResult;
?\>
**ارسال زمان‌دار (SendSMSWithTime):**
PHP
\<?php
ini\_set("soap.wsdl\_cache\_enabled", "0");
$sms\_client \= new SoapClient('https://webservice.0098vip.com/service.asmx?wsdl', array('encoding'\=\>'UTF-8'));
$parameters\['username'\] \= "username";
$parameters\['password'\] \= "password";
$parameters\['mobileno'\] \= "0912XXXXXXXX";
$parameters\['pnlno'\] \= "3000XXXX";
$parameters\['text'\] \= "test";
$parameters\['timestamp'\] \= "1658939130";
$parameters\['isflash'\] \= false;
echo $sms\_client\-\>SendSMSwithtime($parameters)-\>SendSMSWithTimeResult;
?\>
**ارسال با دریافت شناسه (SendSMSWithID):**
PHP
\<?php
ini\_set("soap.wsdl\_cache\_enabled", "0");
$sms\_client \= new SoapClient('https://webservice.0098vip.com/service.asmx?wsdl', array('encoding'\=\>'UTF-8'));
$parameters\['username'\] \= "username";
$parameters\['password'\] \= "password";
$parameters\['mobileno'\] \= "0912XXXXXXXX";
$parameters\['pnlno'\] \= "3000XXXX";
$parameters\['text'\] \= "test";
$parameters\['isflash'\] \= false;
echo $sms\_client\-\>SendSMSWithID($parameters)-\>SendSMSWithIDResult;
?\>
**استعلام دلیوری (smsdeliveryState):**
PHP
\<?php
ini\_set("soap.wsdl\_cache\_enabled", "0");
$sms\_client \= new SoapClient('https://webservice.0098vip.com/service.asmx?wsdl', array('encoding'\=\>'UTF-8'));
$parameters\['username'\] \= "username";
$parameters\['password'\] \= "password";
$parameters\['smsid'\] \= "XXXXXXXXXX";
$parameters\['isflash'\] \= false;
echo $sms\_client\-\>smsdeliveryState($parameters)-\>smsdeliveryStateResult;
?\>
**استعلام مانده اعتبار (RemainSms):**
PHP
\<?php
ini\_set("soap.wsdl\_cache\_enabled", "0");
$sms\_client \= new SoapClient('https://webservice.0098vip.com/service.asmx?wsdl', array('encoding'\=\>'UTF-8'));
$parameters\['username'\] \= "username";
$parameters\['password'\] \= "password";
$parameters\['isflash'\] \= false;
echo $sms\_client\-\>RemainSms($parameters)-\>remainResult;
?\>
#### **۳. نمونه کد Python**
ارسال ساده با کتابخانه Zeep:
Python
from zeep import Client
client \= Client('https://webservice.0098vip.com/service.asmx?WSDL')
parameters \= {
'username': 'your\_username',
'password': 'your\_password',
'mobileno': '0912XXXXXXX',
'panelno': '3000XXXXXXX',
'text': 'test',
'isflash': False
}
response \= client.service.SendSMS(parameters)
print("SendSMSResult:", response)
**منابع**
1\. [https://github.com/c123ir/ai-123-flutter](https://github.com/c123ir/ai-123-flutter)
2\. [https://github.com/c123ir/ai-123-flutter](https://github.com/c123ir/ai-123-flutter)
3\. [https://github.com/c123ir/ai-123-flutter](https://github.com/c123ir/ai-123-flutter)
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+97 -18
View File
@@ -1,11 +1,11 @@
Project Root: khatibi
Generated At: 2026-10-09T15:40:01+00:00
Python Files: 126
Django Files: 16
Generated At: 2026-10-10T06:32:47+00:00
Python Files: 153
Django Files: 12
JavaScript/TypeScript Files: 14
HTML Files: 50
Total Files: 447
Total Directories: 71
HTML Files: 54
Total Files: 523
Total Directories: 74
khatibi
├── .cursor
@@ -19,6 +19,7 @@ khatibi
│ ├── plugins
│ │ └── graphify.js
│ └── opencode.json
├── .run
├── admin
│ ├── migrations
│ │ └── __init__.py
@@ -35,6 +36,8 @@ khatibi
│ │ │ ├── __init__.py
│ │ │ ├── gt_esh.py
│ │ │ ├── send_daily_report.py
│ │ │ ├── sms_scan.py
│ │ │ ├── sms_worker.py
│ │ │ └── sqlite_db_backup.py
│ │ └── __init__.py
│ ├── migrations
@@ -94,6 +97,12 @@ khatibi
│ │ ├── 0054_auto_20260715_2250.py
│ │ ├── 0055_action_nextnazardt.py
│ │ ├── 0056_auto_20260731_1824.py
│ │ ├── 0057_sms.py
│ │ ├── 0058_seed_sms_templates.py
│ │ ├── 0059_sms_flow_control.py
│ │ ├── 0060_sms_permission_label.py
│ │ ├── 0061_sms_timing_and_primary.py
│ │ ├── 0062_sms_link_provider.py
│ │ └── __init__.py
│ ├── models
│ │ ├── __init__.py
@@ -102,9 +111,19 @@ khatibi
│ │ ├── factors.py
│ │ ├── finance.py
│ │ ├── products.py
│ │ ├── sms.py
│ │ ├── store.py
│ │ ├── system.py
│ │ └── users.py
│ ├── sms
│ │ ├── __init__.py
│ │ ├── enqueue.py
│ │ ├── phones.py
│ │ ├── recipients.py
│ │ ├── render.py
│ │ ├── rules.py
│ │ ├── scanner.py
│ │ └── timing.py
│ ├── static
│ │ └── admin_panel
│ │ ├── DataTables
@@ -189,6 +208,7 @@ khatibi
│ │ ├── customer
│ │ │ ├── customerNazarInc.html
│ │ │ └── nazar.html
│ │ ├── _customer_sms.html
│ │ ├── ActionList.html
│ │ ├── base.html
│ │ ├── baseHesabdari.html
@@ -232,6 +252,9 @@ khatibi
│ │ ├── report_rezayat.html
│ │ ├── reportCheckNesiye.html
│ │ ├── rezayat.html
│ │ ├── sms_queue.html
│ │ ├── sms_settings.html
│ │ ├── sms_templates.html
│ │ ├── store.html
│ │ ├── store_transfers.html
│ │ ├── storeBK1.html
@@ -249,26 +272,18 @@ khatibi
│ │ ├── customers.py
│ │ ├── factors.py
│ │ ├── finance.py
│ │ ├── sms.py
│ │ └── store.py
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── context_processor.py
│ ├── services.py
│ ├── signals.py
│ ├── tests.py
│ ├── urls.py
│ ├── views - Copy.py
│ └── viewsBK.py
├── backups
│ └── refactor-2026-10-09
│ ├── admin.py
│ ├── apps.py
│ ├── context_processor.py
│ ├── MANIFEST.sha256
│ ├── models.py
│ ├── services.py
│ ├── urls.py
│ └── views.py
├── data9
│ ├── db2 (2).sqlite32
│ ├── db2.sqlite32
@@ -293,6 +308,7 @@ khatibi
│ │ │ ├── 056f0cb55bfd5a1ddf744c7c824c342048c1db47f3bb350030a1533ab56bf8d7.json
│ │ │ ├── 0b8f8e3b6e4d84256e784dabb96f5646fa6e7295adbd20648c235e7821b4ca1d.json
│ │ │ ├── 0e6bfa570caca1f79f549f5cdc37f8bdf9c9b43a023ca28a8fcb83351389b399.json
│ │ │ ├── 100f37182d082767395fcd09f1ec937fd74e57ed039885843bf81d349c3fc1f5.json
│ │ │ ├── 128b2b616c097fbfbcbc3fddafc224df0919ba261bb5ded7bcafa8b445fe118b.json
│ │ │ ├── 12d922593c75865b8068057f9244ad61a5ee573b7f3f38a16c31fa83abb9d668.json
│ │ │ ├── 13ca1f37c3eea3c07228d2d98d47f7db6a9c1d8468b259651f3fea5419651569.json
@@ -300,13 +316,19 @@ khatibi
│ │ │ ├── 174e406c2649f2384921581cbf912945a95d54aab73707b9aa45752aaa7c54d7.json
│ │ │ ├── 183467d5c1ee7daac78dfb428bd335c17e17272bd4510358f38fc5924140c816.json
│ │ │ ├── 1893253c5245ae073f9de9168d537384ab764b79d7b1631c785cd7335aab791b.json
│ │ │ ├── 1d5a29a9dabb365c079039c788988636b66c3a4bd2dbe66b249df001c2b5396f.json
│ │ │ ├── 1d9f15f04104af07c60349f21bcd9f6f2c0e65c915e3e9c69040a10197b0fa6f.json
│ │ │ ├── 1dd0c6958a307a1e4e59c24f20225292277585dd8652b35537ede94fc04807ee.json
│ │ │ ├── 21af53aa7322868a25b47458e59f339a8f6e58b7914fbe8e3389ab86e0b0b643.json
│ │ │ ├── 22310d4d49f2beee611c83915933ea06e80ec562fd92c170345c556cd3772561.json
│ │ │ ├── 2356cb9f067d43739312459bad2cb6d9ecbacac703e7e40a282d5b89d64332b3.json
│ │ │ ├── 23f4e3a62e03a38743659d94dd1350d17775da8be9175846e4ff3f38fa87c515.json
│ │ │ ├── 2501a9885d6fa5292276ee8cc7db63a9a3c08605f38d199d425d33f650dac35e.json
│ │ │ ├── 25dbf169bbd8ae50f7cc568b4dbb7d259eb993ccddc9dbb20db96018ed093f5a.json
│ │ │ ├── 28df8bbd275806fd77c086629211231bd1329fe8156f521b19b5c5979f0d3b51.json
│ │ │ ├── 294e2d390f56acc0445564d428d90c641d76496cbc457bcfb09876382e9d87ab.json
│ │ │ ├── 2f36d1565911fdcb414da716162085b0fdc4b92d9d2d3fbb9fecc403b1916766.json
│ │ │ ├── 338e7d5d2e7d3a7260ab74d9b7b19bbab6b9ce84e7036623060190d9e0cc4bcd.json
│ │ │ ├── 3614ae46baf3e1b6efa17011c37407a5d2931aba1886d8f8bdf8e4e154ab6c3f.json
│ │ │ ├── 3ff644065147b53af40ea703202c0fd832114936fe67e6c9a30d15e3da915455.json
│ │ │ ├── 40e7fbe5e765171201ab8016acce76a28304e1339d9aeb5a9cc18da87c5604d2.json
@@ -319,7 +341,10 @@ khatibi
│ │ │ ├── 57a18b5350b4a567305ca01b2951efee6eeb3b289b5ffbbf390c3962ff29d61c.json
│ │ │ ├── 58ad02e0930221b4f44603dbcf01d3b5d07d9da2772edafe48a94a9894ea148a.json
│ │ │ ├── 58bfae9cf8467d2bb9363884d517e98cae38a934f4c474e9e02aed520fdaab95.json
│ │ │ ├── 58f2e3419fb2de4a3bdc4a9c0bf48e50f2cc225506096109dbf2b9bfe4778bb5.json
│ │ │ ├── 5901400fc36893a9ad85acd97eb73b691cb4b8d1440047c5f85c1534a2c7e492.json
│ │ │ ├── 5b63b5d7fc7a09b2dbf5e2f7db6fe00ccab773762b3291bd666c8e29d32100d3.json
│ │ │ ├── 5ce9ee869c507986bf00ae6929f3aae3ab7aa58b5c2bded4965374fcf7f5654b.json
│ │ │ ├── 5d5b6249e3cfa0aeee94ba2053fc73b4b7b5b8fa1d21df09d40bda7c90ef273d.json
│ │ │ ├── 60002a01d6fc22147e2994d1a1a447d5e577c02aed2443789af20571ec2d2a19.json
│ │ │ ├── 60e00560038df059b1696e54dcf055cd658677d6e1201570a8500531addc0ac6.json
@@ -328,8 +353,11 @@ khatibi
│ │ │ ├── 64fc04d8c553b2121a71955d48257160a40f6dd34258e3287945873f02d897dc.json
│ │ │ ├── 65d5139792e0ee840c5b1710d7659ecfb5dbdcadbd02d205f7517a6a3a374211.json
│ │ │ ├── 6876f75a9cb4a5f5857c47e3b0d6b38e51941aff76bc6d92cb1b4eef1c51071e.json
│ │ │ ├── 6a88f82e5b9f9da12b50f8531eac30c10e1c2a0cf39601d275fd90ee3e28da55.json
│ │ │ ├── 6aca679f90689aa4c275389c8b4e2462a9456775e8b124071070af2c988ca0fc.json
│ │ │ ├── 6d4d7d7f829f13e46e4898aceb760d66d2255dc838eab0933f9a92d720f420a7.json
│ │ │ ├── 6df9c1e88224e95c55dee4238fa91358360231ee47362294e884bc6e24d73612.json
│ │ │ ├── 6e262456570c174e207055c7c964fe85431addb0f59298b1e4d912500dfb23a3.json
│ │ │ ├── 6e2ed39911a83c75e432ffd0dab22fd095ce8c5bb186d6ab22d594375f3b32bc.json
│ │ │ ├── 71a07328d56403d55714d76cefc05db3e68c6498719e629e4e76889a1940efc2.json
│ │ │ ├── 73a129efd08cc38274f3c56503c234eef17f58faf74dadbdbbe475efcbfc2777.json
@@ -338,24 +366,38 @@ khatibi
│ │ │ ├── 75d0862c4a7be01c37a92f0baf858c27367f6b242254080cccc5888a758dd0ff.json
│ │ │ ├── 760b5e6a8ce6ddc3b34aae59a11c57c36471f9eb93f8d2e24c7a030c8ad02dbc.json
│ │ │ ├── 761cb648c1272d981720580035fca3a501c423ab819648863f223b83292b4e58.json
│ │ │ ├── 767dd58146a5d4a713611561a9e5c1b706e5f509a5621891c9b2e195f3b8ee0a.json
│ │ │ ├── 77a1520156879de714ee872c29b1387967bcc5ce470b47d37087ea2a110f3875.json
│ │ │ ├── 780977bca042ffdde1cca2811a3a46c36bd3fcff2723c94ac527428113131c64.json
│ │ │ ├── 7be7f428e6acfe4c99370707cc7de9253f801feff1564088f2584d5dc8c42a89.json
│ │ │ ├── 7c4fc90402a7836b364425c33cc2ffc98e7634391ebe78143df03c88945d8988.json
│ │ │ ├── 7e9bad82d0eb642b739dcf11e86c2fcf2005dfb12c94478af143deb9a8129bfa.json
│ │ │ ├── 8055bf9fe36a9fa183d0c5dc152684e65410d58c5634e544e8f38f6c9216f6ad.json
│ │ │ ├── 82833f6235cb6559bad38e93372d42284eb4e9097e90519337266113da2495bf.json
│ │ │ ├── 828dacee98d6f71fcf53c8a051b3bf1bfc9d94c272a79ed8539801e3fb90b3df.json
│ │ │ ├── 84281ba94e70a71d52b5179cf2e933f8412da3a9eaf5e7177b815f8fadd0afc5.json
│ │ │ ├── 846ba1087a1ee6b5cda2ebdf22d0bc61ad9175b21aba29783429277288dbfaaf.json
│ │ │ ├── 86e57806cd08cf765ce1f56f0638dd321e93c653bb6613aa35d7ad375808433c.json
│ │ │ ├── 88e349e879d601e1f5c75f3b019ffb4958e90639317ba893c54f82b0cc9066c2.json
│ │ │ ├── 8ffb83ce6f13d2f97d0197c7381748e9b66956c13f0158bc519350b7dd1b9ad0.json
│ │ │ ├── 908e0129cefe129d0b562243460fe20793c84876244cef2e99ca1becf8cf6a1f.json
│ │ │ ├── 940d0349821b0fb7f2c3effcb81fd13bb1c046daa6f6c5f2cd1b157058c55877.json
│ │ │ ├── 957f37be1d5d6a5207f90ad07704f8423c915c5bcdfcd130acc85a388fc89d19.json
│ │ │ ├── 96c3ba545db191ae5811d5dfdde74e7c6396a932b759513c94160cb7b7c81fc8.json
│ │ │ ├── 9766508e5a013c3d3cbb2602719391199e90af5720c9782a77ca71565aa346b1.json
│ │ │ ├── 9880af8b991c19abe5ed019ad7b7291b8b281ed9dcb894a00c3fd8259a59b4a4.json
│ │ │ ├── 996411678d23c3cf8a0e6c1c5076701e8bf5f3dad21ca907ec4116d15366a668.json
│ │ │ ├── 9cfb6e4f7b08a863675af032e7823c0523170f5201e686c5ef72b39c04b07a12.json
│ │ │ ├── 9eabdb0cacba38c8fb6b3e777932e06bd82172cdc0bcccf2fc0bfc23b5bd4974.json
│ │ │ ├── a31ab0e36b30049a602cc060d77c0715187f7c3838db91b8c20bfaa802f49899.json
│ │ │ ├── a823c9c28027c8d525714e6571bc3f3ca38f3bd0420aaf4f4921067cf0672475.json
│ │ │ ├── a8311f6e51027ea4606e35bf14d50edc1795e71b2d63cddeae8c7bb7ece215b8.json
│ │ │ ├── add004e78a62164d7565c102eb825debea20811f7fe76afa0567a1b6b289f264.json
│ │ │ ├── aeaae7b60339b1283cab517b4ab42806c64933c272d007a68431988ba8e0edb5.json
│ │ │ ├── b26c22033963df1727407c1fa6c79d8241bfc16056e7a8d3c4383fb2d050d3cd.json
│ │ │ ├── b26e251bde84a040dbb2d1a4f700ebcd761f51a124a98a104704e82537e320eb.json
│ │ │ ├── b34cb0a1a28e753a900b3d23f01d3810f9c06635b0650d34be4ec9d98daa8fbb.json
│ │ │ ├── b543952627caaf0a5c52bec09b33fae93443fca7234365680bbe75acc260dd1c.json
│ │ │ ├── b59999c2fd322c241c7bd37e6ed0a95c9124cbb79f8424f1fa7cb2e92fe63817.json
│ │ │ ├── b82d5f6840b4930eb62fdb1c7b3a58124fff805e529ede7684516a65322fcae0.json
│ │ │ ├── bb140cfe12cfb3e32dc9815393035fc33c7649c2faeeccb82cdee56a930ea635.json
@@ -368,24 +410,33 @@ khatibi
│ │ │ ├── c72226544540cc75217ba02f1d09d989eb810d879409ac086c76245f27220cfa.json
│ │ │ ├── c795f43bd1f10cf666ff987d18af538b2164c1683629ac689f0a54cb875118be.json
│ │ │ ├── c7eb97fa16701c6d9ee531fa032479a7c771292f3e9d8921d3ea12a4897fd43a.json
│ │ │ ├── c846b5de286163b5eed16ff6d6e00f2038f30bf7edd2ab3f8d831369e190ae18.json
│ │ │ ├── cdc5059cf09416d24d03fe57a48af5219727866c84b8109d986456fbaa789dc4.json
│ │ │ ├── cf6668c54cd4f65d268a17df44f100e1cb77f91eae90dd2dc052564731b2f195.json
│ │ │ ├── d1289d5406d360e3ff617e213db4e262cd43a32711a61aba97bdcd0a906cbcb5.json
│ │ │ ├── d3456cd690135f9630e6b0bb79fef5d205804c7acabffc61cbd5ac4032b17bc7.json
│ │ │ ├── d88e1e365cafd5d26ce47c2a0fe1f067dc3bfe5ceed1f75a21393fae950be939.json
│ │ │ ├── d94bebfe941bb0acc30b633423eaee11690652709e5f8f28d157473d31a07ee3.json
│ │ │ ├── db68b8db5217e2295eb1195760d7352960627da044a4c4f8f6b89c67ad2f8f57.json
│ │ │ ├── dbbdad9d992922aff62ad3262a531716cfc32e2f15f600773b9a0cf10545058d.json
│ │ │ ├── dc0344515d1b3ffe8643560516b88418be0b306fba56101b8a630ed73d48db46.json
│ │ │ ├── e16f78807ff202fa2c81867b321acfacede2d24cc785856eef0633d8d352796b.json
│ │ │ ├── e1a7be59904a3dc503c5b12bddc7604c6783b02462c8ec76834c65c5610c3bda.json
│ │ │ ├── e3ee8d9942c74a74be83ca00d5438e4a1e1cb5b23f55d95d308a42954e4757f0.json
│ │ │ ├── e9589ded446a5befb285a0a2e93a182ca9431c76661e68c9210ed252580dd7f0.json
│ │ │ ├── e9e14d581ed59799b2d83c67402e8555b5b59e7d49425950ec490422bd0307d9.json
│ │ │ ├── ec07e022005d3513838eee7e6618ff060e25d914cab7745aa6172f22d4089497.json
│ │ │ ├── ecd9c1dbfa0a72e253adb680ae7088eff24c26360e53a65862b274ea1281ceae.json
│ │ │ ├── ee450e6251e62d8972efb53bc35145c4a65050a7b6db2954b84bda42fca7b704.json
│ │ │ ├── ee557c6ed214858651cd3ebceb5e0fd00999354a4c54bb379f26c64b48b509ab.json
│ │ │ ├── efd113025c3fb3c0becaecd8090e8494b13d92ff3c49220d54665b3a8af586af.json
│ │ │ ├── f1af209a02f2ec5c5a344e3fa7dd8ad7d443e0479e55f5465449c0a2eed314a1.json
│ │ │ ├── f3a6285dcf9eb39df500bc0b76b03b5941103e8b90c06ffaeab1b3129f18fedc.json
│ │ │ ├── f537b36e2ee33d1303c10e950a6910cc7fd954c7fce3220be2c95d75d70dfd40.json
│ │ │ ├── f76726d2898f2a3ba7168d3fb77c21087432eb9c200631b664ecb3b1837aaf17.json
│ │ │ └── fc6ae63ae2639372c4ae4fbe9718feca814261dda099ba58bb780d14c69086cd.json
│ │ │ ├── fc6ae63ae2639372c4ae4fbe9718feca814261dda099ba58bb780d14c69086cd.json
│ │ │ ├── fd714e03c631694d30457ac529d05d8d131c183665e086246da607f752dea5eb.json
│ │ │ └── ff862e0ffad7bba911716072ae82f268240d82105ffefe78c7714a160c4c31ba.json
│ │ ├── semantic
│ │ │ └── p5e80268fecd6
│ │ │ ├── 0c4f87b180cd5b0576b197ff5cb0e9cc5dfd8d82d97d618036df00c7955fdbac.json
@@ -451,6 +502,7 @@ khatibi
│ │ │ ├── 7edbffbd4455d071df034927a69faaf76f8e8bbf6495921ce2b70eea1abe8e4f.json
│ │ │ ├── 8a79c80fca0ed04957e7b4df749ab80b5d0f4ce3d0ae032a47d51d4a2fbfcd82.json
│ │ │ ├── 8f60bda631cbe7457886d1d574b2e3d2850034f469592a3e8c333f0623a2d3c6.json
│ │ │ ├── 908e0129cefe129d0b562243460fe20793c84876244cef2e99ca1becf8cf6a1f.json
│ │ │ ├── 92e1d82ed4571249c09163413ceae360dd4d7d03c162c554f3b5a47e94474cf1.json
│ │ │ ├── 9897fa1997a41e155ed8de922722238e1f684f5c77a7f56405c8ac7cd1aa376f.json
│ │ │ ├── 9b674bec71dfe6e133af9fb4e35a421fa56cecd230544008ed83a44e6eaae3bf.json
@@ -466,6 +518,7 @@ khatibi
│ │ │ ├── c97d49fed39b9aca8dea35a49c3e031a641420e4452e61d1ea2a912e899f6dd9.json
│ │ │ ├── cacf6773249daafca21d21004106209d86aa215140de071c64363aff4d9e5431.json
│ │ │ ├── cfb57017bc764ee7e9011b52f1e44ad2f41b2670778af73232ecfc309b406a0d.json
│ │ │ ├── d94bebfe941bb0acc30b633423eaee11690652709e5f8f28d157473d31a07ee3.json
│ │ │ ├── dc89fcddf8c6f20bc9f52ae15e5cc8b226320d2776dc8f27be16894e53165013.json
│ │ │ ├── dd78862d0f7862653c702e86f7acfabfe33ec5d85d40ba764138c0d1784f7b06.json
│ │ │ ├── de67f63e3f49d4ee32811dd67676da33a1e584f671d396fca130f3681d54bd76.json
@@ -484,10 +537,31 @@ khatibi
│ ├── .graphify_labels.json.sig
│ ├── .graphify_root
│ ├── .graphify_semantic_marker
│ ├── backup-graph-preibackupignore-20261009-191817.json
│ ├── backup-graph-presemantic-20261009-193633.json
│ ├── backup-graph-preupdate-20261009-191412.json
│ ├── backup-manifest-20261009-194120.json
│ ├── graph.html
│ ├── graph.json
│ ├── GRAPH_REPORT.md
│ └── manifest.json
├── integrations
│ ├── bale
│ │ ├── __init__.py
│ │ └── providers.py
│ ├── sms
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── behin.py
│ │ ├── console.py
│ │ ├── factory.py
│ │ ├── http.py
│ │ ├── normalize.py
│ │ ├── providers.py
│ │ └── sms0098.py
│ ├── __init__.py
│ ├── announcements.py
│ └── voip.py
├── khatibi
│ ├── __init__.py
│ ├── asgi.py
@@ -504,6 +578,7 @@ khatibi
│ ├── test_factors.py
│ ├── test_inventory.py
│ ├── test_models_contract.py
│ ├── test_sms.py
│ ├── test_urls_contract.py
│ └── test_view_smoke.py
├── .dockerignore
@@ -512,6 +587,7 @@ khatibi
├── AGENTS.md
├── check_finance.py
├── db.json
├── docker-compose.yml
├── Dockerfile
├── entrypoint.sh
├── financial_audit_20260816_151225.csv
@@ -525,4 +601,7 @@ khatibi
├── reconciliation_tool.py
├── requirements.txt
├── requirements2.txt
└── ripwire - README.md
├── ripwire - README.md
├── run.sh
├── screencapture-localhost-8000-panel-sms-templates-2026-10-09-23_01_18.png
└── stop.sh
+100
View File
@@ -75,3 +75,103 @@ class StoreRoomAdmin(admin.ModelAdmin):
"""نمایش تعداد مالکین در لیست اصلی انبارها"""
return obj.owners.count()
get_owners_count.short_description = "تعداد مالکین"
# ============================================================
# مدیریت پیامک (SMS)
# ============================================================
from django import forms
from django.contrib import messages
class SMSProviderConfigForm(forms.ModelForm):
class Meta:
model = SMSProviderConfig
fields = '__all__'
widgets = {
'password': forms.PasswordInput(render_value=True),
'api_token': forms.PasswordInput(render_value=True),
}
@admin.register(SMSProviderConfig)
class SMSProviderConfigAdmin(admin.ModelAdmin):
form = SMSProviderConfigForm
list_display = ('provider', 'is_enabled', 'sender', 'username',
'allow_backlog', 'backlog_max', 'max_per_run',
'send_interval_seconds', 'updated_at')
readonly_fields = ('updated_at',)
fieldsets = (
("اتصال سرویس‌دهنده", {
'fields': ('provider', 'is_enabled', 'api_url', 'username', 'password',
'sender', 'api_token', 'test_message', 'timeout_seconds'),
}),
("وب‌سرویس ۰۰۹۸ (فقط provider وب‌سرویس)", {
'fields': ('webservice_url', 'panel_no'),
}),
("کنترل جریان ارسال", {
'fields': ('scan_since', 'allow_backlog', 'backlog_max',
'max_per_run', 'send_interval_seconds', 'updated_at'),
}),
)
def has_add_permission(self, request):
# سینگلتون: فقط ویرایش رکورد موجود.
return not SMSProviderConfig.objects.exists()
def has_delete_permission(self, request, obj=None):
return False
@admin.register(SMSTemplate)
class SMSTemplateAdmin(admin.ModelAdmin):
list_display = ('title', 'sms_type', 'is_enabled', 'auto_send', 'offset_days',
'repeat_interval_days', 'repeat_interval_months')
list_editable = ('is_enabled', 'auto_send')
list_filter = ('sms_type', 'is_enabled', 'auto_send')
search_fields = ('title', 'text')
readonly_fields = ('updated_at',)
@admin.register(CustomerSMSSetting)
class CustomerSMSSettingAdmin(admin.ModelAdmin):
list_display = ('customer', 'sms_type', 'is_enabled')
list_filter = ('sms_type', 'is_enabled')
raw_id_fields = ('customer',)
search_fields = ('customer__name_family',)
@admin.register(SMSQueue)
class SMSQueueAdmin(admin.ModelAdmin):
list_display = ('id', 'customer', 'sms_type', 'phone_number', 'status',
'delivery_status', 'attempts', 'created_at', 'sent_at')
list_filter = ('status', 'sms_type', 'delivery_status')
raw_id_fields = ('customer', 'created_by')
search_fields = ('phone_number', 'message', 'dedup_key')
readonly_fields = ('created_at', 'updated_at', 'sent_at')
actions = ['approve_selected', 'retry_selected']
@admin.action(description="تأیید موارد انتخاب‌شده (ارسال توسط worker)")
def approve_selected(self, request, queryset):
updated = queryset.filter(status=SMSQueue.Status.PENDING).update(
status=SMSQueue.Status.APPROVED, next_retry_at=None,
)
self.message_user(request, f"{updated} پیامک تأیید شد.", messages.SUCCESS)
@admin.action(description="ارسال مجدد موارد انتخاب‌شده")
def retry_selected(self, request, queryset):
updated = queryset.update(status=SMSQueue.Status.APPROVED, next_retry_at=None, last_error='')
self.message_user(request, f"{updated} پیامک برای ارسال مجدد تأیید شد.", messages.SUCCESS)
@admin.register(SMSLog)
class SMSLogAdmin(admin.ModelAdmin):
list_display = ('id', 'queue', 'attempt_no', 'success', 'provider_code', 'created_at')
list_filter = ('success',)
raw_id_fields = ('queue',)
readonly_fields = ('queue', 'attempt_no', 'success', 'provider_id', 'provider_code', 'error', 'created_at')
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
+33
View File
@@ -1,6 +1,39 @@
import sys
from django.apps import AppConfig
def make_std_streams_unicode_safe():
"""استریم‌های استاندارد را در برابر متن غیر-ASCII مقاوم می‌کند.
کنسول ویندوز با کدپیج cp1252 کار می‌کند و نوشتن متن فارسی روی آن
``UnicodeEncodeError`` می‌دهد (پیام خطا: ``'charmap' codec can't encode ...``).
رخداد واقعی که این تابع برایش نوشته شد: ``print`` متن فارسی داخل provider کنسولِ
پیامک استثنا می‌داد، worker آن را به‌عنوان «ارسال ناموفق» ثبت می‌کرد و پیامک در صف
``failed`` می‌ماند — در حالی که ارسال هیچ مشکلی نداشت. یک استثنای صرفاً نمایشی
نباید هرگز نتیجهٔ یک عملیات کسب‌وکار را عوض کند.
پس اینجا یک‌بار برای همیشه استریم‌ها به UTF-8 با ``errors='replace'`` تنظیم می‌شوند:
هیچ چاپ فارسی‌ای پروسه را نمی‌شکند و فایل‌های لاگ هم فارسی را درست نگه می‌دارند.
"""
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is None:
continue
try:
reconfigure(encoding="utf-8", errors="replace")
except Exception:
# جریان‌های جایگزین (مثلاً test runner یا jupyter) ممکن است پشتیبانی نکنند.
pass
class AdminPanelConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'admin_panel'
def ready(self):
# پیش از هر کامند/سیگنال: چاپ امن متن فارسی روی کنسول ویندوز.
make_std_streams_unicode_safe()
# ثبت سیگنال‌ها (busy_timeout برای SQLite).
from . import signals # noqa: F401
+4
View File
@@ -3,6 +3,7 @@ import os
import datetime
from .models import *
from .sms.notices import resolve as resolve_sms_notice
def dashBoxData(request):
@@ -53,4 +54,7 @@ def dashBoxData(request):
'bazdid': bazdid
}
# پیام نتیجهٔ عملیات پیامک (جایگزین flash message) از ?notice=<code>.
data.update(resolve_sms_notice(request))
return data
@@ -0,0 +1,30 @@
"""پاک‌سازی یک‌بارهٔ فیلدهای تلفن مشتریان (phone1/phone2/phone3).
روی دیتابیس production (سرور اصلی) هم قابل اجرا است؛ idempotent است و اجرای
دوباره‌اش تغییری ایجاد نمی‌کند.
python manage.py fix_customer_phones --dry-run # فقط گزارش، بدون تغییر
python manage.py fix_customer_phones # اجرای واقعی
"""
from django.core.management.base import BaseCommand
from admin_panel.sms.phone_cleanup import clean_all
class Command(BaseCommand):
help = "استخراج و استانداردسازی شماره‌های قدیمی مشتریان؛ متن‌های حذف‌شده در فیلد توضیحات می‌مانند."
def add_arguments(self, parser):
parser.add_argument(
"--dry-run", action="store_true",
help="فقط گزارش کن که چه چیزی تغییر می‌کند؛ چیزی ذخیره نکن.",
)
def handle(self, *args, **options):
dry = options["dry_run"]
stats = clean_all(dry_run=dry)
prefix = "شبیه‌سازی (بدون تغییر): " if dry else "پاک‌سازی انجام شد: "
self.stdout.write(
f"{prefix}{stats['customers']} مشتری، {stats['fields']} فیلد اصلاح شد، "
f"{stats['noted']} یادداشت ثبت شد."
)
@@ -0,0 +1,118 @@
"""استعلام وضعیت تحقق پیامک‌های ارسال‌شده (رسیده/نرسیده به گوشی).
فقط برای provider وب‌سرویس 0098SMS معنا دارد؛ تنها آن provider با
``SendSMSWithID`` شناسهٔ پیامک (smsid) برمی‌گرداند و ``smsdeliveryState``
وضعیت تحویل را می‌دهد. برای سایر providerها دستور no-op است.
"""
import logging
import signal
import time
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.db.models import Q
from django.utils import timezone
from admin_panel.models import SMSProviderConfig, SMSQueue
from integrations.sms.factory import get_sms_provider
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "استعلام وضعیت تحویل پیامک‌های ارسال‌شده از وب‌سرویس 0098SMS."
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._stop = False
def add_arguments(self, parser):
parser.add_argument("--once", action="store_true", help="یک دور پردازش و خروج")
parser.add_argument("--sleep", type=float, default=30.0, help="فاصلهٔ بین دورها (ثانیه)")
parser.add_argument("--batch", type=int, default=100, help="تعداد ردیف در هر دور")
parser.add_argument(
"--min-age", type=int, default=60,
help="حداقل فاصله (ثانیه) از زمان ارسال پیش از نخستین استعلام",
)
def handle(self, *args, **options):
signal.signal(signal.SIGTERM, self._handle_signal)
signal.signal(signal.SIGINT, self._handle_signal)
once = options["once"]
sleep_seconds = options["sleep"]
self._emit("استعلام دلیوری پیامک آغاز شد.", style=self.style.SUCCESS)
while not self._stop:
try:
processed = self._drain_batch(
options["batch"], options["min_age"],
)
except Exception:
logger.exception("خطای غیرمنتظره در یک دور از استعلام دلیوری؛ دور بعد ادامه می‌دهد.")
processed = 0
if once:
break
if processed == 0:
self._sleep_interruptible(sleep_seconds)
self._emit("استعلام دلیوری پیامک متوقف شد.", style=self.style.SUCCESS)
# ------------------------------------------------------------------ helpers
def _handle_signal(self, signum, frame):
self._stop = True
def _emit(self, text, *, style=None, err=False):
"""چاپ با تحمل کدگذاری محدود کنسول (مثل worker پیامک)."""
stream = self.stderr if err else self.stdout
style = style or (lambda value: value)
for payload in (text, text.encode("ascii", "replace").decode("ascii")):
try:
stream.write(style(payload))
return
except (UnicodeEncodeError, ValueError, OSError):
continue
def _sleep_interruptible(self, seconds):
deadline = time.monotonic() + max(0, seconds)
while not self._stop and time.monotonic() < deadline:
time.sleep(min(0.5, deadline - time.monotonic()))
def _drain_batch(self, batch, min_age):
config = SMSProviderConfig.load()
if config.provider != "sms0098ws":
# smsid تنها متعلق به این provider است؛ استعلام با بقیه بی‌معناست.
logger.info("provider فعلی رهگیری دلیوری ندارد؛ استعلام رد شد.")
return 0
now = timezone.now()
cutoff = now - timedelta(seconds=max(0, min_age))
# پیامک‌های ارسال‌شده که شناسه دارند و وضعیت‌شان هنوز نهایی نشده است.
query = SMSQueue.objects.filter(
Q(status=SMSQueue.Status.SENT, provider_id__gt="", sent_at__lte=cutoff),
Q(delivery_checked_at__isnull=True)
| Q(delivery_status__in=["", SMSQueue.DeliveryStatus.PENDING,
SMSQueue.DeliveryStatus.UNKNOWN]),
)
try:
provider = get_sms_provider(config)
except Exception:
logger.exception("ساخت provider پیامک ناموفق بود؛ این دور رد شد.")
return 0
processed = 0
for row in query.order_by("sent_at")[:batch]:
if self._stop:
break
result = provider.get_delivery_status(row.provider_id)
if result is None:
# خطای ارتباط/تنظیمات: ردیف را رد کن تا دور بعد دوباره تلاش شود.
continue
row.delivery_status = result.status
row.delivery_checked_at = timezone.now()
row.save(update_fields=["delivery_status", "delivery_checked_at", "updated_at"])
processed += 1
self._emit(
f" دلیوری #{row.pk} ({row.provider_id}): {result.status} "
f"{'(نهایی)' if result.is_final else '(در انتظار)'}",
)
return processed
@@ -0,0 +1,50 @@
"""زمان‌بند/اسکنر: پیدا کردن موارد سررسیدشده و قرار دادن آن‌ها در صف پیامک."""
import logging
import signal
import time
from django.core.management.base import BaseCommand
from admin_panel.sms.scanner import run_all
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "اسکن یادآوری‌های پیامک و قرار دادن آن‌ها در صف."
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._stop = False
def add_arguments(self, parser):
parser.add_argument("--loop", action="store_true", help="اجرای پیوسته")
parser.add_argument("--interval", type=float, default=300.0, help="فاصلهٔ بین اجراها (ثانیه)")
def handle(self, *args, **options):
signal.signal(signal.SIGTERM, self._handle_signal)
signal.signal(signal.SIGINT, self._handle_signal)
loop = options["loop"]
interval = options["interval"]
while not self._stop:
results = run_all()
summary = ", ".join(f"{name}={count}" for name, count in results.items())
self._emit(f"اسکن پیامک: {summary}")
if not loop:
break
# خواب قابل‌قطع
slept = 0.0
while slept < interval and not self._stop:
time.sleep(min(1.0, interval - slept))
slept += 1.0
def _handle_signal(self, signum, frame):
self._stop = True
def _emit(self, text):
"""نوشتن خروجی با تحمل کدگذاری محدود کنسول (مثلاً cp1252 در ویندوز)."""
try:
self.stdout.write(text)
except UnicodeEncodeError:
self.stdout.write(text.encode("ascii", "replace").decode("ascii"))
@@ -0,0 +1,179 @@
"""worker تخلیه‌کنندهٔ صف پیامک.
در یک پردازش جدا اجرا می‌شود تا کندی/خطای سامانه پیامک به برنامه اصلی ضربه نزند.
"""
import logging
import signal
import time
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.db.models import Q
from django.utils import timezone
from admin_panel.models import SMSLog, SMSProviderConfig, SMSQueue
from integrations.sms.factory import get_sms_provider
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "تخلیهٔ صف پیامک و ارسال از طریق سرویس‌دهندهٔ تنظیم‌شده."
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._stop = False
def add_arguments(self, parser):
parser.add_argument("--once", action="store_true", help="یک دور پردازش و خروج")
parser.add_argument("--sleep", type=float, default=3.0, help="فاصلهٔ بین دورها (ثانیه)")
parser.add_argument(
"--batch", type=int, default=None,
help="تعداد پیامک در هر دور (پیش‌فرض: max_per_run از تنظیمات پنل)",
)
def handle(self, *args, **options):
signal.signal(signal.SIGTERM, self._handle_signal)
signal.signal(signal.SIGINT, self._handle_signal)
once = options["once"]
sleep_seconds = options["sleep"]
batch = options["batch"]
self._emit("worker پیامک آغاز شد.", style=self.style.SUCCESS)
while not self._stop:
# یک دور خراب (خطای دیتابیس، provider، …) نباید کل worker را از پا دربیاورد؛
# در غیر این‌صورت صف تا ری‌استارت دستی راکد می‌ماند.
try:
processed = self._drain_batch(batch)
except Exception:
logger.exception("خطای غیرمنتظره در یک دور از worker پیامک؛ دور بعد ادامه می‌دهد.")
processed = 0
if once:
break
if processed == 0:
self._sleep_interruptible(sleep_seconds)
self._emit("worker پیامک متوقف شد.", style=self.style.SUCCESS)
def _handle_signal(self, signum, frame):
self._stop = True
def _emit(self, text, *, style=None, err=False):
"""نوشتن خروجی با تحمل کدگذاری محدود کنسول (مثلاً cp1252 در ویندوز).
چاپ، عملیات جانبی است: اگر کنسول نتواند متن را بنویسد نباید ارسال‌ها را
متوقف یا «ناموفق» کند. اول متن کامل، بعد نسخهٔ ASCII-شده، و در نهایت سکوت.
"""
stream = self.stderr if err else self.stdout
style = style or (lambda value: value)
for payload in (text, text.encode("ascii", "replace").decode("ascii")):
try:
stream.write(style(payload))
return
except (UnicodeEncodeError, ValueError, OSError):
continue
def _drain_batch(self, batch):
config = SMSProviderConfig.load()
if not config.is_enabled:
logger.info("ارسال پیامک از تنظیمات غیرفعال است؛ صف دست‌نخورده می‌ماند.")
return 0
if not batch:
batch = config.max_per_run or 20
interval = config.send_interval_seconds or 0
now = timezone.now()
# تنها پیامک‌هایی که مدیر صراحتاً تأیید کرده ارسال می‌شوند؛ موارد «در
# انتظار تأیید» دست‌نخورده در صف بازبینی می‌مانند.
candidates = SMSQueue.objects.filter(
status=SMSQueue.Status.APPROVED,
).filter(
Q(next_retry_at__isnull=True) | Q(next_retry_at__lte=now),
).order_by("created_at")[:batch]
try:
provider = get_sms_provider(config)
except Exception:
logger.exception("ساخت provider پیامک ناموفق بود؛ این دور رد شد.")
return 0
processed = 0
for item in candidates:
if self._stop:
break
# claim اتمیک مثبت‌گرا (SQLite از select_for_update پشتیبانی نمی‌کند).
claimed = SMSQueue.objects.filter(
pk=item.pk, status=SMSQueue.Status.APPROVED,
).update(status=SMSQueue.Status.PROCESSING)
if not claimed:
continue
try:
row = SMSQueue.objects.get(pk=item.pk)
except SMSQueue.DoesNotExist:
# رکورد بین انتخاب و claim حذف شده (مثلاً پاک‌سازی دستی پنل).
continue
# فاصلهٔ بین پیامک‌ها: پیش از ارسال مورد بعدی صبر می‌کنیم (نه بعد از آخرین).
if processed and interval:
self._sleep_interruptible(interval)
if self._stop:
# مورد claim‌شده را به صف برگردان تا از دست نرود.
SMSQueue.objects.filter(
pk=row.pk, status=SMSQueue.Status.PROCESSING,
).update(status=SMSQueue.Status.APPROVED)
break
processed += 1
self._send(provider, row)
return processed
def _sleep_interruptible(self, seconds):
"""خواب قابل‌قطع تا سیگنال توقف سریع پاسخ داده شود."""
remaining = float(seconds)
while remaining > 0 and not self._stop:
step = min(0.5, remaining)
time.sleep(step)
remaining -= step
def _send(self, provider, row):
try:
result = provider.send_message(row.phone_number, row.message)
except Exception as exc: # provider نباید worker را از پا دربیاورد
logger.exception("استثنای غیرمنتظره در ارسال پیامک #%s", row.pk)
result = None
error = str(exc)
else:
error = getattr(result, "error", "") or ""
result = result if getattr(result, "success", False) else None
now = timezone.now()
row.attempts = (row.attempts or 0) + 1
if result is not None:
row.status = SMSQueue.Status.SENT
row.sent_at = now
row.provider_id = getattr(result, "provider_id", "") or ""
row.provider_code = getattr(result, "provider_code", "") or ""
# شناسه (smsid) داریم ⇒ اهل رهگیری دلیوری؛ دستور sms_delivery پر می‌کند.
row.delivery_status = (
SMSQueue.DeliveryStatus.PENDING if row.provider_id else ""
)
row.last_error = ""
row.save()
SMSLog.objects.create(
queue=row, attempt_no=row.attempts, success=True,
provider_id=row.provider_id, provider_code=row.provider_code,
)
self._emit(f" ارسال موفق #{row.pk} -> {row.phone_number}")
return
row.last_error = error[:2000]
if row.attempts >= (row.max_attempts or 5):
row.status = SMSQueue.Status.FAILED
else:
# همچنان تأییدشده می‌ماند تا تلاش خودکار بعدی ادامه یابد.
row.status = SMSQueue.Status.APPROVED
backoff = min(3600, 30 * (2 ** row.attempts))
row.next_retry_at = now + timedelta(seconds=backoff)
row.save()
SMSLog.objects.create(
queue=row, attempt_no=row.attempts, success=False, error=row.last_error,
)
self._emit(f" ارسال ناموفق #{row.pk} (تلاش {row.attempts}): {row.last_error[:120]}", err=True)
+124
View File
@@ -0,0 +1,124 @@
# Generated by Django 3.2.25 on 2026-10-09 16:52
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('admin_panel', '0056_auto_20260731_1824'),
]
operations = [
migrations.CreateModel(
name='SMSProviderConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('provider', models.CharField(choices=[('sms0098', '0098 (SOAP/zeep)'), ('http', 'HTTP عمومی'), ('console', 'Console (تست/دیباگ)')], default='sms0098', max_length=20, verbose_name='سرویس\u200cدهنده پیامک')),
('is_enabled', models.BooleanField(default=True, help_text='در صورت غیرفعال بودن، worker هیچ پیامکی ارسال نمی\u200cکند.', verbose_name='فعال بودن ارسال پیامک')),
('api_url', models.CharField(blank=True, default='', max_length=500, verbose_name='آدرس سرویس (WSDL یا URL)')),
('username', models.CharField(blank=True, default='', max_length=100, verbose_name='نام کاربری')),
('password', models.CharField(blank=True, default='', max_length=200, verbose_name='رمز عبور')),
('sender', models.CharField(blank=True, default='', max_length=50, verbose_name='شماره خط فرستنده')),
('api_token', models.CharField(blank=True, default='', max_length=255, verbose_name='توکن API (HTTP)')),
('timeout_seconds', models.PositiveIntegerField(default=30, verbose_name='مهلت ارتباط (ثانیه)')),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'تنظیمات سامانه پیامک',
'verbose_name_plural': 'تنظیمات سامانه پیامک',
'permissions': [('ManageSMS', 'مدیریت پیامک')],
},
),
migrations.CreateModel(
name='SMSTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sms_type', models.CharField(choices=[('welcome', 'خوش\u200cآمدگویی (ایجاد مشتری)'), ('cheque_due', 'یادآوری سررسید چک'), ('nesiye_due', 'یادآوری سررسید نسیه'), ('cheque_registration', 'یادآوری تأمین موجودی چک'), ('debt_due', 'یادآوری بدهی (سررسید وصول مطالبات)'), ('visit', 'یادآوری بازدید خدمات'), ('survey', 'نظرسنجی پس از فاکتور'), ('custom', 'پیام سفارشی')], max_length=40, unique=True, verbose_name='نوع پیامک')),
('title', models.CharField(max_length=100, verbose_name='عنوان')),
('text', models.TextField(help_text='متغیرهای قابل استفاده: {name} نام مشتری، {amount} مبلغ، {amount_sep} مبلغ با جداکننده، {date} تاریخ جلالی، {days} تعداد روز، {check_row} شماره ردیف چک، {owner_name} نام مالک چک.', verbose_name='متن پیامک')),
('is_enabled', models.BooleanField(default=True, verbose_name='فعال')),
('offset_days', models.PositiveSmallIntegerField(default=2, verbose_name='چند روز قبل از سررسید ارسال شود')),
('repeat_interval_days', models.PositiveSmallIntegerField(blank=True, help_text='خالی بگذارید برای ارسال یک\u200cباره؛ مقدار بدهید برای ارسال تکرارشونده.', null=True, verbose_name='فاصله تکرار (روز)')),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'قالب پیامک',
'verbose_name_plural': 'قالب\u200cهای پیامک',
},
),
migrations.AddField(
model_name='phonenumber',
name='is_primary',
field=models.BooleanField(default=False, verbose_name='شماره اصلی (گیرنده پیامک)'),
),
migrations.CreateModel(
name='SMSQueue',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sms_type', models.CharField(blank=True, choices=[('welcome', 'خوش\u200cآمدگویی (ایجاد مشتری)'), ('cheque_due', 'یادآوری سررسید چک'), ('nesiye_due', 'یادآوری سررسید نسیه'), ('cheque_registration', 'یادآوری تأمین موجودی چک'), ('debt_due', 'یادآوری بدهی (سررسید وصول مطالبات)'), ('visit', 'یادآوری بازدید خدمات'), ('survey', 'نظرسنجی پس از فاکتور'), ('custom', 'پیام سفارشی')], default='', max_length=40, verbose_name='نوع')),
('phone_number', models.CharField(max_length=20, verbose_name='شماره گیرنده')),
('message', models.TextField(verbose_name='متن')),
('status', models.CharField(choices=[('pending', 'در انتظار'), ('processing', 'در حال پردازش'), ('sent', 'ارسال شده'), ('failed', 'ناموفق'), ('cancelled', 'لغو شده')], db_index=True, default='pending', max_length=12, verbose_name='وضعیت')),
('attempts', models.PositiveSmallIntegerField(default=0, verbose_name='تعداد تلاش')),
('max_attempts', models.PositiveSmallIntegerField(default=5, verbose_name='حداکثر تلاش')),
('next_retry_at', models.DateTimeField(blank=True, null=True, verbose_name='زمان تلاش بعدی')),
('last_error', models.TextField(blank=True, default='', verbose_name='آخرین خطا')),
('provider_id', models.CharField(blank=True, default='', max_length=100)),
('provider_code', models.CharField(blank=True, default='', max_length=50)),
('dedup_key', models.CharField(blank=True, help_text='کلید یکتاسازی برای جلوگیری از ارسال تکراری.', max_length=120, null=True, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('sent_at', models.DateTimeField(blank=True, null=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
('customer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sms_queue', to='admin_panel.customer', verbose_name='مشتری')),
],
options={
'verbose_name': 'صف پیامک',
'verbose_name_plural': 'صف پیامک\u200cها',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='SMSLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attempt_no', models.PositiveSmallIntegerField(default=1, verbose_name='شماره تلاش')),
('success', models.BooleanField(default=False, verbose_name='موفق')),
('provider_id', models.CharField(blank=True, default='', max_length=100)),
('provider_code', models.CharField(blank=True, default='', max_length=50)),
('error', models.TextField(blank=True, default='', verbose_name='خطا')),
('created_at', models.DateTimeField(auto_now_add=True)),
('queue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='admin_panel.smsqueue', verbose_name='صف')),
],
options={
'verbose_name': 'لاگ پیامک',
'verbose_name_plural': 'لاگ\u200cهای پیامک',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='CustomerSMSSetting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sms_type', models.CharField(choices=[('welcome', 'خوش\u200cآمدگویی (ایجاد مشتری)'), ('cheque_due', 'یادآوری سررسید چک'), ('nesiye_due', 'یادآوری سررسید نسیه'), ('cheque_registration', 'یادآوری تأمین موجودی چک'), ('debt_due', 'یادآوری بدهی (سررسید وصول مطالبات)'), ('visit', 'یادآوری بازدید خدمات'), ('survey', 'نظرسنجی پس از فاکتور'), ('custom', 'پیام سفارشی')], max_length=40, verbose_name='نوع پیامک')),
('is_enabled', models.BooleanField(default=True, verbose_name='فعال')),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sms_settings', to='admin_panel.customer')),
],
options={
'verbose_name': 'تنظیم پیامک مشتری',
'verbose_name_plural': 'تنظیمات پیامک مشتریان',
},
),
migrations.AddIndex(
model_name='smsqueue',
index=models.Index(fields=['status', 'next_retry_at'], name='admin_panel_status_c227cc_idx'),
),
migrations.AlterUniqueTogether(
name='customersmssetting',
unique_together={('customer', 'sms_type')},
),
]
@@ -0,0 +1,115 @@
"""دادهٔ اولیه: متن پیش‌فرض قالب‌های پیامک + علامت‌گذاری شمارهٔ اصلی از فیلدهای قدیمی.
این مایگریشن idempotent است (get_or_create / update_if) و اجرای دوبارهٔ آن بی‌خطر است.
"""
from django.db import migrations
DEFAULT_TEMPLATES = [
{
"sms_type": "welcome",
"title": "خوش‌آمدگویی",
"offset_days": 0,
"repeat_interval_days": None,
"text": "{name} عزیز، از خرید شما سپاسگزاریم. به‌امید دیدار دوباره.",
},
{
"sms_type": "cheque_due",
"title": "یادآوری سررسید چک",
"offset_days": 2,
"repeat_interval_days": None,
"text": "{name} عزیز، {days} روز تا سررسید چک شما به مبلغ {amount_sep} ریال باقی مانده است. لطفاً نسبت به تأمین موجودی اقدام فرمایید.",
},
{
"sms_type": "nesiye_due",
"title": "یادآوری سررسید نسیه",
"offset_days": 2,
"repeat_interval_days": None,
"text": "{name} عزیز، {days} روز تا سررسید بدهی شما به مبلغ {amount_sep} ریال باقی مانده است.",
},
{
"sms_type": "cheque_registration",
"title": "یادآوری تأمین موجودی چک",
"offset_days": 2,
"repeat_interval_days": 3,
"text": "{name} عزیز، سررسید چک شما ({check_row}) نزدیک است. لطفاً موجودی بانکی حساب خود را تأمین فرمایید.",
},
{
"sms_type": "debt_due",
"title": "یادآوری بدهی",
"offset_days": 0,
"repeat_interval_days": None,
"text": "{name} عزیز، مبلغ {amount_sep} ریال بدهی شما باقی مانده است. لطفاً نسبت به تسویه اقدام فرمایید.",
},
{
"sms_type": "visit",
"title": "یادآوری بازدید خدمات",
"offset_days": 0,
"repeat_interval_days": None,
"text": "{name} عزیز، زمان بازدید دوره‌ای دستگاه شما فرا رسیده است. برای تعیین وقت با ما تماس بگیرید.",
},
{
"sms_type": "survey",
"title": "نظرسنجی",
"offset_days": 2,
"repeat_interval_days": None,
"text": "{name} عزیز، نظر شما دربارهٔ خدمات ما برایمان ارزشمند است. لطفاً در نظرسنجی شرکت فرمایید.",
},
{
"sms_type": "custom",
"title": "پیام سفارشی",
"offset_days": 0,
"repeat_interval_days": None,
"text": "",
},
]
def seed_templates(apps, schema_editor):
SMSTemplate = apps.get_model("admin_panel", "SMSTemplate")
for item in DEFAULT_TEMPLATES:
SMSTemplate.objects.get_or_create(
sms_type=item["sms_type"],
defaults={
"title": item["title"],
"text": item["text"],
"is_enabled": True,
"offset_days": item["offset_days"],
"repeat_interval_days": item["repeat_interval_days"],
},
)
def unseed_templates(apps, schema_editor):
SMSTemplate = apps.get_model("admin_panel", "SMSTemplate")
SMSTemplate.objects.filter(
sms_type__in=[t["sms_type"] for t in DEFAULT_TEMPLATES]
).delete()
def backfill_primary_phones(apps, schema_editor):
"""اگر هیچ شمارهٔ اصلی‌ای ثبت نشده، شمارهٔ مطابق phone1 هر مشتری را اصلی می‌کند."""
Customer = apps.get_model("admin_panel", "Customer")
PhoneNumber = apps.get_model("admin_panel", "PhoneNumber")
for customer in Customer.objects.all():
if PhoneNumber.objects.filter(customer=customer, is_primary=True).exists():
continue
phone1 = (customer.phone1 or "").strip()
if not phone1:
continue
PhoneNumber.objects.filter(customer=customer, phone_number=phone1).update(is_primary=True)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("admin_panel", "0057_sms"),
]
operations = [
migrations.RunPython(seed_templates, unseed_templates),
migrations.RunPython(backfill_primary_phones, noop),
]
@@ -0,0 +1,49 @@
# Generated by Django 3.2.25 on 2026-10-09 17:08
from django.db import migrations, models
def stamp_scan_since(apps, schema_editor):
"""روی نصب‌های موجود، خط شروع را روی «همین حالا» مهر می‌کند.
نتیجه: پس از به‌روزرسانی، موارد قدیمی موجود در دیتابیس یک‌باره در صف قرار نمی‌گیرند.
"""
from django.utils import timezone
SMSProviderConfig = apps.get_model('admin_panel', 'SMSProviderConfig')
SMSProviderConfig.objects.filter(scan_since__isnull=True).update(scan_since=timezone.now())
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0058_seed_sms_templates'),
]
operations = [
migrations.AddField(
model_name='smsproviderconfig',
name='allow_backlog',
field=models.BooleanField(default=False, help_text='اگر فعال باشد، مواردی که پیش از «خط شروع» واجد شرط شده\u200cاند هم (به\u200cاندازهٔ سقف زیر، در هر اجرا) ارسال می\u200cشوند. به\u200cصورت پیش\u200cفرض خاموش است.', verbose_name='اجازهٔ ارسال موارد قدیمی'),
),
migrations.AddField(
model_name='smsproviderconfig',
name='backlog_max',
field=models.PositiveIntegerField(default=0, help_text='فقط وقتی «اجازهٔ ارسال موارد قدیمی» روشن است معنا دارد. صفر = هیچ.', verbose_name='حداکثر موارد قدیمی در هر اجرا'),
),
migrations.AddField(
model_name='smsproviderconfig',
name='max_per_run',
field=models.PositiveIntegerField(default=200, help_text='سقف کلی صف\u200cکردن برای هر نوع پیامک در هر اجرای اسکنر.', verbose_name='حداکثر پیامک هر اسکنر در هر اجرا'),
),
migrations.AddField(
model_name='smsproviderconfig',
name='scan_since',
field=models.DateTimeField(blank=True, help_text='فقط مواردی که پنجرهٔ یادآوری\u200cشان از این لحظه به بعد باز می\u200cشود در صف قرار می\u200cگیرند؛ موارد قدیمی\u200cتر (قبل از این لحظه) نادیده گرفته می\u200cشوند. اگر خالی باشد، در نخستین اجرا به\u200cصورت خودکار روی «همین حالا» تنظیم می\u200cشود.', null=True, verbose_name='خط شروع ارسال'),
),
migrations.AddField(
model_name='smsproviderconfig',
name='send_interval_seconds',
field=models.PositiveIntegerField(default=3, help_text='worker پس از هر ارسال این مدت صبر می\u200cکند تا سرویس\u200cدهنده زیر بار نرود.', verbose_name='فاصلهٔ بین هر پیامک (ثانیه)'),
),
migrations.RunPython(stamp_scan_since, migrations.RunPython.noop),
]
@@ -0,0 +1,32 @@
# Generated by Django 3.2.25 on 2026-10-09 17:15
from django.db import migrations
def rename_manage_sms_permission(apps, schema_editor):
"""نام نمایشی پرمیشن را با قرارداد پروژه هم‌راستا می‌کند.
Django هنگام ``AlterModelOptions`` رکورد Permission موجود را تغییر نمی‌دهد؛
پس نام قبلی (فارسی) روی نصب‌های موجود باقی می‌ماند و باید صریحاً به‌روزرسانی شود.
"""
Permission = apps.get_model('auth', 'Permission')
Permission.objects.filter(
content_type__app_label='admin_panel',
content_type__model='smsproviderconfig',
codename='ManageSMS',
).update(name='ManageSMS')
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0059_sms_flow_control'),
]
operations = [
migrations.AlterModelOptions(
name='smsproviderconfig',
options={'permissions': [('ManageSMS', 'ManageSMS')], 'verbose_name': 'تنظیمات سامانه پیامک', 'verbose_name_plural': 'تنظیمات سامانه پیامک'},
),
migrations.RunPython(rename_manage_sms_permission, migrations.RunPython.noop),
]
@@ -0,0 +1,119 @@
"""یکسان‌سازی شماره‌های اصلی + زمان‌بندی اختصاصی هر نوع پیامک.
* «شمارهٔ اصلی» از رکورد ``PhoneNumber`` به خودِ ``Customer`` منتقل می‌شود و با مقدارِ
نرمال‌شدهٔ شماره ذخیره می‌گردد تا با افزودن/حذف/ویرایش مداوم مشتریان پایدار بماند.
* فیلد زمانی هر قالب با معنای واقعی همان نوع هم‌راستا می‌شود (مثلاً نظرسنجی = ۳ روز بعد
از صدور فاکتور، و یادآوری ثبت چک = هر ۳ روز تا ثبت اطلاعات چک).
"""
from django.db import migrations, models
def _normalize(raw):
"""نرمال‌سازی شماره (بدون وابستگی به کد اپ، برای اجرای امن روی دادهٔ قدیمی)."""
from integrations.sms.normalize import normalize_mobile
return normalize_mobile(raw)
def backfill_primary_phones(apps, schema_editor):
"""پرچم‌های ``is_primary`` موجود را به مقدار نرمال‌شده روی مشتری منتقل می‌کند."""
PhoneNumber = apps.get_model("admin_panel", "PhoneNumber")
for customer_id, phone_number in (
PhoneNumber.objects.filter(is_primary=True)
.values_list("customer_id", "phone_number")
):
normalized = _normalize((phone_number or "").strip())
if not normalized:
continue
customer = apps.get_model("admin_panel", "Customer").objects.filter(pk=customer_id).first()
if customer is None:
continue
current = [p for p in (customer.sms_primary_phones or "").split(",") if p]
if normalized in current:
continue
current.append(normalized)
customer.sms_primary_phones = ",".join(current)
customer.save(update_fields=["sms_primary_phones"])
def normalize_template_timing(apps, schema_editor):
"""مقادیر زمانیِ بی‌معنی را با معنای درست هر نوع پیامک هم‌راستا می‌کند.
فقط مقادیری که برای آن نوع معنا ندارند اصلاح می‌شوند؛ تنظیمات معنادارِ مدیر دست‌نخورده
می‌ماند.
"""
SMSTemplate = apps.get_model("admin_panel", "SMSTemplate")
# نظرسنجی: «۳ روز بعد از صدور فاکتور» (مقدار پیشین ۲ بود).
SMSTemplate.objects.filter(sms_type="survey", offset_days=2).update(offset_days=3)
# یادآوری ثبت چک: فاصله تکرار ۳ روز و بدون offset سررسید.
for template in SMSTemplate.objects.filter(sms_type="cheque_registration"):
template.offset_days = 0
if not template.repeat_interval_days:
template.repeat_interval_days = 3
template.save(update_fields=["offset_days", "repeat_interval_days"])
# یادآوری بازدید: دورهٔ پیش‌فرض ۹ ماه (قابل تغییر از پنل).
SMSTemplate.objects.filter(
sms_type="visit", repeat_interval_months__isnull=True,
).update(repeat_interval_months=9)
# متن «یادآوری ثبت چک» به معنای جدید (ثبت اطلاعات چک) هم‌راستا می‌شود.
SMSTemplate.objects.filter(sms_type="cheque_registration").update(
title="یادآوری ثبت اطلاعات چک",
text="{name} عزیز، برای چک با سررسید {date} هنوز اطلاعات ثبت چک "
"(شمارهٔ صیادی) در سامانه ثبت نشده است. لطفاً نسبت به ثبت آن اقدام فرمایید.",
)
# سایر انواع: هیچ فیلد زمانی نباید مقدار داشته باشد.
SMSTemplate.objects.filter(
sms_type__in=["welcome", "debt_due", "custom"],
).update(offset_days=0, repeat_interval_days=None, repeat_interval_months=None)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("admin_panel", "0060_sms_permission_label"),
]
operations = [
migrations.AddField(
model_name="customer",
name="sms_primary_phones",
field=models.CharField(
blank=True, default="", max_length=100,
verbose_name="شماره‌های اصلی پیامک",
help_text="شماره‌های نرمال‌شده (+98…) جدا‌شده با کاما؛ خالی = شمارهٔ اول مشتری.",
),
),
# ترتیب مهم است: ابتدا مقادیر فعلی خوانده و منتقل می‌شوند، سپس ستون قدیمی حذف می‌گردد.
migrations.RunPython(backfill_primary_phones, noop),
migrations.RemoveField(model_name="phonenumber", name="is_primary"),
migrations.AddField(
model_name="smstemplate",
name="repeat_interval_months",
field=models.PositiveSmallIntegerField(
blank=True, null=True,
verbose_name="دورهٔ تکرار (ماه)",
help_text="برای «یادآوری بازدید»: چند ماه یک‌بار بازدید تکرار شود. "
"خالی = پیش‌فرض سامانه (۹ ماه).",
),
),
migrations.AlterField(
model_name="smstemplate",
name="offset_days",
field=models.PositiveSmallIntegerField(
default=0,
verbose_name="فاصلهٔ زمانی (روز)",
help_text="معنای این عدد برای هر نوع پیامک متفاوت است و در پنل توضیح داده "
"می‌شود؛ مثلاً «چند روز قبل از سررسید» یا «چند روز بعد از صدور فاکتور».",
),
),
migrations.RunPython(normalize_template_timing, noop),
]
@@ -0,0 +1,23 @@
# Generated by Django 3.2.25 on 2026-10-09 18:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0061_sms_timing_and_primary'),
]
operations = [
migrations.AlterField(
model_name='smsproviderconfig',
name='api_url',
field=models.CharField(blank=True, default='http://www.0098sms.com/sendsmslink.aspx', help_text='برای 0098SMS همان آدرس لینکی ارسال است؛ این سرویس WSDL/SOAP ندارد.', max_length=500, verbose_name='آدرس سرویس پیامک (URL)'),
),
migrations.AlterField(
model_name='smsproviderconfig',
name='provider',
field=models.CharField(choices=[('sms0098', '0098SMS (سرویس لینکی HTTP)'), ('http', 'HTTP عمومی'), ('console', 'Console (تست/دیباگ)')], default='sms0098', max_length=20, verbose_name='سرویس\u200cدهنده پیامک'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 3.2.25 on 2026-10-10 06:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0062_sms_link_provider'),
]
operations = [
migrations.AlterField(
model_name='smsqueue',
name='status',
field=models.CharField(choices=[('pending', 'در انتظار تأیید'), ('approved', 'تأییدشده'), ('processing', 'در حال پردازش'), ('sent', 'ارسال شده'), ('failed', 'ناموفق'), ('cancelled', 'لغو شده')], db_index=True, default='pending', max_length=12, verbose_name='وضعیت'),
),
]
@@ -0,0 +1,38 @@
# Generated by Django 3.2.25 on 2026-10-10 07:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0063_alter_smsqueue_status'),
]
operations = [
migrations.AddField(
model_name='smsproviderconfig',
name='panel_no',
field=models.CharField(blank=True, default='', help_text='شمارهٔ اختصاصی پنل در وب\u200cسرویس؛ اگر خالی بماند از «شماره خط فرستنده» استفاده می\u200cشود.', max_length=50, verbose_name='شماره پنل (PnlNo)'),
),
migrations.AddField(
model_name='smsproviderconfig',
name='webservice_url',
field=models.CharField(blank=True, default='https://webservice.0098sms.com/service.asmx', help_text='فقط برای provider «وب\u200cسرویس» لازم است؛ ارسال با شناسه (smsid) و استعلام دلیوری از همین آدرس انجام می\u200cشود.', max_length=500, verbose_name='آدرس وب\u200cسرویس (SOAP/WSDL)'),
),
migrations.AddField(
model_name='smsqueue',
name='delivery_checked_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='زمان آخرین استعلام دلیوری'),
),
migrations.AddField(
model_name='smsqueue',
name='delivery_status',
field=models.CharField(blank=True, choices=[('pending', 'در انتظار استعلام'), ('delivered', 'رسیده به گوشی'), ('not_delivered', 'نرسیده به گوشی'), ('expired', 'منقضی\u200cشده'), ('unknown', 'نامشخص')], db_index=True, default='', help_text='خالی = بدون پیگیری. مقدار از استعلام smsdeliveryState پر می\u200cشود.', max_length=16, verbose_name='وضعیت تحویل'),
),
migrations.AlterField(
model_name='smsproviderconfig',
name='provider',
field=models.CharField(choices=[('sms0098', '0098SMS (سرویس لینکی HTTP)'), ('sms0098ws', '0098SMS (وب\u200cسرویس + استعلام دلیوری)'), ('http', 'HTTP عمومی'), ('console', 'Console (تست/دیباگ)')], default='sms0098', max_length=20, verbose_name='سرویس\u200cدهنده پیامک'),
),
]
@@ -0,0 +1,35 @@
# Generated by Django 3.2.25 on 2026-10-10 07:48
from django.db import migrations, models
def scrub_brand(apps, schema_editor):
"""حذف نام برند از متن قالب‌هایی که پیش‌تر با «کتیبه» ذخیره شده‌اند."""
SMSTemplate = apps.get_model("admin_panel", "SMSTemplate")
for template in SMSTemplate.objects.filter(text__contains="کتیبه"):
cleaned = (
template.text.replace("به خانواده کتیبه خوش آمدید. ", "")
.replace(" نزد کتیبه", "")
)
template.text = cleaned
template.save(update_fields=["text"])
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0064_sms_webservice_delivery'),
]
operations = [
migrations.AddField(
model_name='smsproviderconfig',
name='test_message',
field=models.TextField(blank=True, default='پیامک آزمایشی؛ جهت بررسی صحت ارسال از پنل فرستاده شده است.', help_text='متنی که با دکمهٔ «ارسال پیامک آزمایشی» فرستاده می‌شود؛ قابل ویرایش است.', verbose_name='متن پیامک آزمایشی'),
),
migrations.RunPython(scrub_brand, noop),
]
+42
View File
@@ -0,0 +1,42 @@
# Generated by Django 3.2.25 on 2026-10-10 08:05
from django.db import migrations, models
def backfill_keys(apps, schema_editor):
"""کلیدهای صفِ موجود را به دفتر منتقل می‌کند تا حذف بعدی‌شان باعث بازگشت نشود."""
SMSQueue = apps.get_model("admin_panel", "SMSQueue")
SMSDedup = apps.get_model("admin_panel", "SMSDedup")
keys = SMSQueue.objects.exclude(dedup_key__isnull=True).exclude(
dedup_key=""
).values_list("dedup_key", flat=True)
SMSDedup.objects.bulk_create(
[SMSDedup(key=key) for key in keys], ignore_conflicts=True,
)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0065_smsproviderconfig_test_message'),
]
operations = [
migrations.CreateModel(
name='SMSDedup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=120, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'کلید پردازش\u200cشدهٔ پیامک',
'verbose_name_plural': 'کلیدهای پردازش\u200cشدهٔ پیامک',
},
),
migrations.RunPython(backfill_keys, noop),
]
@@ -0,0 +1,30 @@
# Generated by Django 3.2.25 on 2026-10-10 08:43
from django.db import migrations, models
def scrub_legacy_phones(apps, schema_editor):
"""پاک‌سازی یک‌بارهٔ فیلدهای تلفن قدیمی (idempotent).
همان قاعدهٔ ``admin_panel.sms.phone_cleanup``؛ اینجا فقط مدل از رجیستری
تاریخی گرفته می‌شود. اجرای دوباره بی‌اثر است.
"""
from admin_panel.sms.phone_cleanup import clean_all
clean_all(apps=apps)
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0066_smsdedup'),
]
operations = [
migrations.AddField(
model_name='customer',
name='notes',
field=models.TextField(blank=True, default='', help_text='یادداشت آزاد دربارهٔ مشتری؛ متن‌های حذف‌شدهٔ فیلدهای تلفن هم اینجا ثبت می‌شوند.', verbose_name='توضیحات'),
),
migrations.RunPython(scrub_legacy_phones, migrations.RunPython.noop),
]
@@ -0,0 +1,23 @@
# Generated by Django 3.2.25 on 2026-10-10 09:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0067_customer_notes'),
]
operations = [
migrations.AlterField(
model_name='smsproviderconfig',
name='allow_backlog',
field=models.BooleanField(default=False, help_text='اگر فعال باشد، مواردی که پیش از «تاریخ شروع دوره» واجد شرط شده\u200cاند هم (به\u200cاندازهٔ سقف زیر، در هر اجرا) ارسال می\u200cشوند. به\u200cصورت پیش\u200cفرض خاموش است تا مشتریان قدیمی مشمول این فیلتر شوند.', verbose_name='اجازهٔ ارسال موارد قدیمی'),
),
migrations.AlterField(
model_name='smsproviderconfig',
name='scan_since',
field=models.DateTimeField(blank=True, help_text='فیلتر شروع دوره: فقط مواردی که «پنجرهٔ یادآوری»شان از این لحظه به بعد باز می\u200cشود در صف قرار می\u200cگیرند؛ موارد قدیمی\u200cتر (مثل مشتریان خیلی قدیمی که مدت\u200cهاست بازدید نشده\u200cاند) نادیده گرفته می\u200cشوند. از پنل به شکل تاریخ شمسی ویرایش می\u200cشود و روی «آغاز همان روز» تنظیم می\u200cگردد. اگر خالی باشد، در نخستین اجرا خودکار روی «همین حالا» تنظیم می\u200cشود.', null=True, verbose_name='تاریخ شروع دوره'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 3.2.25 on 2026-10-10 10:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0068_auto_20261010_1305'),
]
operations = [
migrations.AddField(
model_name='smstemplate',
name='auto_send',
field=models.BooleanField(default=False, help_text='اگر خاموش باشد (پیش\u200cفرض)، پیامک\u200cهای این نوع در وضعیت «در انتظار تأیید» در صف می\u200cمانند تا مدیر آن\u200cها را تأیید کند. با روشن\u200cکردن، پیامکِ ساخته\u200cشده توسط اسکنر مستقیماً تأیید و بلافاصله توسط worker ارسال می\u200cشود.', verbose_name='ارسال خودکار (بدون تأیید مدیر)'),
),
]
+1
View File
@@ -17,3 +17,4 @@ from .factors import * # noqa: F401,F403
from .finance import * # noqa: F401,F403
from .actions import * # noqa: F401,F403
from .system import * # noqa: F401,F403
from .sms import * # noqa: F401,F403
+18
View File
@@ -11,6 +11,12 @@ class Customer(models.Model):
]
name_family = models.CharField(max_length=200)
address = models.CharField(max_length=200)
# توضیحات آزاد مشتری؛ متن‌های غیرشماره‌ای که از فیلدهای تلفن پاک‌سازی می‌شوند
# اینجا نگه داشته می‌شوند تا هیچ داده‌ای از سیستم حذف نشود.
notes = models.TextField(
blank=True, default="", verbose_name="توضیحات",
help_text="یادداشت آزاد دربارهٔ مشتری؛ متن‌های حذف‌شدهٔ فیلدهای تلفن هم اینجا ثبت می‌شوند.",
)
phone1 = models.CharField(max_length=20, default='')
phone2 = models.CharField(max_length=20, default='', null=True, blank=True)
phone3 = models.CharField(max_length=20, default='', null=True, blank=True)
@@ -23,6 +29,16 @@ class Customer(models.Model):
# فیلد جدید برای حذف نرم (بدون تداخل با داده‌های قبلی)
is_active = models.BooleanField(default=True, null=True, blank=True, help_text="وضعیت فعالیت مشتری")
# شماره‌های «اصلی» گیرندهٔ پیامک، به‌صورت مقدارِ خودِ شماره (نرمال‌شده، جدا با کاما).
# چرا مقدار و نه کلیدِ رکورد؟ چون دادهٔ مشتریان دائماً افزوده/ویرایش/حذف می‌شود و
# کلید رکورد در بازسازی‌ها از بین می‌رود؛ ولی شماره پایدار است. اگر شماره‌ای حذف یا
# ویرایش شود، این انتخاب خودبه‌خود بی‌اثر می‌شود و سیستم به شمارهٔ اول برمی‌گردد.
sms_primary_phones = models.CharField(
max_length=100, blank=True, default="",
verbose_name="شماره‌های اصلی پیامک",
help_text="شماره‌های نرمال‌شده (+98…) جدا‌شده با کاما؛ خالی = شمارهٔ اول مشتری.",
)
def __str__(self):
return self.name_family
@@ -37,6 +53,8 @@ class PhoneNumber(models.Model):
customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='phones')
title = models.CharField(max_length=50, default='تلفن', blank=True, null=True, help_text="عنوان شماره (مثل: موبایل پدر)")
phone_number = models.CharField(max_length=20)
# توجه: «شمارهٔ اصلی پیامک» روی خودِ Customer.sms_primary_phones نگه داشته می‌شود
# (کلیدش مقدار شماره است، نه رکورد) تا با افزودن/حذف/ویرایش مداوم مشتریان از بین نرود.
def __str__(self):
return f"{self.title}: {self.phone_number}"
+314
View File
@@ -0,0 +1,314 @@
"""مدل‌های زیرسیستم پیامک.
شامل:
- تنظیمات سامانه پیامک (singleton) با URL/نام‌کاربری/رمز قابل ویرایش از Django Admin.
- قالب متن هر نوع پیامک (قابل ویرایش از پنل).
- تنظیم فعال/غیرفعال هر نوع پیامک به‌ازای هر مشتری.
- صف ارسال (outbox) و لاگ تلاش‌های ارسال.
"""
from django.conf import settings
from django.db import models
class SMSType(models.TextChoices):
"""انواع پیامک‌های سیستم."""
WELCOME = "welcome", "خوش‌آمدگویی (ایجاد مشتری)"
CHEQUE_DUE = "cheque_due", "یادآوری سررسید چک"
NESIYE_DUE = "nesiye_due", "یادآوری سررسید نسیه"
CHEQUE_REGISTRATION = "cheque_registration", "یادآوری تأمین موجودی چک"
DEBT_DUE = "debt_due", "یادآوری بدهی (سررسید وصول مطالبات)"
VISIT = "visit", "یادآوری بازدید خدمات"
SURVEY = "survey", "نظرسنجی پس از فاکتور"
CUSTOM = "custom", "پیام سفارشی"
class SMSProviderConfig(models.Model):
"""تنظیمات سامانه پیامک؛ تک‌رکوردی (pk=1) و قابل ویرایش از Django Admin."""
class Meta:
verbose_name = "تنظیمات سامانه پیامک"
verbose_name_plural = "تنظیمات سامانه پیامک"
permissions = [
# نام نمایشی مطابق قرارداد سایر پرمیشن‌های پروژه (ViewStore/AddCustomer/...)
# تا در فهرست پرمیشن‌های Django Admin به‌سادگی پیدا شود.
("ManageSMS", "ManageSMS"),
]
ProviderChoices = [
("sms0098", "0098SMS (سرویس لینکی HTTP)"),
("sms0098ws", "0098SMS (وب‌سرویس + استعلام دلیوری)"),
("http", "HTTP عمومی"),
("console", "Console (تست/دیباگ)"),
]
provider = models.CharField(
max_length=20, choices=ProviderChoices, default="sms0098",
verbose_name="سرویس‌دهنده پیامک",
)
is_enabled = models.BooleanField(
default=True, verbose_name="فعال بودن ارسال پیامک",
help_text="در صورت غیرفعال بودن، worker هیچ پیامکی ارسال نمی‌کند.",
)
api_url = models.CharField(
max_length=500, blank=True,
default="http://www.0098sms.com/sendsmslink.aspx",
verbose_name="آدرس سرویس پیامک (URL)",
help_text="برای 0098SMS همان آدرس لینکی ارسال است؛ این سرویس WSDL/SOAP ندارد.",
)
username = models.CharField(max_length=100, blank=True, default="", verbose_name="نام کاربری")
password = models.CharField(max_length=200, blank=True, default="", verbose_name="رمز عبور")
sender = models.CharField(max_length=50, blank=True, default="", verbose_name="شماره خط فرستنده")
webservice_url = models.CharField(
max_length=500, blank=True,
default="https://webservice.0098sms.com/service.asmx",
verbose_name="آدرس وب‌سرویس (SOAP/WSDL)",
help_text="فقط برای provider «وب‌سرویس» لازم است؛ ارسال با شناسه (smsid) و استعلام "
"دلیوری از همین آدرس انجام می‌شود.",
)
panel_no = models.CharField(
max_length=50, blank=True, default="",
verbose_name="شماره پنل (PnlNo)",
help_text="شمارهٔ اختصاصی پنل در وب‌سرویس؛ اگر خالی بماند از «شماره خط فرستنده» "
"استفاده می‌شود.",
)
api_token = models.CharField(max_length=255, blank=True, default="", verbose_name="توکن API (HTTP)")
test_message = models.TextField(
blank=True,
default="پیامک آزمایشی؛ جهت بررسی صحت ارسال از پنل فرستاده شده است.",
verbose_name="متن پیامک آزمایشی",
help_text="متنی که با دکمهٔ «ارسال پیامک آزمایشی» فرستاده می‌شود؛ قابل ویرایش است.",
)
timeout_seconds = models.PositiveIntegerField(default=30, verbose_name="مهلت ارتباط (ثانیه)")
# --- کنترل جریان ارسال (جلوگیری از سیل پیامک) ---
scan_since = models.DateTimeField(
null=True, blank=True,
verbose_name="تاریخ شروع دوره",
help_text="فیلتر شروع دوره: فقط مواردی که «پنجرهٔ یادآوری»شان از این لحظه به بعد باز "
"می‌شود در صف قرار می‌گیرند؛ موارد قدیمی‌تر (مثل مشتریان خیلی قدیمی که "
"مدت‌هاست بازدید نشده‌اند) نادیده گرفته می‌شوند. از پنل به شکل تاریخ شمسی "
"ویرایش می‌شود و روی «آغاز همان روز» تنظیم می‌گردد. اگر خالی باشد، در "
"نخستین اجرا خودکار روی «همین حالا» تنظیم می‌شود.",
)
allow_backlog = models.BooleanField(
default=False,
verbose_name="اجازهٔ ارسال موارد قدیمی",
help_text="اگر فعال باشد، مواردی که پیش از «تاریخ شروع دوره» واجد شرط شده‌اند هم "
"(به‌اندازهٔ سقف زیر، در هر اجرا) ارسال می‌شوند. به‌صورت پیش‌فرض خاموش است "
"تا مشتریان قدیمی مشمول این فیلتر شوند.",
)
backlog_max = models.PositiveIntegerField(
default=0, verbose_name="حداکثر موارد قدیمی در هر اجرا",
help_text="فقط وقتی «اجازهٔ ارسال موارد قدیمی» روشن است معنا دارد. صفر = هیچ.",
)
max_per_run = models.PositiveIntegerField(
default=200, verbose_name="حداکثر پیامک هر اسکنر در هر اجرا",
help_text="سقف کلی صف‌کردن برای هر نوع پیامک در هر اجرای اسکنر.",
)
send_interval_seconds = models.PositiveIntegerField(
default=3, verbose_name="فاصلهٔ بین هر پیامک (ثانیه)",
help_text="worker پس از هر ارسال این مدت صبر می‌کند تا سرویس‌دهنده زیر بار نرود.",
)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"تنظیمات پیامک ({self.get_provider_display()})"
def save(self, *args, **kwargs):
# سینگلتون: همیشه فقط یک رکورد با pk=1.
self.pk = 1
# نخستین بارگذاری = لحظهٔ فعال‌سازی؛ خط شروع روی «همین حالا» مهر می‌شود تا
# موارد قدیمی موجود در دیتابیس یک‌باره در صف قرار نگیرند.
if self.scan_since is None:
from django.utils import timezone
self.scan_since = timezone.now()
super().save(*args, **kwargs)
@classmethod
def load(cls):
obj, _ = cls.objects.get_or_create(pk=1)
if obj.scan_since is None:
obj.save(update_fields=["scan_since"])
return obj
class SMSTemplate(models.Model):
"""قالب متن هر نوع پیامک. متن قابل ویرایش از پنل است."""
class Meta:
verbose_name = "قالب پیامک"
verbose_name_plural = "قالب‌های پیامک"
sms_type = models.CharField(
max_length=40, choices=SMSType.choices, unique=True, verbose_name="نوع پیامک",
)
title = models.CharField(max_length=100, verbose_name="عنوان")
text = models.TextField(
verbose_name="متن پیامک",
help_text="متغیرهای قابل استفاده: {name} نام مشتری، {amount} مبلغ، "
"{amount_sep} مبلغ با جداکننده، {date} تاریخ جلالی، {days} تعداد روز، "
"{check_row} شماره ردیف چک، {owner_name} نام مالک چک.",
)
is_enabled = models.BooleanField(default=True, verbose_name="فعال")
auto_send = models.BooleanField(
default=False,
verbose_name="ارسال خودکار (بدون تأیید مدیر)",
help_text="اگر خاموش باشد (پیش‌فرض)، پیامک‌های این نوع در وضعیت «در انتظار تأیید» "
"در صف می‌مانند تا مدیر آن‌ها را تأیید کند. با روشن‌کردن، پیامکِ ساخته‌شده "
"توسط اسکنر مستقیماً تأیید و بلافاصله توسط worker ارسال می‌شود.",
)
offset_days = models.PositiveSmallIntegerField(
default=0,
verbose_name="فاصلهٔ زمانی (روز)",
help_text="معنای این عدد برای هر نوع پیامک متفاوت است و در پنل توضیح داده می‌شود؛ "
"مثلاً «چند روز قبل از سررسید» یا «چند روز بعد از صدور فاکتور».",
)
repeat_interval_days = models.PositiveSmallIntegerField(
null=True, blank=True,
verbose_name="فاصله تکرار (روز)",
help_text="خالی بگذارید برای ارسال یک‌باره؛ مقدار بدهید برای ارسال تکرارشونده.",
)
repeat_interval_months = models.PositiveSmallIntegerField(
null=True, blank=True,
verbose_name="دورهٔ تکرار (ماه)",
help_text="برای «یادآوری بازدید»: چند ماه یک‌بار بازدید تکرار شود. خالی = پیش‌فرض سامانه (۹ ماه).",
)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.title
class CustomerSMSSetting(models.Model):
"""تنظیم فعال/غیرفعال هر نوع پیامک برای یک مشتری (مدل opt-out).
نبودِ رکورد برای یک (مشتری، نوع) یعنی فعال. تنها تغییرهای صریح ذخیره می‌شوند.
"""
class Meta:
verbose_name = "تنظیم پیامک مشتری"
verbose_name_plural = "تنظیمات پیامک مشتریان"
unique_together = ("customer", "sms_type")
customer = models.ForeignKey(
"Customer", on_delete=models.CASCADE, related_name="sms_settings",
)
sms_type = models.CharField(max_length=40, choices=SMSType.choices, verbose_name="نوع پیامک")
is_enabled = models.BooleanField(default=True, verbose_name="فعال")
def __str__(self):
state = "فعال" if self.is_enabled else "غیرفعال"
return f"{self.customer} - {self.get_sms_type_display()} ({state})"
class SMSQueue(models.Model):
"""صف دیتابیسی ارسال پیامک (outbox). توسط worker تخلیه می‌شود."""
class Status(models.TextChoices):
PENDING = "pending", "در انتظار تأیید" # در صف بازبینی مدیر
APPROVED = "approved", "تأییدشده" # تأیید مدیر؛ آمادهٔ ارسال توسط worker
PROCESSING = "processing", "در حال پردازش"
SENT = "sent", "ارسال شده"
FAILED = "failed", "ناموفق"
CANCELLED = "cancelled", "لغو شده"
class DeliveryStatus(models.TextChoices):
"""وضعیت تحویل واقعی به گوشی (از استعلام web service، فقط اگر smsid داشته باشیم)."""
PENDING = "pending", "در انتظار استعلام"
DELIVERED = "delivered", "رسیده به گوشی"
NOT_DELIVERED = "not_delivered", "نرسیده به گوشی"
EXPIRED = "expired", "منقضی‌شده"
UNKNOWN = "unknown", "نامشخص"
customer = models.ForeignKey(
"Customer", on_delete=models.SET_NULL, null=True, blank=True,
related_name="sms_queue", verbose_name="مشتری",
)
sms_type = models.CharField(
max_length=40, choices=SMSType.choices, blank=True, default="", verbose_name="نوع",
)
phone_number = models.CharField(max_length=20, verbose_name="شماره گیرنده")
message = models.TextField(verbose_name="متن")
status = models.CharField(
max_length=12, choices=Status.choices, default=Status.PENDING,
db_index=True, verbose_name="وضعیت",
)
attempts = models.PositiveSmallIntegerField(default=0, verbose_name="تعداد تلاش")
max_attempts = models.PositiveSmallIntegerField(default=5, verbose_name="حداکثر تلاش")
next_retry_at = models.DateTimeField(null=True, blank=True, verbose_name="زمان تلاش بعدی")
last_error = models.TextField(blank=True, default="", verbose_name="آخرین خطا")
provider_id = models.CharField(max_length=100, blank=True, default="")
provider_code = models.CharField(max_length=50, blank=True, default="")
dedup_key = models.CharField(
max_length=120, unique=True, null=True, blank=True,
help_text="کلید یکتاسازی برای جلوگیری از ارسال تکراری.",
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True,
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
sent_at = models.DateTimeField(null=True, blank=True)
# --- رهگیری تحویل (فقط برای providerهایی که شناسهٔ پیامک برمی‌گردانند) ---
delivery_status = models.CharField(
max_length=16, choices=DeliveryStatus.choices, blank=True, default="",
db_index=True, verbose_name="وضعیت تحویل",
help_text="خالی = بدون پیگیری. مقدار از استعلام smsdeliveryState پر می‌شود.",
)
delivery_checked_at = models.DateTimeField(
null=True, blank=True, verbose_name="زمان آخرین استعلام دلیوری",
)
class Meta:
verbose_name = "صف پیامک"
verbose_name_plural = "صف پیامک‌ها"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["status", "next_retry_at"]),
]
def __str__(self):
return f"{self.get_sms_type_display() or self.sms_type} → {self.phone_number} [{self.status}]"
class SMSDedup(models.Model):
"""دفتر کلیدهای یکتاسازیِ *همیشه‌ثبت‌شده*.
``SMSQueue.dedup_key`` تنها تا وقتی جلوی تکرار را می‌گیرد که رکورد صف باقی
باشد؛ با حذف رکورد از صف (رد کردن پیامک توسط مدیر) کلید هم پاک می‌شد و اسکنر
در اجرای بعدی همان پیامک را دوباره می‌ساخت. این دفتر مستقل از صف است و هرگز
حذف نمی‌شود، پس پیامکِ حذف‌شده دیگر بازنمی‌گردد.
"""
class Meta:
verbose_name = "کلید پردازش‌شدهٔ پیامک"
verbose_name_plural = "کلیدهای پردازش‌شدهٔ پیامک"
key = models.CharField(max_length=120, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.key
class SMSLog(models.Model):
"""لاگ سطح-تلاش برای هر بار ارسال توسط worker."""
class Meta:
verbose_name = "لاگ پیامک"
verbose_name_plural = "لاگ‌های پیامک"
ordering = ["-created_at"]
queue = models.ForeignKey(
SMSQueue, on_delete=models.CASCADE, related_name="logs", verbose_name="صف",
)
attempt_no = models.PositiveSmallIntegerField(default=1, verbose_name="شماره تلاش")
success = models.BooleanField(default=False, verbose_name="موفق")
provider_id = models.CharField(max_length=100, blank=True, default="")
provider_code = models.CharField(max_length=50, blank=True, default="")
error = models.TextField(blank=True, default="", verbose_name="خطا")
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
state = "موفق" if self.success else "ناموفق"
return f"#{self.queue_id} تلاش {self.attempt_no} ({state})"
+19
View File
@@ -0,0 +1,19 @@
"""سیگنال‌های اپ admin_panel."""
import logging
from django.db.backends.signals import connection_created
from django.dispatch import receiver
logger = logging.getLogger(__name__)
@receiver(connection_created)
def configure_sqlite_connection(sender, connection, **kwargs):
"""افزایش busy_timeout برای SQLite تا نوشتن هم‌زمان web/worker/scheduler خطا ندهد."""
if connection.vendor != "sqlite":
return
try:
with connection.cursor() as cursor:
cursor.execute("PRAGMA busy_timeout=30000;")
except Exception:
logger.warning("تنظیم busy_timeout برای SQLite ناموفق بود.", exc_info=True)
+1
View File
@@ -0,0 +1 @@
"""منطق دامنهٔ زیرسیستم پیامک (رندر قالب، گیرندگان، قواعد، صف و اسکنر)."""
+74
View File
@@ -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
+59
View File
@@ -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"),
}
+139
View File
@@ -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
+163
View File
@@ -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 []
+22
View File
@@ -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)
+41
View File
@@ -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))
+25
View File
@@ -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
]
+336
View File
@@ -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
+112
View File
@@ -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
@@ -0,0 +1,70 @@
{% if perms.admin_panel.ManageSMS %}
<div class="box" dir="rtl">
<h2 class="title is-5">پیامک</h2>
<div class="columns is-multiline">
<div class="column is-6-desktop is-12-mobile">
<h3 class="subtitle is-6">شماره‌های اصلی (گیرنده پیامک)</h3>
{# همهٔ شماره‌ها از هر منبعی که ذخیره شده‌اند نمایش داده می‌شوند (جدول شماره‌ها یا فیلدهای قدیمی) #}
{% for entry in sms_phone_entries %}
<form method="post" action="{% url 'admin_panel:sms_primary_toggle' customer.id %}" class="field has-addons">
{% csrf_token %}
<input type="hidden" name="phone" value="{{ entry.raw }}">
<input type="hidden" name="is_primary" value="{% if entry.is_primary %}false{% else %}true{% endif %}">
<div class="control is-expanded">
<input class="input" type="text"
value="{{ entry.title }} — {{ entry.display }}{% if not entry.is_mobile %} (غیرقابل ارسال پیامک){% endif %}"
disabled>
</div>
<div class="control">
{% if entry.is_mobile %}
<button class="button {% if entry.is_primary %}is-success{% else %}is-light{% endif %}" type="submit">
{% if entry.is_primary %}اصلی ✓{% else %}تعیین به‌عنوان اصلی{% endif %}
</button>
{% else %}
<button class="button is-light" type="button" disabled>غیرموبایل</button>
{% endif %}
</div>
</form>
{% empty %}
<p class="help">شماره‌ای برای این مشتری ثبت نشده است.</p>
{% endfor %}
<p class="help">اگر شمارهٔ اصلی تعیین نشده باشد، شمارهٔ اولِ معتبر به‌صورت خودکار استفاده می‌شود.</p>
<p class="help">گیرندگان فعلی: {% for r in sms_recipients %}<span dir="ltr">{{ r }}</span>{% if not forloop.last %}، {% endif %}{% empty %}—{% endfor %}</p>
</div>
<div class="column is-6-desktop is-12-mobile">
<h3 class="subtitle is-6">فعال/غیرفعال پیامک‌ها برای این مشتری</h3>
{% for item in sms_toggles %}
<form method="post" action="{% url 'admin_panel:sms_customer_toggle' customer.id %}" class="field has-addons">
{% csrf_token %}
<input type="hidden" name="sms_type" value="{{ item.code }}">
<input type="hidden" name="is_enabled" value="{% if item.enabled %}false{% else %}true{% endif %}">
<div class="control is-expanded">
<input class="input" type="text" value="{{ item.label }}" disabled>
</div>
<div class="control">
<button class="button {% if item.enabled %}is-success{% else %}is-danger{% endif %}" type="submit">
{% if item.enabled %}فعال{% else %}غیرفعال{% endif %}
</button>
</div>
</form>
{% endfor %}
</div>
<div class="column is-12">
<h3 class="subtitle is-6">ارسال پیامک سفارشی</h3>
<form method="post" action="{% url 'admin_panel:sms_send_custom' customer.id %}">
{% csrf_token %}
<div class="field">
<div class="control">
<textarea class="textarea" name="text" rows="2" placeholder="متن پیامک... (خالی بگذارید تا متن قالب «پیام سفارشی» استفاده شود)"></textarea>
</div>
</div>
<p class="help">می‌توانید از متغیرهایی مثل {name} (نام مشتری) استفاده کنید.</p>
<button class="button is-info" type="submit">ارسال به شماره‌های اصلی</button>
</form>
</div>
</div>
</div>
{% endif %}
@@ -19,6 +19,7 @@
<script type="text/javascript" src="{% static 'admin_panel/persian-datepicker.min.js' %}"></script>
<link rel="stylesheet" type="text/css" href="{% static 'admin_panel/style.css' %}">
{% block cssEnd %}{% endblock %}
<style>
</style>
</head>
@@ -119,6 +120,23 @@
{% if perms.admin_panel.ManageSMS %}
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">پیامک</a>
<div class="navbar-dropdown">
<a href="{% url 'admin_panel:sms_settings' %}" style="padding-right: 10px;" class="navbar-item">
تنظیمات سامانه
</a>
<a href="{% url 'admin_panel:sms_templates' %}" style="padding-right: 10px;" class="navbar-item">
قالب‌ها
</a>
<a href="{% url 'admin_panel:sms_queue' %}" style="padding-right: 10px;" class="navbar-item">
صف ارسال
</a>
</div>
</div>
{% endif %}
<a class="navbar-item">
کاربران
</a>
@@ -201,6 +219,17 @@
{% endif %}
</div>
{% if notice_text %}
<div class="columns m-0">
<div class="column is-12 px-2 pt-3 pb-0">
<div class="notification {{ notice_kind }} is-light mb-2">
<button class="delete close-notification" type="button"></button>
<strong>{{ notice_text }}</strong>
</div>
</div>
</div>
{% endif %}
{% block bodyPage %}
{% endblock %}
@@ -45,17 +45,17 @@
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">شماره‌های تماس</label>
<div id="dynamic-phones-edit">
{% if customer.phones.all %}
{% for phone_obj in customer.phones.all %}
{# همهٔ شماره‌ها از هر منبعی که ذخیره شده‌اند (جدول شماره‌ها + فیلدهای قدیمی)، بدون تکرار #}
{% for entry in sms_phone_entries %}
<div class="field has-addons mt-2 ss02">
<div class="control" style="width: 30%;">
<input disabled value="{{ phone_obj.title }}" name="phone_titles" class="input" type="text" placeholder="عنوان">
<input disabled value="{{ entry.title }}" name="phone_titles" class="input" type="text" placeholder="عنوان">
</div>
<div class="control is-expanded">
<input disabled value="0{{ phone_obj.phone_number }}" name="phones" class="input an_phone" type="text" placeholder="تلفن">
<input disabled value="{{ entry.display }}" name="phones" class="input an_phone" type="text" placeholder="تلفن">
</div>
<div class="control">
<a href="tel:0{{ phone_obj.phone_number }}" class="button is-success" title="تماس با {{ phone_obj.phone_number }}">
<a href="tel:{{ entry.display }}" class="button is-success" title="تماس با {{ entry.display }}">
📞
</a>
</div>
@@ -65,66 +65,9 @@
</div>
{% endif %}
</div>
{% empty %}
<p class="help mt-2">شماره‌ای برای این مشتری ثبت نشده است.</p>
{% endfor %}
{% else %}
{% if customer.phone1 %}
<div class="field has-addons ss02 mt-2">
<div class="control" style="width: 30%;">
<input disabled value="تلفن 1" name="phone_titles" class="input" type="text" placeholder="عنوان">
</div>
<div class="control is-expanded">
<input disabled value="0{{ customer.phone1 }}" name="phones" class="input an_phone" type="text" placeholder="تلفن">
</div>
<div class="control">
<a href="tel:0{{ customer.phone1 }}" class="button is-success" title="تماس">
📞
</a>
</div>
{% if perms.admin_panel.ViewStore %}
<div class="control"><button type="button" disabled class="button is-danger remove-phone-btn">-</button></div>
{% endif %}
</div>
{% endif %}
{% if customer.phone2 %}
<div class="field has-addons ss02 mt-2">
<div class="control" style="width: 30%;">
<input disabled value="تلفن 2" name="phone_titles" class="input" type="text">
</div>
<div class="control is-expanded">
<input disabled value="0{{ customer.phone2 }}" name="phones" class="input an_phone" type="text">
</div>
<div class="control">
<a href="tel:0{{ customer.phone2 }}" class="button is-success" title="تماس">
📞
</a>
</div>
{% if perms.admin_panel.ViewStore %}
<div class="control"><button type="button" disabled class="button is-danger remove-phone-btn">-</button></div>
{% endif %}
</div>
{% endif %}
{% if customer.phone3 %}
<div class="field has-addons ss02 mt-2">
<div class="control" style="width: 30%;">
<input disabled value="تلفن 3" name="phone_titles" class="input" type="text">
</div>
<div class="control is-expanded">
<input disabled value="0{{ customer.phone3 }}" name="phones" class="input an_phone" type="text">
</div>
<div class="control">
<a href="tel:0{{ customer.phone3 }}" class="button is-success" title="تماس">
📞
</a>
</div>
{% if perms.admin_panel.ViewStore %}
<div class="control"><button type="button" disabled class="button is-danger remove-phone-btn">-</button></div>
{% endif %}
</div>
{% endif %}
{% endif %}
</div>
{% if perms.admin_panel.ViewStore %}
<button type="button" disabled id="add-more-phone-edit" class="button is-small is-info mt-2 w-100 add-phone-btn">
@@ -148,6 +91,13 @@
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">توضیحات</label>
<div class="control">
<textarea disabled name="notes" class="textarea ss02 an_notes" rows="4" placeholder="یادداشت آزاد">{{ customer.notes }}</textarea>
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">لغو بازدید</label>
<div class="control">
@@ -686,7 +636,7 @@ $(document).ready(function() {
var test = 0;
$("#enEdit").click(function(){
$("#formcsEdit").find("input").prop('disabled', false);
$("#formcsEdit").find("input, textarea").prop('disabled', false);
// فعال کردن دکمه‌های کنترل تلفن
$("#formcsEdit").find("button.remove-phone-btn, button.add-phone-btn").prop('disabled', false);
@@ -180,6 +180,8 @@
</form>
</div>
{% include "./_customer_sms.html" %}
</div>
{% endblock %}
+187 -78
View File
@@ -136,63 +136,87 @@
<button class="modal-close is-large closeSearch" aria-label="close"></button>
</div>
<div class="modal addNewUser">
<div class="modal addNewUser" role="dialog" aria-modal="true" aria-labelledby="addUserTitle">
<div class="modal-background closeAddUser"></div>
<div class="modal-content">
<form action="{% url 'admin_panel:customersAdd' %}" class="columns is-mobile is-multiline" method="post">
<div class="modal-content add-modal">
<div class="add-modal-card">
<header class="add-modal-head">
<h2 class="title is-5 mb-0" id="addUserTitle">ایجاد مشتری جدید</h2>
<button type="button" class="delete closeAddUser" aria-label="بستن"></button>
</header>
<form action="{% url 'admin_panel:customersAdd' %}" method="post" id="addCustomerForm">
{% csrf_token %}
<div class="field column is-4-desktop is-12-mobile m-0">
<label class="label">نام ونام خانوادگی</label>
<div class="add-modal-body">
<div class="columns is-mobile is-multiline m-0">
<div class="field column is-8-desktop is-12-mobile py-2 px-2">
<label class="label" for="addName">نام و نام خانوادگی <span class="has-text-danger">*</span></label>
<div class="control">
<input required name="an_name" class="input an_name" type="text" placeholder="نام نام خانوادگی">
<input required name="an_name" id="addName" class="input an_name" type="text"
autocomplete="name" placeholder="نام و نام خانوادگی">
</div>
</div>
<div class="field column is-8-desktop is-12-mobile m-0">
<label class="label">آدرس</label>
<div class="field column is-4-desktop is-12-mobile py-2 px-2">
<label class="label" for="addIdnCode">کدشناسایی</label>
<div class="control">
<input name="an_address" class="input an_address" type="text" placeholder="آدرس کامل">
<input name="idnCode" id="addIdnCode" class="input idnCode" type="text"
placeholder="کد شناسایی مشتری">
</div>
</div>
<div class="field column is-6-desktop is-12-mobile m-0">
<div class="field column is-4-desktop is-12-mobile py-2 px-2">
<label class="label" for="persianDateP">تاریخ نصب</label>
<div class="control">
<input name="installDate" id="persianDateP" class="input installDate" type="text"
autocomplete="off" placeholder="مثل: 1400/06/01">
</div>
<p class="help">مبنای محاسبهٔ یادآوری بازدید دوره‌ای.</p>
</div>
<div class="field column is-8-desktop is-12-mobile py-2 px-2">
<label class="label" for="addAddress">آدرس</label>
<div class="control">
<input name="an_address" id="addAddress" class="input an_address" type="text"
autocomplete="street-address" placeholder="آدرس کامل">
</div>
</div>
<div class="field column is-12 py-2 px-2">
<label class="label">شماره‌های تماس</label>
<div id="dynamic-phones-add">
<div class="field has-addons">
<div class="control" style="width: 35%;">
<input name="phone_titles" class="input" type="text" placeholder="عنوان (مثلاً موبایل)">
<div class="phone-row">
<div class="phone-row-fields">
<input name="phone_titles" class="input phone-title" type="text"
aria-label="عنوان شماره" placeholder="عنوان (مثلاً موبایل)">
<input name="phones" class="input phone-number an_phone" type="tel"
inputmode="tel" autocomplete="tel" aria-label="شماره تماس"
placeholder="شماره تماس">
</div>
<div class="control is-expanded">
<input name="phones" class="input an_phone" type="text" placeholder="شماره تماس">
</div>
<div class="control">
<button type="button" class="button is-info add-phone-btn">+</button>
<div class="phone-row-actions">
<label class="checkbox phone-primary" title="گیرندهٔ پیامک‌های این مشتری">
<input type="checkbox" name="phone_primary" value="0" checked> اصلی
</label>
<button type="button" class="button is-small is-danger is-light remove-phone-btn"
aria-label="حذف این شماره">حذف</button>
</div>
</div>
</div>
<button type="button" class="button is-info is-light is-small add-phone-btn"
data-target="#dynamic-phones-add">+ افزودن شماره</button>
<p class="help">تیک «اصلی» یعنی گیرندهٔ پیامک‌های این مشتری (می‌تواند چند شماره باشد).</p>
</div>
</div>
</div>
<div class="field column is-4-desktop is-12-mobile m-0">
<label class="label">تاریخ نصب</label>
<div class="control">
<input name="installDate" id="persianDateP" class="input installDate" type="text" placeholder="مثل: 1400/06/01">
</div>
</div>
<div class="field column is-4-desktop is-12-mobile m-0">
<label class="label">کدشناسایی</label>
<div class="control">
<input name="idnCode" class="input idnCode" type="text" placeholder="">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<input class="button is-success" id="DcreateCS" value="ایجاد مشتری" type="submit">
</div>
<footer class="add-modal-foot">
<button class="button is-success" id="DcreateCS" type="submit">ایجاد مشتری</button>
<button class="button is-light closeAddUser" type="button">انصراف</button>
</footer>
</form>
</div>
</div>
<button class="modal-close is-large closeAddUser" aria-label="close"></button>
</div>
@@ -252,51 +276,131 @@
</div>
{% endblock %}
{% block cssEnd %}
<style>
/* --- مودال ایجاد مشتری: کارت با سربرگ/پابرگ چسبان و بدنهٔ اسکرول‌شو --- */
/* .add-modal همان .modal-content بولماست؛ سقف ارتفاع و max-width پیش‌فرضش را خنثی
می‌کنیم تا کارت خودش اندازه را تعیین کند (وگرنه پابرگ بریده می‌شود). */
.add-modal {
overflow: visible; max-height: none; max-width: 760px;
width: 100%; margin: 0 auto; padding: 0 .5rem;
}
.add-modal-card {
display: flex; flex-direction: column;
background: #fff; border-radius: 10px; overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, .25);
width: 100%; max-width: 720px; margin: 0 auto;
max-height: calc(100vh - 120px);
}
/* فرم باید خودش ستون فلکس باشد تا بدنه بتواند اسکرول شود و پابرگ پایین بماند. */
.add-modal-card > form {
display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto;
}
.add-modal-head {
display: flex; align-items: center; justify-content: space-between;
gap: .5rem; padding: .75rem 1rem;
border-bottom: 1px solid #eee; background: #fff; flex: 0 0 auto;
}
.add-modal-body {
overflow-y: auto; -webkit-overflow-scrolling: touch;
padding: .25rem .5rem; flex: 1 1 auto; min-height: 0;
}
.add-modal-foot {
display: flex; gap: .5rem; padding: .75rem 1rem;
border-top: 1px solid #eee; background: #fafafa; flex: 0 0 auto;
}
.add-modal-foot .button { min-height: 2.6em; }
/* --- ردیف شمارهٔ تماس: دسکتاپ یک‌خطی، موبایل عمودی --- */
.phone-row {
display: flex; flex-wrap: wrap; align-items: center; gap: .5rem;
padding: .5rem; margin-bottom: .5rem;
border: 1px solid #e6e6e6; border-radius: 8px; background: #fafafa;
}
.phone-row-fields { display: flex; flex-wrap: wrap; gap: .5rem; flex: 1 1 320px; min-width: 0; }
.phone-row-fields .phone-title { flex: 0 0 140px; min-width: 0; }
.phone-row-fields .phone-number { flex: 1 1 180px; min-width: 0; }
.phone-row-actions { display: flex; align-items: center; gap: .75rem; white-space: nowrap; }
.phone-primary { display: flex; align-items: center; gap: .35rem; font-size: .9rem; }
@media (max-width: 768px) {
.add-modal-card { max-height: calc(100vh - 32px); border-radius: 8px; }
/* dvh روی مرورگرهای موبایل نوار آدرس را هم حساب می‌کند. */
.add-modal-card { max-height: calc(100dvh - 32px); }
.phone-row-fields { flex: 1 1 100%; }
.phone-row-fields .phone-title,
.phone-row-fields .phone-number { flex: 1 1 100%; }
.phone-row-actions { flex: 1 1 100%; justify-content: space-between; }
.add-modal-foot { flex-direction: column-reverse; }
.add-modal-foot .button { width: 100%; }
}
</style>
{% endblock %}
{% block jsEnd %}
<script>
$(document).ready(function() {
kamaDatepicker('persianDateP', { buttonsColor: "red", forceFarsiDigits: true });
$(document).on('click', '.add-phone-btn', function() {
var phoneRow = `
<div class="field has-addons mt-2">
<div class="control" style="width: 35%;">
<input name="phone_titles" class="input" type="text" placeholder="عنوان">
var PHONE_ROW = `
<div class="phone-row">
<div class="phone-row-fields">
<input name="phone_titles" class="input phone-title" type="text"
aria-label="عنوان شماره" placeholder="عنوان (مثلاً موبایل)">
<input name="phones" class="input phone-number an_phone" type="tel"
inputmode="tel" autocomplete="tel" aria-label="شماره تماس" placeholder="شماره تماس">
</div>
<div class="control is-expanded">
<input name="phones" class="input an_phone" type="text" placeholder="شماره تماس">
</div>
<div class="control">
<button type="button" class="button is-danger remove-phone-btn">-</button>
<div class="phone-row-actions">
<label class="checkbox phone-primary" title="گیرندهٔ پیامک‌های این مشتری">
<input type="checkbox" name="phone_primary" value="0"> اصلی
</label>
<button type="button" class="button is-small is-danger is-light remove-phone-btn"
aria-label="حذف این شماره">حذف</button>
</div>
</div>`;
$(this).closest('div[id^="dynamic-phones"]').append(phoneRow);
function reindexPhones() {
$('#dynamic-phones-add .phone-row').each(function(i) {
$(this).find('input[name="phone_primary"]').val(i);
});
}
// همیشه دست‌کم یک شماره «اصلی» بماند (شمارهٔ اول پیش‌فرضِ گیرندهٔ پیامک است).
function ensurePrimary() {
var $rows = $('#dynamic-phones-add .phone-row');
if ($rows.length && $rows.find('input[name="phone_primary"]:checked').length === 0) {
$rows.first().find('input[name="phone_primary"]').prop('checked', true);
}
}
reindexPhones();
$(document).on('click', '.add-phone-btn', function() {
// data-target صریح (وقتی دکمه بیرونِ ظرف است)؛ وگرنه نزدیک‌ترین ظرف شماره‌ها.
var $target = $(this).data('target')
? $($(this).data('target'))
: $(this).closest('div[id^="dynamic-phones"]');
$target.append(PHONE_ROW);
reindexPhones();
$target.find('.phone-row').last().find('.phone-number').focus();
});
$(document).on('click', '.remove-phone-btn', function() {
$(this).closest('.field.has-addons').remove();
$(this).closest('.phone-row').remove();
reindexPhones();
ensurePrimary();
});
$("#createCS").click(function(){
var u_name = $(".addNewUser").find('.an_name').val();
var u_address = $(".addNewUser").find('.an_address').val();
var u_phone1 = $(".addNewUser").find('#phone-1').val();
var u_phone2 = $(".addNewUser").find('#phone-2').val();
var u_phone3 = $(".addNewUser").find('#phone-3').val();
var u_installDate = $(".addNewUser").find('.installDate').val();
alert("Ok send To server");
function resetAddForm() {
$('#addCustomerForm').find('input.an_name, input.an_address, input.installDate, input.idnCode').val('');
$('#dynamic-phones-add').empty().append(PHONE_ROW);
reindexPhones();
ensurePrimary();
}
$(".closeAddUser").click(function(){
$(".addNewUser").removeClass("is-active");
$(".addNewUser").find('.an_name').val('');
$(".addNewUser").find('.an_address').val('');
$(".addNewUser").find('#phone-1').val('');
$(".addNewUser").find('#phone-2').val('');
$(".addNewUser").find('#phone-3').val('');
$(".addNewUser").find('.installDate').val('');
//Refresh page
resetAddForm();
});
$(".modal-close, .modal-background").click(function(){
$(".mEdit1").removeClass("is-active")
@@ -304,20 +408,25 @@ $(document).ready(function() {
$(".closeSearch").click(function(){
$(".SearchBox").removeClass("is-active")
});
$(".closeAddUser").click(function(){
$(".addNewUser").removeClass("is-active");
$(".addNewUser").find('.an_name').val('');
$(".addNewUser").find('.an_address').val('');
$(".addNewUser").find('#phone-1').val('');
$(".addNewUser").find('#phone-2').val('');
$(".addNewUser").find('#phone-3').val('');
$(".addNewUser").find('.installDate').val('');
});
$("#addCustomerForm").on('submit', function(){
// جلوگیری از ارسال دوباره؛ خودِ فرم همین حالا داده‌ها را بسته است.
$(this).find('#DcreateCS').prop('disabled', true).text('در حال ثبت…');
});
$("#addNewUser").click(function(){
$(".addNewUser").addClass("is-active")
resetAddForm();
$(".addNewUser").addClass("is-active");
$('#addName').trigger('focus');
});
$(document).on('keydown', function(e){
if (e.key === 'Escape' && $(".addNewUser").hasClass("is-active")) {
$(".addNewUser").removeClass("is-active");
resetAddForm();
}
});
$("#openSearchBox").click(function(){
$(".SearchBox").addClass("is-active")
});
@@ -0,0 +1,149 @@
{% extends "./base.html" %}
{% load custom_tags %}
{% block bodyPage %}
<div class="container mt-5" dir="rtl">
<h1 class="title is-4">صف پیامک‌ها</h1>
<div class="notification is-warning is-light">
پیامک‌ها تا زمانی که مدیر آن‌ها را تأیید نکند ارسال نمی‌شوند. صف را بازبینی
کنید، موارد نامناسب را حذف کنید و سپس تأیید بزنید.
<br>
حذف یک مورد، فقط همان نوبت را کنسل می‌کند. یادآوری‌های دوره‌ای (بازدید و ثبت
اطلاعات چک) در <strong>دورهٔ بعدی</strong> دوباره ساخته می‌شوند؛ پس با حذف یک
مورد، پیامک برای آن مشتری برای همیشه قطع نمی‌شود.
</div>
<div class="columns is-multiline mb-2" dir="rtl">
<div class="column is-narrow">
<div class="box py-3 px-4 has-text-centered">
<p class="heading mb-0">کل صف</p>
<p class="title is-5 mb-0">{{ total_count }}</p>
</div>
</div>
{% for code, label, count in status_stats %}
<div class="column is-narrow">
<a href="?status={{ code }}" class="box py-3 px-4 has-text-centered"
{% if status == code %}style="border:2px solid #3273dc"{% endif %}>
<p class="heading mb-0">{{ label }}</p>
<p class="title is-5 mb-0">{{ count }}</p>
</a>
</div>
{% endfor %}
</div>
{% if status or delivery %}
<p class="mb-3">نتایج فیلترشده: <strong>{{ filtered_count }}</strong> مورد — <a href="{% url 'admin_panel:sms_queue' %}">حذف فیلتر</a></p>
{% endif %}
<form method="get" class="field has-addons mb-3">
<div class="control">
<div class="select">
<select name="status">
<option value="">همهٔ وضعیت‌ها</option>
{% for value, label in status_choices %}
<option value="{{ value }}" {% if status == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control">
<div class="select">
<select name="delivery">
<option value="">همهٔ دلیوری‌ها</option>
{% for value, label in delivery_choices %}
<option value="{{ value }}" {% if delivery == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control"><button class="button" type="submit">فیلتر</button></div>
</form>
<form method="post" action="{% url 'admin_panel:sms_queue_bulk' %}" id="bulk-form" class="mb-3">
{% csrf_token %}
<button class="button is-success" name="action" value="approve_all"
onclick="return confirm('همهٔ پیامک‌های «در انتظار تأیید» تأیید و ارسال شوند؟');">
تأیید همهٔ موارد در انتظار ({{ pending_count }})
</button>
<button class="button is-info" name="action" value="approve_selected">تأیید انتخاب‌شده‌ها</button>
<button class="button is-danger" name="action" value="delete_selected"
onclick="return confirm('موارد انتخاب‌شده از صف حذف شوند؟');">حذف انتخاب‌شده‌ها</button>
</form>
<div class="notification is-danger is-light is-flex is-align-items-center is-justify-content-space-between mb-3">
<span>خالی کردن کل صف: همهٔ پیامک‌ها (در هر وضعیتی) حذف می‌شوند و دیگر بازنمی‌گردند.</span>
<form method="post" action="{% url 'admin_panel:sms_queue_empty' %}"
onsubmit="return confirm('کل صف پیامک خالی شود؟ این پیامک‌ها دیگر هرگز ارسال یا بازسازی نمی‌شوند.');">
{% csrf_token %}
<button class="button is-danger" type="submit">خالی کردن کل صف</button>
</form>
</div>
<div class="table-container">
<table class="table is-fullwidth is-narrow is-hoverable is-striped">
<thead>
<tr>
<th><input type="checkbox" onclick="document.querySelectorAll('.row-check').forEach(c => c.checked = this.checked)"></th>
<th>شناسه</th><th>مشتری</th><th>نوع</th><th>شماره</th>
<th>وضعیت</th><th>دلیوری</th><th>تلاش</th><th>ایجاد</th><th>ارسال</th><th>خطا</th><th></th>
</tr>
</thead>
<tbody>
{% for item in queue %}
<tr>
<td><input class="row-check" type="checkbox" form="bulk-form" name="ids" value="{{ item.id }}"></td>
<td>{{ item.id }}</td>
<td>
{% if item.customer_id %}
<a href="{% url 'admin_panel:customer' item.customer_id %}">{{ item.customer.name_family }}</a>
{% else %}—{% endif %}
</td>
<td>{{ item.get_sms_type_display }}</td>
<td dir="ltr">{{ item.phone_number }}</td>
<td>{{ item.get_status_display }}</td>
<td>{{ item.get_delivery_status_display|default:'—' }}</td>
<td>{{ item.attempts }}</td>
<td>{{ item.created_at|toJalaliDT }}</td>
<td>{{ item.sent_at|toJalaliDT|default:'—' }}</td>
<td class="is-size-7">{{ item.last_error|truncatechars:60 }}</td>
<td>
<div class="buttons are-small">
{% if item.status == 'pending' %}
<form method="post" action="{% url 'admin_panel:sms_queue_approve' item.id %}">
{% csrf_token %}
<button class="button is-small is-success" type="submit">تأیید</button>
</form>
{% elif item.status == 'failed' %}
<form method="post" action="{% url 'admin_panel:sms_queue_retry' item.id %}">
{% csrf_token %}
<button class="button is-small is-warning" type="submit">ارسال مجدد</button>
</form>
{% endif %}
<form method="post" action="{% url 'admin_panel:sms_queue_delete' item.id %}"
onclick="return confirm('این پیامک از صف حذف شود؟');">
{% csrf_token %}
<button class="button is-small is-danger" type="submit">حذف</button>
</form>
</div>
</td>
</tr>
{% empty %}
<tr><td colspan="12" class="has-text-centered">موردی یافت نشد.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if queue.has_other_pages %}
<nav class="pagination" role="navigation" aria-label="pagination">
{% if queue.has_previous %}
<a class="pagination-previous" href="?page={{ queue.previous_page_number }}{% if status %}&status={{ status }}{% endif %}{% if delivery %}&delivery={{ delivery }}{% endif %}">قبلی</a>
{% endif %}
{% if queue.has_next %}
<a class="pagination-next" href="?page={{ queue.next_page_number }}{% if status %}&status={{ status }}{% endif %}{% if delivery %}&delivery={{ delivery }}{% endif %}">بعدی</a>
{% endif %}
<ul class="pagination-list"><li><span class="pagination-link">صفحه {{ queue.number }} از {{ queue.paginator.num_pages }}</span></li></ul>
</nav>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,188 @@
{% extends "./base.html" %}
{% block bodyPage %}
<div class="container mt-5" dir="rtl">
<h1 class="title is-4">تنظیمات سامانه پیامک</h1>
<div class="box">
<form method="post" action="{% url 'admin_panel:sms_settings' %}">
{% csrf_token %}
<div class="columns is-multiline">
<div class="field column is-4-desktop is-12-mobile">
<label class="label">سرویس‌دهنده</label>
<div class="control">
<div class="select is-fullwidth">
<select name="provider">
{% for value, label in provider_choices %}
<option value="{{ value }}" {% if config.provider == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">وضعیت ارسال</label>
<div class="control">
<label class="checkbox">
<input type="checkbox" name="is_enabled" {% if config.is_enabled %}checked{% endif %}>
فعال باشد
</label>
</div>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">مهلت ارتباط (ثانیه)</label>
<div class="control">
<input class="input" type="number" name="timeout_seconds" value="{{ config.timeout_seconds }}">
</div>
</div>
<div class="field column is-12-mobile">
<label class="label">آدرس سرویس پیامک (URL)</label>
<div class="control">
<input class="input" type="text" name="api_url" value="{{ config.api_url }}" dir="ltr">
</div>
<p class="help">
برای 0098SMS همین آدرس لینکی ارسال است
(<span dir="ltr">http://www.0098sms.com/sendsmslink.aspx</span>)؛ این سرویس
WSDL/SOAP ندارد و آدرس WSDL باعث خطای «Invalid XML content» می‌شود.
</p>
</div>
<div class="field column is-12-mobile">
<label class="label">آدرس وب‌سرویس (SOAP/WSDL) — فقط provider وب‌سرویس</label>
<div class="control">
<input class="input" type="text" name="webservice_url" value="{{ config.webservice_url }}" dir="ltr">
</div>
<p class="help">
ارسال با شناسهٔ پیامک (<span dir="ltr">smsid</span>) و استعلام تحویل به گوشی
از همین آدرس انجام می‌شود. پیش‌فرض:
<span dir="ltr">https://webservice.0098sms.com/service.asmx</span>
</p>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">شمارهٔ پنل (PnlNo)</label>
<div class="control">
<input class="input" type="text" name="panel_no" value="{{ config.panel_no }}" dir="ltr">
</div>
<p class="help">اگر خالی بماند از «شماره خط فرستنده» استفاده می‌شود.</p>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">نام کاربری</label>
<div class="control">
<input class="input" type="text" name="username" value="{{ config.username }}" dir="ltr">
</div>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">رمز عبور</label>
<div class="control">
<input class="input" type="password" name="password" placeholder="{% if config.password %}(بدون تغییر)‌{% endif %}" dir="ltr">
</div>
</div>
<div class="field column is-4-desktop is-12-mobile">
<label class="label">شماره خط فرستنده</label>
<div class="control">
<input class="input" type="text" name="sender" value="{{ config.sender }}" dir="ltr">
</div>
</div>
<div class="field column is-12-mobile">
<label class="label">توکن API (فقط برای provider HTTP)</label>
<div class="control">
<input class="input" type="password" name="api_token" placeholder="{% if config.api_token %}(بدون تغییر)‌{% endif %}" dir="ltr">
</div>
</div>
</div>
<hr>
<h2 class="title is-5">کنترل جریان ارسال</h2>
<p class="help mb-3">
برای جلوگیری از سیل پیامک: تنها مواردی ارسال می‌شوند که پنجرهٔ یادآوری‌شان از
«خط شروع» به بعد باز شود. موارد قدیمی‌تر تنها با روشن‌کردن «اجازهٔ موارد قدیمی»
و در سقف تعیین‌شده ارسال می‌شوند. بین هر پیامک هم فاصلهٔ زمانی رعایت می‌شود.
</p>
<div class="columns is-multiline">
<div class="field column is-6-desktop is-12-mobile">
<label class="label">تاریخ شروع دوره</label>
<div class="control">
<input class="input" type="text" name="scan_since"
value="{{ scan_since_jalali }}"
placeholder="مثلاً 1403/07/18 — خالی = همین حالا" dir="ltr">
</div>
<p class="help">
فقط یادآوری‌هایی صف می‌شوند که «پنجرهٔ» آن‌ها از این تاریخ به بعد باز شده باشد.
مشتریان قدیمی‌تری که پنجره‌شان پیش از این تاریخ باز شده، وارد صف نمی‌شوند.
قالب: <span dir="ltr">YYYY/MM/DD</span> (شمسی) — خالی = همین حالا.
</p>
</div>
<div class="field column is-3-desktop is-6-mobile">
<label class="label">حداکثر پیامک هر اسکنر در هر اجرا</label>
<div class="control">
<input class="input" type="number" min="0" name="max_per_run" value="{{ config.max_per_run }}">
</div>
</div>
<div class="field column is-3-desktop is-6-mobile">
<label class="label">فاصلهٔ بین هر پیامک (ثانیه)</label>
<div class="control">
<input class="input" type="number" min="0" name="send_interval_seconds" value="{{ config.send_interval_seconds }}">
</div>
</div>
<div class="field column is-3-desktop is-6-mobile">
<label class="label">اجازهٔ ارسال موارد قدیمی</label>
<div class="control">
<label class="checkbox">
<input type="checkbox" name="allow_backlog" {% if config.allow_backlog %}checked{% endif %}>
فعال باشد
</label>
</div>
</div>
<div class="field column is-3-desktop is-6-mobile">
<label class="label">حداکثر موارد قدیمی در هر اجرا</label>
<div class="control">
<input class="input" type="number" min="0" name="backlog_max" value="{{ config.backlog_max }}">
</div>
<p class="help">صفر = هیچ مورد قدیمی ارسال نمی‌شود.</p>
</div>
</div>
<div class="buttons">
<button class="button is-success" type="submit">ذخیره تنظیمات</button>
</div>
</form>
</div>
<div class="box">
<h2 class="title is-5">ارسال پیامک آزمایشی</h2>
<form method="post" action="{% url 'admin_panel:sms_settings' %}">
{% csrf_token %}
<input type="hidden" name="action" value="test">
<div class="field">
<label class="label">متن پیامک آزمایشی</label>
<div class="control">
<textarea class="textarea" name="test_message" rows="3">{{ config.test_message }}</textarea>
</div>
<p class="help">این متن ذخیره می‌شود و در ارسال‌های بعدی هم استفاده خواهد شد.</p>
</div>
<div class="field has-addons">
<div class="control is-expanded">
<input class="input" type="text" name="test_phone" placeholder="مثلاً 09120000000" dir="ltr">
</div>
<div class="control">
<button class="button is-info" type="submit">ارسال آزمایشی</button>
</div>
</div>
</form>
<p class="help">پیامک آزمایشی در صف قرار می‌گیرد و توسط worker ارسال می‌شود.</p>
</div>
</div>
{% endblock %}
@@ -0,0 +1,455 @@
{% extends "./base.html" %}
{% block bodyPage %}
<style>
{% verbatim %}
/* --- صفحهٔ قالب‌های پیامک: استایل‌ها با پیشوند smsx و محدود به همین صفحه --- */
/* هیچ عنصری از این صفحه نباید از کادر خودش بیرون بزند؛ ریشهٔ به‌هم‌ریختگی‌ها
معمولاً همین box-sizing است (width:100% + padding). */
.smsx, .smsx *, .smsx *::before, .smsx *::after { box-sizing: border-box; }
.smsx { max-width: 60rem; margin-inline: auto; }
.smsx img, .smsx textarea, .smsx input, .smsx button { max-width: 100%; }
.smsx-head { display: flex; flex-wrap: wrap; gap: .75rem; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
.smsx-head__actions { display: flex; flex-wrap: wrap; gap: .5rem; }
/* ---------- کارت هر نوع پیامک ---------- */
.smsx-card { background: #fff; border: 1px solid #e8e8e8; border-radius: 12px; box-shadow: 0 1px 2px rgba(0,0,0,.04); margin-bottom: .9rem; overflow: hidden; }
.smsx-card[open] { box-shadow: 0 6px 18px rgba(0,0,0,.07); border-color: #d9d9d9; }
.smsx-card.is-off .smsx-summary__title { color: #9a9a9a; }
.smsx-card.is-dirty { border-color: #ffdd57; }
.smsx-summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; gap: .75rem; align-items: center; padding: .9rem 1rem; cursor: pointer; list-style: none; }
.smsx-summary::-webkit-details-marker { display: none; }
.smsx-summary:hover { background: #fafafa; }
.smsx-summary:focus-visible { outline: 2px solid #3273dc; outline-offset: -2px; }
.smsx-summary__icon { font-size: 1.35rem; line-height: 1; }
.smsx-summary__main { min-width: 0; }
.smsx-summary__title { display: block; font-weight: 600; color: #363636; overflow-wrap: anywhere; }
.smsx-summary__sub { display: block; font-size: .78rem; color: #7a7a7a; line-height: 1.7; overflow-wrap: anywhere; }
.smsx-summary__meta { display: flex; gap: .35rem; align-items: center; flex-wrap: wrap; }
.smsx-summary__chev { flex: 0 0 auto; width: .55rem; height: .55rem; border-right: 2px solid #b5b5b5; border-bottom: 2px solid #b5b5b5; transform: rotate(45deg); transition: transform .18s; }
.smsx-card[open] .smsx-summary__chev { transform: rotate(-135deg); }
/* ---------- بدنهٔ فرم ---------- */
.smsx-body { padding: 1rem; border-top: 1px solid #f0f0f0; }
/* گرید سه‌ستونی مقاوم: ستون عنوان کشسان، دو ستون دیگر به‌قدر محتوا */
.smsx-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 10rem) max-content; gap: .9rem 1rem; align-items: start; }
.smsx-cell { display: flex; flex-direction: column; gap: .4rem; min-width: 0; }
.smsx-label { display: block; margin: 0; font-size: .8rem; font-weight: 600; color: #4a4a4a; line-height: 1.6; overflow-wrap: anywhere; }
.smsx-cell--switch .smsx-label { white-space: nowrap; }
.smsx-note { margin-top: .75rem; margin-bottom: 0; line-height: 1.9; }
/* کلید فعال/غیرفعال */
.smsx-switch { display: inline-flex; align-items: center; gap: .5rem; cursor: pointer; user-select: none; min-height: 2.5rem; }
.smsx-switch input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.smsx-switch__track { position: relative; flex: 0 0 auto; width: 46px; height: 26px; border-radius: 13px; background: #dbdbdb; transition: background .18s; }
.smsx-switch__track::after { content: ""; position: absolute; top: 3px; inset-inline-start: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3); transition: transform .18s; }
.smsx-switch input:checked + .smsx-switch__track { background: #48c774; }
.smsx-switch input:checked + .smsx-switch__track::after { transform: translateX(-20px); }
.smsx-switch input:focus-visible + .smsx-switch__track { outline: 2px solid #3273dc; outline-offset: 2px; }
.smsx-switch__text { font-size: .85rem; color: #4a4a4a; white-space: nowrap; }
/* ---------- کادر نوشتن متن ---------- */
.smsx-editor { margin-top: 1rem; border: 1px solid #e2e2e2; border-radius: 10px; background: #fff; overflow: hidden; transition: border-color .15s, box-shadow .15s; }
.smsx-editor:focus-within { border-color: #3273dc; box-shadow: 0 0 0 3px rgba(50,115,220,.12); }
/* padding افقی سر/پا هم‌اندازهٔ padding خود textarea (1rem) تا متن و برچسب هم‌تراز بمانند */
.smsx-editor__head { display: flex; flex-wrap: wrap; gap: .5rem .75rem; align-items: center; justify-content: space-between; padding: .65rem 1rem; background: #fbfbfb; border-bottom: 1px solid #eee; }
.smsx-chips { display: flex; flex-wrap: wrap; gap: .35rem; }
.smsx-chip { display: inline-block; font-size: .75rem; line-height: 1.6; padding: .35rem .65rem; border-radius: 999px; border: 1px solid #dbdbdb; background: #fff; color: #4a4a4a; cursor: pointer; direction: ltr; font-family: ui-monospace, Menlo, Consolas, monospace; white-space: nowrap; }
.smsx-chip:hover { border-color: #3273dc; color: #3273dc; background: #f5f9ff; }
.smsx-chip:active { transform: translateY(1px); }
/* textarea دقیقاً به اندازهٔ کادر؛ بدون border/padding سرکش */
.smsx-editor textarea.smsx-textarea {
display: block; width: 100%; max-width: 100%; margin: 0;
border: 0; border-radius: 0; box-shadow: none; background: transparent;
padding: .85rem 1rem; font-size: 1rem; line-height: 1.9; font-family: inherit;
resize: vertical; overflow-y: auto;
unicode-bidi: plaintext; text-align: start; overflow-wrap: anywhere;
}
.smsx-editor textarea.smsx-textarea:focus { border: 0; box-shadow: none; outline: none; }
.smsx-editor__foot { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem .6rem; padding: .5rem 1rem; border-top: 1px solid #f0f0f0; background: #fbfbfb; font-size: .78rem; color: #7a7a7a; }
.smsx-dot { color: #dbdbdb; }
.smsx-metric { font-variant-numeric: tabular-nums; }
.smsx-metric.is-ok { color: #257942; }
.smsx-metric.is-warn { color: #b8860b; }
.smsx-metric.is-bad { color: #cc0f35; font-weight: 600; }
.smsx-warn { color: #cc0f35; overflow-wrap: anywhere; }
/* ---------- پیش‌نمایش ---------- */
.smsx-preview { margin-top: 1rem; }
.smsx-preview__label { display: block; font-size: .8rem; font-weight: 600; color: #4a4a4a; margin-bottom: .4rem; }
.smsx-bubble { background: #ecf7ee; border: 1px solid #d5ecda; border-radius: 14px 14px 4px 14px; padding: .75rem .95rem; line-height: 1.9; font-size: .9rem; white-space: pre-wrap; overflow-wrap: anywhere; min-height: 2.6rem; }
.smsx-preview__hint { margin: .4rem 0 0; font-size: .75rem; line-height: 1.8; }
.smsx-actions { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; margin-top: 1rem; }
.smsx-dirty { font-size: .8rem; color: #b8860b; }
.smsx-help { margin-bottom: 1rem; background: #f5f9ff; border: 1px solid #dbe9ff; border-radius: 10px; padding: .7rem .95rem; }
.smsx-help > summary { cursor: pointer; font-size: .85rem; color: #3273dc; font-weight: 600; }
.smsx-help .smsx-chips { margin-top: .6rem; }
.smsx-help .smsx-chip { cursor: default; white-space: normal; }
/* ---------- موبایل ---------- */
@media (max-width: 768px) {
.smsx-body { padding: .85rem; }
.smsx-summary { grid-template-columns: auto minmax(0, 1fr) auto; padding: .8rem .85rem; row-gap: .5rem; }
.smsx-summary__meta { grid-column: 1 / -1; }
/* همهٔ فیلدها زیر هم؛ دیگر هیچ چیز به هم نمی‌چسبد */
.smsx-grid { grid-template-columns: minmax(0, 1fr); }
.smsx-cell--switch .smsx-label { white-space: normal; }
.smsx-editor textarea.smsx-textarea { font-size: 16px; } /* جلوگیری از زوم خودکار iOS */
.smsx-actions .button { width: 100%; }
}
{% endverbatim %}
</style>
<div class="container mt-5" dir="rtl">
<div class="smsx">
<div class="smsx-head">
<div>
<h1 class="title is-4 mb-1">قالب‌های پیامک</h1>
<p class="subtitle is-6 has-text-grey mb-0">
متن هر نوع پیامک و زمان ارسال آن را اینجا تعیین کنید. برای ویرایش، روی هر کارت بزنید.
</p>
</div>
<div class="smsx-head__actions">
<button type="button" class="button is-small is-light" data-smsx-expand-all>باز کردن همه</button>
<button type="button" class="button is-small is-light" data-smsx-collapse-all>بستن همه</button>
</div>
</div>
<details class="smsx-help">
<summary>متغیرهای قابل استفاده در متن پیامک</summary>
<p class="help mb-0">کافی است روی هر متغیر بزنید تا در کادر متن همان پیامک درج شود.</p>
<div class="smsx-chips">
{% for token, desc in variables %}
<span class="smsx-chip" title="{{ desc }}">{{ token }} <span class="has-text-grey">{{ desc }}</span></span>
{% endfor %}
</div>
</details>
{{ smsx_config|json_script:"smsx-config" }}
{% for row in rows %}
<details class="smsx-card" id="type-{{ row.code }}" {% if forloop.first %}open{% endif %} data-smsx-card>
<summary class="smsx-summary">
<span class="smsx-summary__icon" aria-hidden="true">{{ row.icon }}</span>
<span class="smsx-summary__main">
<span class="smsx-summary__title">{{ row.label }}</span>
<span class="smsx-summary__sub">
{{ row.when }}{% if row.timing_value is not None %} · {{ row.timing_label }}: {{ row.timing_value }}{% endif %}
</span>
</span>
<span class="smsx-summary__meta">
<span class="tag is-light" data-smsx-badge>—</span>
{% if not row.template.is_enabled %}
<span class="tag is-warning is-light">غیرفعال</span>
{% endif %}
{% if row.template.auto_send %}
<span class="tag is-success is-light">خودکار</span>
{% endif %}
</span>
<span class="smsx-summary__chev" aria-hidden="true"></span>
</summary>
<form class="smsx-body" method="post" action="{% url 'admin_panel:sms_template_edit' row.template.id %}">
{% csrf_token %}
<div class="smsx-grid">
<div class="smsx-cell">
<label class="smsx-label" for="title-{{ row.code }}">عنوان</label>
<input id="title-{{ row.code }}" class="input" type="text" name="title" value="{{ row.template.title }}">
</div>
{% if row.timing_value is not None %}
<div class="smsx-cell">
<label class="smsx-label" for="timing-{{ row.code }}">{{ row.timing_label }}</label>
<input id="timing-{{ row.code }}" class="input" type="number" inputmode="numeric" min="0"
name="timing_value" value="{{ row.timing_value|default_if_none:'' }}">
</div>
{% endif %}
<div class="smsx-cell smsx-cell--switch">
<span class="smsx-label">وضعیت ارسال</span>
<label class="smsx-switch">
<input type="checkbox" name="is_enabled" {% if row.template.is_enabled %}checked{% endif %}>
<span class="smsx-switch__track"></span>
<span class="smsx-switch__text">{% if row.template.is_enabled %}فعال{% else %}غیرفعال{% endif %}</span>
</label>
</div>
<div class="smsx-cell smsx-cell--switch">
<span class="smsx-label">ارسال</span>
<label class="smsx-switch" title="روشن = ارسال خودکار بدون صف تأیید مدیر / خاموش = نیازمند تأیید مدیر">
<input type="checkbox" name="auto_send" {% if row.template.auto_send %}checked{% endif %}>
<span class="smsx-switch__track"></span>
<span class="smsx-switch__text">{% if row.template.auto_send %}خودکار{% else %}با تأیید مدیر{% endif %}</span>
</label>
</div>
</div>
{% if row.timing.note %}
<p class="help smsx-note">{{ row.timing.note }}</p>
{% endif %}
<div class="smsx-editor">
<div class="smsx-editor__head">
<label class="smsx-label" for="text-{{ row.code }}">متن پیامک</label>
<div class="smsx-chips" role="group" aria-label="درج متغیر در متن">
{% for token, desc in variables %}
<button type="button" class="smsx-chip" data-smsx-insert="{{ token }}"
title="{{ desc }}" aria-label="درج {{ token }} — {{ desc }}">{{ token }}</button>
{% endfor %}
</div>
</div>
<textarea id="text-{{ row.code }}" class="textarea smsx-textarea" name="text" rows="6"
data-smsx-text aria-describedby="meta-{{ row.code }}"
placeholder="متن پیامک را اینجا بنویسید…">{{ row.template.text }}</textarea>
<div class="smsx-editor__foot" id="meta-{{ row.code }}">
<span class="smsx-metric" data-smsx-counter>—</span>
<span class="smsx-dot" aria-hidden="true">•</span>
<span class="smsx-metric" data-smsx-segments>—</span>
<span class="smsx-warn" data-smsx-warn hidden></span>
</div>
</div>
<div class="smsx-preview">
<span class="smsx-preview__label">پیش‌نمایش (با دادهٔ نمونه)</span>
<div class="smsx-bubble" data-smsx-preview>{{ row.preview }}</div>
<p class="help smsx-preview__hint">
مقادیر نمونه فقط برای نمایش است؛ هنگام ارسال، اطلاعات واقعی همان مشتری جای متغیرها می‌نشیند.
</p>
</div>
<div class="smsx-actions">
<button class="button is-success" type="submit">ذخیره</button>
<span class="smsx-dirty" data-smsx-dirty hidden>● تغییرات ذخیره‌نشده</span>
</div>
</form>
</details>
{% empty %}
<div class="notification is-warning">هیچ قالبی تعریف نشده است.</div>
{% endfor %}
</div>
</div>
{% endblock %}
{% block jsEnd %}
<script>
{% verbatim %}
(function () {
"use strict";
var configEl = document.getElementById("smsx-config");
if (!configEl) return;
var CFG = JSON.parse(configEl.textContent);
var TOKENS = CFG.tokens || [];
var SAMPLE = CFG.sample || {};
var TOKEN_RE = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
// GSM-7 پایه (یک نویسه) و نویسه‌های افزودنی (دو نویسه)
var GSM_BASIC = {};
" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\n\r".split("").forEach(function (c) { GSM_BASIC[c] = true; });
var GSM_EXT = {};
["^", "{", "}", "[", "]", "~", "|", "\\", "€"].forEach(function (c) { GSM_EXT[c] = true; });
function toFa(value) {
return String(value).replace(/\d/g, function (d) { return "۰۱۲۳۴۵۶۷۸۹"[d]; });
}
// شمارش نویسه و بخش‌های پیامک: GSM-7 ⇒ ۱۶۰/۱۵۳، یونیکد ⇒ ۷۰/۶۷
function measure(text) {
var chars = Array.from(text);
var gsm = true, len = 0;
for (var i = 0; i < chars.length; i++) {
var c = chars[i];
if (GSM_BASIC[c]) { len += 1; }
else if (GSM_EXT[c]) { len += 2; }
else { gsm = false; break; }
}
if (!gsm) len = chars.length;
var single = gsm ? 160 : 70, multi = gsm ? 153 : 67;
var parts = len === 0 ? 0 : (len <= single ? 1 : Math.ceil(len / multi));
return { len: len, parts: parts, gsm: gsm };
}
function fillSample(text) {
return text.replace(TOKEN_RE, function (match, name) {
return Object.prototype.hasOwnProperty.call(SAMPLE, name) ? SAMPLE[name] : "";
});
}
function unknownTokens(text) {
var found, seen = {}, out = [];
while ((found = TOKEN_RE.exec(text)) !== null) {
var name = "{" + found[1] + "}";
if (TOKENS.indexOf(name) === -1 && !seen[name]) { seen[name] = true; out.push(name); }
}
TOKEN_RE.lastIndex = 0;
return out;
}
function cardOf(el) { return el ? el.closest("[data-smsx-card]") : null; }
function refresh(card) {
var ta = card.querySelector("[data-smsx-text]");
if (!ta) return;
var text = ta.value;
var shown = fillSample(text);
var info = measure(shown);
var counter = card.querySelector("[data-smsx-counter]");
var segments = card.querySelector("[data-smsx-segments]");
var warn = card.querySelector("[data-smsx-warn]");
var preview = card.querySelector("[data-smsx-preview]");
var badge = card.querySelector("[data-smsx-badge]");
counter.textContent = toFa(info.len) + " نویسه";
if (info.parts === 0) {
segments.textContent = "متن خالی است";
segments.className = "smsx-metric";
} else {
segments.textContent = toFa(info.parts) + (info.parts === 1 ? " پیامک" : " پیامک (چندبخشی)");
segments.className = "smsx-metric " + (info.parts <= 1 ? "is-ok" : (info.parts <= 3 ? "is-warn" : "is-bad"));
}
if (badge) badge.textContent = info.parts === 0 ? "—" : toFa(info.parts) + " پیامک";
var unknown = unknownTokens(text);
if (unknown.length) {
warn.hidden = false;
warn.textContent = "متغیر ناشناخته: " + unknown.join("، ") + " — خالی رندر می‌شود.";
} else {
warn.hidden = true;
warn.textContent = "";
}
if (preview) preview.textContent = shown;
}
var DIRTY = false;
function markDirty(card) {
if (!card || card.classList.contains("is-dirty")) return;
card.classList.add("is-dirty");
var flag = card.querySelector("[data-smsx-dirty]");
if (flag) flag.hidden = false;
DIRTY = true;
}
function insertAtCursor(ta, token) {
ta.focus();
var value = ta.value;
var start = ta.selectionStart == null ? value.length : ta.selectionStart;
var end = ta.selectionEnd == null ? value.length : ta.selectionEnd;
var before = value.slice(0, start);
var prefix = (before && !/\s$/.test(before)) ? " " : "";
ta.value = before + prefix + token + value.slice(end);
var pos = start + prefix.length + token.length;
ta.setSelectionRange(pos, pos);
}
document.addEventListener("click", function (event) {
// بستن اعلان‌ها (Bulma JS بارگذاری نشده است).
var dismiss = event.target.closest(".notification .delete");
if (dismiss) {
var box = dismiss.closest(".notification");
if (box) box.remove();
return;
}
var insert = event.target.closest("[data-smsx-insert]");
if (insert) {
event.preventDefault();
var card = cardOf(insert);
var ta = card && card.querySelector("[data-smsx-text]");
if (!ta) return;
insertAtCursor(ta, insert.getAttribute("data-smsx-insert"));
refresh(card);
markDirty(card);
return;
}
if (event.target.closest("[data-smsx-expand-all]")) {
document.querySelectorAll("[data-smsx-card]").forEach(function (d) { d.open = true; refresh(d); });
return;
}
if (event.target.closest("[data-smsx-collapse-all]")) {
document.querySelectorAll("[data-smsx-card]").forEach(function (d) { d.open = false; });
}
});
document.addEventListener("input", function (event) {
var card = cardOf(event.target);
if (!card) return;
if (event.target.matches("[data-smsx-text]")) {
refresh(card);
markDirty(card);
} else if (event.target.matches('input[name="title"], input[name="timing_value"]')) {
markDirty(card);
}
});
document.addEventListener("change", function (event) {
var input = event.target;
if (!input.matches('input[name="is_enabled"], input[name="auto_send"]')) return;
var card = cardOf(input);
if (!card) return;
markDirty(card);
if (input.name === "is_enabled") card.classList.toggle("is-off", !input.checked);
// متن سوئیچِ همان کلید به‌روز می‌شود (نه اولین سوئیچ کارت).
var sw = input.closest(".smsx-switch");
var label = sw && sw.querySelector(".smsx-switch__text");
if (label) {
label.textContent = input.name === "is_enabled"
? (input.checked ? "فعال" : "غیرفعال")
: (input.checked ? "خودکار" : "با تأیید مدیر");
}
});
// «toggle» رویداد حباب‌شو نیست؛ با capture گرفته می‌شود.
document.addEventListener("toggle", function (event) {
if (event.target.matches && event.target.matches("[data-smsx-card]") && event.target.open) {
refresh(event.target);
}
}, true);
document.addEventListener("submit", function () { DIRTY = false; });
window.addEventListener("beforeunload", function (event) {
if (!DIRTY) return;
event.preventDefault();
event.returnValue = "";
});
function init() {
document.querySelectorAll("[data-smsx-card]").forEach(function (card) {
var toggle = card.querySelector('input[name="is_enabled"]');
if (toggle) card.classList.toggle("is-off", !toggle.checked);
refresh(card);
});
// پس از ذخیره، همان کارت باز و در نمای دید باقی می‌ماند (لنگر #type-…).
var hash = window.location.hash;
if (hash.indexOf("#type-") === 0) {
var target = document.getElementById(hash.slice(1));
if (target) {
document.querySelectorAll("[data-smsx-card][open]").forEach(function (d) {
if (d !== target) d.open = false;
});
target.open = true;
refresh(target);
target.scrollIntoView({ block: "start" });
}
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
{% endverbatim %}
</script>
{% endblock %}
+11 -1
View File
@@ -1,5 +1,6 @@
from django import template
from persiantools.jdatetime import JalaliDate
from django.utils import timezone
from persiantools.jdatetime import JalaliDate, JalaliDateTime
import datetime
register = template.Library()
@@ -8,8 +9,17 @@ def toJalali(value):
if(value):
return JalaliDate(value).strftime("%Y/%m/%d")
def toJalaliDT(value):
"""تاریخ و ساعت به شمسی، در تایم‌زون محلی پروژه."""
if not value:
return ""
if timezone.is_aware(value):
value = timezone.localtime(value)
return JalaliDateTime(value).strftime("%Y/%m/%d %H:%M")
def has_group(user, group_name):
return user.groups.filter(name=group_name).exists()
register.filter('has_group', has_group)
register.filter('toJalali', toJalali)
register.filter('toJalaliDT', toJalaliDT)
+14
View File
@@ -80,4 +80,18 @@ urlpatterns = [
path('addSarresid/<int:cs_id>', views.addSarresid, name='addSarresid'),
path('pardakhtNesiye/<int:cs_id>', views.pardakhtNesiye, name='pardakhtNesiye'),
# === مدیریت پیامک ===
path('sms/settings', views.sms_settings, name='sms_settings'),
path('sms/templates', views.sms_templates, name='sms_templates'),
path('sms/template/<int:t_id>/edit', views.sms_template_edit, name='sms_template_edit'),
path('sms/customer/<int:c_id>/toggle', views.sms_customer_toggle, name='sms_customer_toggle'),
path('sms/customer/<int:c_id>/primary', views.sms_primary_toggle, name='sms_primary_toggle'),
path('sms/customer/<int:c_id>/send', views.sms_send_custom, name='sms_send_custom'),
path('sms/queue', views.sms_queue, name='sms_queue'),
path('sms/queue/bulk', views.sms_queue_bulk, name='sms_queue_bulk'),
path('sms/queue/empty', views.sms_queue_empty, name='sms_queue_empty'),
path('sms/queue/<int:q_id>/approve', views.sms_queue_approve, name='sms_queue_approve'),
path('sms/queue/<int:q_id>/delete', views.sms_queue_delete, name='sms_queue_delete'),
path('sms/queue/<int:q_id>/retry', views.sms_queue_retry, name='sms_queue_retry'),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
+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:
+75 -41
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', '')
# آپدیت فیلدهای قدیمی جهت حفظ سازگاری
# آپدیت فیلدهای قدیمی جهت حفظ سازگاری (تنها وقتی شماره‌ای ارسال شده باشد)
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()
# بروزرسانی جدول بی‌نهایت (حذف قبلی‌ها و درج مجدد)
# بروزرسانی جدول شماره‌ها فقط در صورت ارسال شماره‌های جدید.
# اگر فرم شماره‌ای نفرستاده باشد (فیلدهای غیرفعال)، شماره‌های فعلی دست‌نخورده می‌مانند.
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'])
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")
+43
View File
@@ -0,0 +1,43 @@
# راه‌اندازی چهار سرویس از یک ایمیج مشترک:
# web → وب‌سرور Gunicorn (می‌گریشن/استاتیک فقط اینجا اجرا می‌شود)
# worker → تخلیه صف پیامک (sms_worker)
# scheduler → اسکن دوره‌ای و صف‌کردن یادآوری‌ها (sms_scan --loop)
# delivery → استعلام وضعیت تحویل پیامک‌ها (sms_delivery --sleep 30)
#
# توجه: دیتابیس SQLite است؛ مقیاس افقی (چند replica) ممنوع.
x-app: &app
build: .
env_file: production.env
volumes:
- ./sqlite:/app/sqlite
restart: unless-stopped
services:
web:
<<: *app
ports:
- "8000:8000"
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/').read()\""]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
worker:
<<: *app
command: ["python", "manage.py", "sms_worker", "--sleep", "3"]
depends_on:
- web
scheduler:
<<: *app
command: ["python", "manage.py", "sms_scan", "--loop", "--interval", "300"]
depends_on:
- web
delivery:
<<: *app
command: ["python", "manage.py", "sms_delivery", "--sleep", "30"]
depends_on:
- web
+15 -2
View File
@@ -3,11 +3,19 @@
# ۱. متوقف کردن آنی کل فرآیند کانتینر در صورت بروز خطا در هر لایه
set -e
# اگر کامند سفارشی داده شده (worker/scheduler) می‌گریشن/استاتیک فقط در سرویس وب اجرا می‌شود
RUN_WEB_SETUP=1
if [ "$#" -gt 0 ] && [ "$1" != "gunicorn" ]; then
RUN_WEB_SETUP=0
fi
if [ "$RUN_WEB_SETUP" = "1" ]; then
echo "==> [1/4] Collecting Static Files..."
python manage.py collectstatic --noinput --clear
echo "==> [2/4] Running Database Migrations..."
python manage.py migrate --noinput
fi
echo "==> [3/4] Optimizing SQLite Storage (WAL & Synchronous)..."
# استفاده از بلاک EOF پایتون برای خوانایی، ایزولاسیون خطا و ثبت سیستماتیک لاگ‌ها
@@ -40,6 +48,11 @@ except Exception as e:
sys.exit(1)
EOF
echo "==> [4/4] Starting Gunicorn Web Server..."
# جایگزین کردن پروسس PID 1 با Gunicorn برای مدیریت اصولی سیگنال‌های توقف داکر (Graceful Shutdown)
echo "==> [4/4] Starting Application..."
# اگر کامند سفارشی داده شده باشد (مثل sms_worker/sms_scan) همان اجرا می‌شود؛
# در غیر این‌صورت وب‌سرور Gunicorn به‌عنوان PID 1 جایگزین می‌شود (Graceful Shutdown).
if [ "$#" -gt 0 ]; then
exec "$@"
else
exec gunicorn --bind 0.0.0.0:8000 khatibi.wsgi:application
fi
+121
View File
@@ -0,0 +1,121 @@
# اصلاح دستی شماره‌های قدیمی مشتریان
## چرا؟
فیلد‌های `phone1` / `phone2` / `phone3` در مدل `Customer` از نوع `CharField` هستند و
سال‌ها داده در آن‌ها به هر شکلی وارد شده: بدون صفر ابتدایی (`9133722815`)، ارقام
فارسی، فاصله/خط تیره، شمارهٔ ثابت ۸رقمی، یا کلاهی متن آزاد (نام، تاریخ نصب، «معرف …»).
هدف: هرچه در فیلد شماره هست پاک‌سازی شود، موبایل به شکل استاندارد `09xxxxxxxxx`
در بیاید، و **هیچ متنی از سیستم حذف نشود** — متن‌های برداشته‌شده در فیلد
`Customer.notes` (توضیحات) می‌مانند و در پنل، ستون سمت راست صفحهٔ مشتری نمایش
داده می‌شوند.
> این کار **یک‌بار** لازم است. دستور idempotent است؛ اجرای دوباره‌اش هیچ تغییری
> ایجاد نمی‌کند و یادداشتی را تکرار نمی‌کند.
## قاعدهٔ پاک‌سازی
| ورودی (نمونه) | نتیجه |
|---|---|
| `9133722815` | `09133722815` (و اصل متن در توضیحات) |
| `۰۹۱۳۳۷۲۲۸۱۵` | `09133722815` (و اصل متن در توضیحات) |
| `0913-372-2815` | `09133722815` (و اصل متن در توضیحات) |
| `57322182/9130261671` | `09130261671` (کل متن اصلی در توضیحات) |
| `57387608` (ثابت ۸رقمی) | **دست‌نخورده** |
| `اعظمی` (متن آزاد) | خالی؛ `اعظمی` در توضیحات |
| `0` (تکهٔ بی‌معنی) | خالی؛ `0` در توضیحات |
خلاصه: هرچیزی که در مقدار جدید نمی‌ماند، بی‌کم‌وکاست در `notes` ثبت می‌شود.
## اجرای دستور
دستور منیجمنت: `fix_customer_phones`
### ۱) اول «شبیه‌سازی» — بدون هیچ تغییری
فقط گزارش می‌دهد چه چیزی تغییر می‌کند. **قبل از هر اجرای واقعی این را بزنید.**
از WSL:
```bash
cd /mnt/d/projects/khatibi
.venv/Scripts/python.exe manage.py fix_customer_phones --dry-run
```
از ویندوز (PowerShell / CMD):
```bat
cd D:\projects\khatibi
.venv\Scripts\python.exe manage.py fix_customer_phones --dry-run
```
خروجی نمونه:
```
شبیه‌سازی (بدون تغییر): 653 مشتری، 700 فیلد اصلاح شد، 640 یادداشت ثبت شد.
```
### ۲) اجرای واقعی
```bash
.venv/Scripts/python.exe manage.py fix_customer_phones
```
خروجی نمونه:
```
پاک‌سازی انجام شد: 653 مشتری، 700 فیلد اصلاح شد، 640 یادداشت ثبت شد.
```
### ۳) اجرای دوباره برای اطمینان (اختیاری)
دوباره بزنید؛ باید صفر باشد:
```
پاک‌سازی انجام شد: 0 مشتری، 0 فیلد اصلاح شد، 0 یادداشت ثبت شد.
```
## اجرا روی سرور اصلی (production)
دستور برای همین کار نوشته شده: روی دیتابیس production هم بی‌خطر است.
```bash
cd /path/to/khatibi # مسیر پروژه روی سرور
source .venv/bin/activate # یا مسیر venv سرور
python manage.py fix_customer_phones --dry-run # اول شبیه‌سازی
python manage.py fix_customer_phones # سپس اجرا
```
**توصیهٔ مهم:** پیش از اجرای واقعی روی production یک بکاپ از دیتابیس بگیرید
(دستور `sqlite_db_backup` در همین پروژه موجود است). شبیه‌سازی فقط می‌خواند و
بی‌خطر است، ولی بکاپ عادت خوبی است.
## راه جایگزین: مهاجرت (migration)
هنگام `manage.py migrate`، مهاجرت `0067_customer_notes` هم فیلد `notes` را می‌سازد
و هم **همین پاک‌سازی را یک‌بار** روی دیتابیس اجرا می‌کند. پس اگر سرور را با
`migrate` به‌روز می‌کنید، لازم نیست دستور را جدا بزنید؛ نتیجه یکی است.
هر دو مسیر به یک پیاده‌سازی مشترک
(`admin_panel/sms/phone_cleanup.py`) تکیه دارند.
## مشاهدهٔ نتیجه
پس از اجرا، به `/panel/customer/<id>` بروید. فیلد **توضیحات** در ستون سمت راست
(کنار نام، آدرس، شماره‌های تماس) متن‌های برداشته‌شده را نشان می‌دهد، با سرصفحهٔ:
```
— پاک‌سازی خودکار فیلدهای تلفن —
«phone3»: نصب جدید 1401/07/02
```
این فیلد در حالت «ویرایش» قابل تغییر است و مقدارش ذخیره می‌شود.
## نکته‌ها
- شماره‌های ثابت ۸رقمی طبق تصمیم، **دست‌نخورده** می‌مانند.
- جدول `PhoneNumber` (شماره‌های جدید) دست‌کاری نمی‌شود؛ فقط فیلدهای قدیمی.
- اگر دستور را با قاعدهٔ قدیمی روی یک دیتابیس اجرا کرده باشید، تکه‌های بی‌معنی
(`0`، `573`) که آن زمان بدون یادداشت پاک شده‌اند قابل بازیابی نیستند. روی
production که هنوز اجرا نشده، همه‌چیز کامل حفظ می‌شود.
+62 -15
View File
@@ -8,41 +8,45 @@
"6": "cheque_inquiry.py",
"7": "admin_panel/templates/admin_panel/baseHesabdari.html",
"8": "django_db_models_deletion",
"9": "django_conf",
"9": "0051_auto_20260705_1356.py",
"10": "admin_panel/storeBK1.html",
"11": "ViewSmokeTests",
"12": "admin_panel/templates/admin_panel/customerAction.html",
"13": "User",
"14": "admin_panel/templates/admin_panel/bedehi.html",
"15": "django_contrib",
"16": "models/__init__.py",
"17": "admin_panel/templates/admin_panel/customerBase.html",
"14": "run_all",
"15": "admin_panel/admin.py",
"16": "stock_in",
"17": "admin_panel/templates/admin_panel/bedehi.html",
"18": "hesabdariCheckPassFail.html",
"19": "Reference guide",
"20": "ProductInStore",
"20": "normalize_mobile",
"21": "graphify",
"22": "hesabdariResidNashode.html",
"23": "0017_factoriteam_garantyesh.py",
"24": "django_apps",
"24": "scanner.py",
"25": "0002_auto_20220114_1616.py",
"26": "0021_alter_article_type.py",
"26": "SMSResult",
"27": "Rip'n Fast. Fewer Tokens. Better Code.",
"28": "0027_alter_action_nazar.py",
"29": "admin_panel/entezar.html",
"28": "models/sms.py",
"29": "admin_panel/templates/admin_panel/baseStore.html",
"30": "views/finance.py",
"31": "Customer Modals UI",
"32": "0029_alter_article_options.py",
"32": "views/factors.py",
"33": "0034_alter_article_sarresid.py",
"34": "0005_auto_20220121_2054.py",
"34": "views/sms.py",
"35": "0035_alter_article_sarresid.py",
"36": "0009_remove_storeroom_user.py",
"36": "django_db_models",
"37": "0010_auto_20220122_1546.py",
"38": "0040_customer_laghv_bazdid.py",
"39": "0043_sarresid_user.py",
"39": "test_sms.py",
"40": "0048_alter_productinstore_options.py",
"41": "0032_article_paystatus.py",
"42": "0033_article_nahvepardakht.py",
"43": "make_customer",
"44": "sms0098.py",
"45": "0044_sarresid_desc.py",
"46": "models/__init__.py",
"47": "make_user",
"48": "GEMINI.md",
"49": "admin/migrations/__init__.py",
"50": "Customer Action Include",
@@ -88,8 +92,51 @@
"90": "khatibi/__init__.py",
"91": "Extended Requirements File",
"92": "Requirements File",
"93": "django_shortcuts",
"94": "resolve_recipients",
"95": "factories.py",
"96": "write_tree",
"97": "TemplatesPageUITests",
"98": "reconciliation_tool.py",
"99": "ModelsPackageContractTests",
"100": "django_conf",
"101": "Command",
"102": "UrlContractTests",
"103": "customerIDFactorBackup.html",
"104": "system.py",
"107": "tests/__init__.py"
"105": "BehinSMSProvider",
"106": "test_customers.py",
"107": "tests/__init__.py",
"108": "Command",
"109": "Command",
"110": "0061_sms_timing_and_primary.py",
"111": "bale/providers.py",
"112": "os",
"113": "Command",
"114": "0058_seed_sms_templates.py",
"115": "test_factors.py",
"116": "Customer",
"117": "customerNewBackup.html",
"118": "0059_sms_flow_control.py",
"119": "0060_sms_permission_label.py",
"120": "datetime",
"121": "0008_storeroom_user.py",
"122": "0013_alter_factor_options.py",
"123": "0018_alter_factoriteam_product.py",
"124": "0020_auto_20220126_1532.py",
"125": "0022_article_typepy.py",
"126": "0025_action_actiontype.py",
"127": "0028_action_grade.py",
"128": "0030_auto_20220414_1047.py",
"129": "0036_sarresid.py",
"130": "0037_sarresid_sarresiddt.py",
"131": "0039_customer_last_bazdid_dt.py",
"132": "0041_auto_20240216_1607.py",
"133": "0047_alter_productinstore_movement_type.py",
"134": "0049_alter_productinstore_options.py",
"135": "0057_sms.py",
"136": "admin_panel/sms/__init__.py",
"137": "bale/__init__.py",
"138": "integrations/__init__.py",
"139": "integrations/sms/__init__.py"
}
+1 -1
View File
@@ -1 +1 @@
{"0": "c3a36a9368a041d9", "1": "90337c8ac61b6b73", "2": "6076efb85d7e0cf6", "3": "102a7faeadcd9f37", "4": "c87f3f051be1d6cf", "5": "38d8150b6fcc29dd", "6": "8f0f51ff611eb425", "7": "828492c80f59c13e", "8": "5a34a83a0a037abe", "9": "ab8fcea052c3ff15", "10": "9701f1591d8fda6e", "11": "bf7d35550596f299", "12": "59f959de834a65cb", "13": "1b1f9f2676dbbf2f", "14": "138ab97b0f9bf63e", "15": "3cc770d8cc99f8ae", "16": "b3f1fcf44a1ef48c", "17": "42f1e68ac27c0389", "18": "90146abe85d9419f", "19": "21f888d34b160f31", "20": "a6cdf08fcc949de1", "21": "0f632fc57e9feeb0", "22": "066f74b181692719", "23": "a800ecffc01fc9d5", "24": "1cdbbc0afb5b81e2", "25": "a19c211702401a5b", "26": "89a02225d25a53c3", "27": "c9e7bf6ac46f263c", "28": "be1b722cf1a01583", "29": "7db54a3d7ae0017c", "30": "c6c68925b6415da0", "31": "03f8896e9b60ca96", "32": "cde232c2541ef2d0", "33": "c02bc9595eaedd36", "34": "6886ad7a5751faca", "35": "7714d721fe893ef5", "36": "a1a1f3c1cdff72cf", "37": "2f1621907db6f86f", "38": "ef8221c5fdeee7f8", "39": "eb04d6a3e3a79bf8", "40": "bf60a8731033e467", "41": "d5c0f5f6714c9991", "42": "9948fb8b548535ed", "45": "90e57f26f669372d", "48": "66ed76491a412a72", "49": "e67668b62ee6c62c", "50": "68aee39cd24db354", "51": "09b7297ea62919f2", "52": "054401d27ffaa4b2", "53": "dace81c1995fb321", "54": "19e301aeace95bb2", "55": "d6fd0cec4634a0b0", "56": "0230d355a6dabb54", "57": "5cebbb5528321795", "58": "536fe28d24c361f5", "59": "b08bdc2712cb0f12", "60": "6ce8444f504022cc", "61": "8021f52559633383", "62": "d8e34a3b3eb43ee0", "63": "056b8d6b5072289b", "64": "171c34c74a95481b", "65": "73e9eca6f2ca1c9b", "66": "dcc2230ae751c29a", "67": "2a617b932f5d92b2", "68": "2f5417a69eb1d15e", "69": "d55cb638532fdfeb", "70": "06cae4389208269f", "71": "e9c0a7be255e9b26", "72": "455b4ca39e55ecb0", "73": "8105c55538c34081", "74": "74304670b54c018f", "75": "a27f876eabb044b2", "76": "73f74dd7a40776d6", "77": "29d433d164b99f89", "78": "3f44aa48aa05d597", "79": "ca8a7efba0a3e579", "80": "5cc770af9d184d77", "81": "2d31fa723128b164", "82": "9d814c2520329846", "83": "f97d1a5c3a066598", "84": "14f84a6e4cd311d6", "85": "3a592d27b1f4ccc9", "86": "0bf6f9d0aeab4fd8", "87": "8b491bb3ff0649dd", "88": "638812cee15d1b9b", "89": "3dd16d68928a479c", "90": "72dad91d66d471ce", "91": "353a9d09eaae47ea", "92": "2d29260874184e4a", "99": "28cdf3baa198be4d", "102": "2de2cdee1890b458", "104": "c5743041b2101132", "107": "43bbf35b96423d46"}
{"0": "5ff8a72726a083db", "1": "219b01f43ec5f76d", "2": "edab4be6f8bf260c", "3": "70018840272ab80e", "4": "d36df3c85181f806", "5": "9eb1daeea9dda6c2", "6": "076144c85b2925b4", "7": "0928f3aa4e597117", "8": "0e080dc901d1f670", "9": "a00d0684d05c01a4", "10": "9701f1591d8fda6e", "11": "5290f7a7558a9c3e", "12": "e6a8282720eded0c", "13": "05119fe8608a0cbd", "14": "903ab35120e04b54", "15": "91b75fb079f9c02c", "16": "c12f70f2136b1a97", "17": "06959e26b0aedfb2", "18": "90146abe85d9419f", "19": "21f888d34b160f31", "20": "bc287e5355564fe6", "21": "0f632fc57e9feeb0", "22": "af89b22ae0662d6d", "23": "a800ecffc01fc9d5", "24": "955593b6e631a7a7", "25": "a19c211702401a5b", "26": "e72a0ec031902754", "27": "c9e7bf6ac46f263c", "28": "1a67e40fab1eeadd", "29": "3f3a4eb6e6b2f4d6", "30": "389c17cd386d28f2", "31": "03f8896e9b60ca96", "32": "40771cc7fa5ee930", "33": "c02bc9595eaedd36", "34": "d76e40111efda56a", "35": "7714d721fe893ef5", "36": "466b12915e08a4f0", "37": "2f1621907db6f86f", "38": "ef8221c5fdeee7f8", "39": "a890d0d5da72d758", "40": "bf60a8731033e467", "41": "d5c0f5f6714c9991", "42": "9948fb8b548535ed", "43": "61882165e32527ce", "44": "f1310e6117296d59", "45": "90e57f26f669372d", "46": "95f91e2eaf52002c", "47": "32e693b104a691c2", "48": "66ed76491a412a72", "49": "e67668b62ee6c62c", "50": "68aee39cd24db354", "51": "09b7297ea62919f2", "52": "054401d27ffaa4b2", "53": "dace81c1995fb321", "54": "19e301aeace95bb2", "55": "d6fd0cec4634a0b0", "56": "0230d355a6dabb54", "57": "5cebbb5528321795", "58": "536fe28d24c361f5", "59": "b08bdc2712cb0f12", "60": "6ce8444f504022cc", "61": "8021f52559633383", "62": "d8e34a3b3eb43ee0", "63": "056b8d6b5072289b", "64": "171c34c74a95481b", "65": "73e9eca6f2ca1c9b", "66": "dcc2230ae751c29a", "67": "2a617b932f5d92b2", "68": "2f5417a69eb1d15e", "69": "d55cb638532fdfeb", "70": "06cae4389208269f", "71": "e9c0a7be255e9b26", "72": "455b4ca39e55ecb0", "73": "8105c55538c34081", "74": "74304670b54c018f", "75": "a27f876eabb044b2", "76": "73f74dd7a40776d6", "77": "29d433d164b99f89", "78": "3f44aa48aa05d597", "79": "ca8a7efba0a3e579", "80": "5cc770af9d184d77", "81": "2d31fa723128b164", "82": "9d814c2520329846", "83": "f97d1a5c3a066598", "84": "14f84a6e4cd311d6", "85": "3a592d27b1f4ccc9", "86": "0bf6f9d0aeab4fd8", "87": "8b491bb3ff0649dd", "88": "638812cee15d1b9b", "89": "3dd16d68928a479c", "90": "72dad91d66d471ce", "91": "353a9d09eaae47ea", "92": "2d29260874184e4a", "93": "00d4d9fc79267971", "94": "8fedb8424b4f7d54", "95": "c7ce06d5e4e87224", "96": "9d46bbe4641a8543", "97": "68b25e60cbe5368c", "98": "8f5a6aa62c618b5f", "99": "33819123dbb4d454", "100": "e8d32595704b722a", "101": "e4fcae6cb365fba9", "102": "32ed40052fe2c3f8", "103": "8ff365aee1c1a767", "104": "c5743041b2101132", "105": "891ec2c014046f05", "106": "0c4707ef57c917a2", "107": "43bbf35b96423d46", "108": "88f880da5a61f9dc", "109": "dbfbf8557361f5ea", "110": "6e90b41fbd82c265", "111": "118bde95524551e0", "112": "b85898490162caed", "113": "d55977ec0e3ae213", "114": "df7ea8ce3fa52be3", "115": "460dadaff123e841", "116": "4faac8c35fa8b703", "117": "aea8fc3ff351253f", "118": "bda9be183e0f933e", "119": "63526d01888c4f22", "120": "329e844b7c9edf2d", "121": "37d2f4398ee065c5", "122": "49438bf9f08f11ff", "123": "b9687fc2d4953aed", "124": "6288b9f165b037f4", "125": "f97753a176d20dae", "126": "9cc7a507cfecd8fb", "127": "edb619bf5ac0fc49", "128": "8cb6b3db3aadb973", "129": "c9638aef2c5c82d8", "130": "c6b3f6e0a15a6a0b", "131": "52502a4c1ef75ce6", "132": "8d20e5c1bce8916d", "133": "b5ce3fce9192f8b8", "134": "6cef5bc37e710e62", "135": "0098e532f53735f2", "136": "b738c5aefe939cff", "137": "a9bf69b3d550abef", "138": "b039e1c69c8fb7b9", "139": "60029bf0948fc02a"}
+210 -87
View File
@@ -1,65 +1,68 @@
# Graph Report - khatibi (2026-10-09)
# Graph Report - khatibi (2026-10-10)
## Corpus Check
- 124 files · ~93,934 words
- 160 files · ~127,728 words
- Verdict: corpus is large enough that graph structure adds value.
- Unclassified: 7 file(s) not represented in the graph (top: (none) 4, .env 2, .csv 1)
## Summary
- 821 nodes · 1291 edges · 93 communities (26 shown, 67 thin omitted)
- Extraction: 92% EXTRACTED · 8% INFERRED · 0% AMBIGUOUS · INFERRED: 103 edges (avg confidence: 0.86)
- 1280 nodes · 2237 edges · 140 communities (47 shown, 93 thin omitted)
- Extraction: 94% EXTRACTED · 6% INFERRED · 0% AMBIGUOUS · INFERRED: 131 edges (avg confidence: 0.87)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `6f36db63`
- Built from commit: `250cdbab`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
## Community Hubs (Navigation)
- views/store.py
- project_tree.py
- sqlite_db_backup.py
- django_db
- Customer ID Backup/New Factor Template
- views/customers.py
- cheque_inquiry.py
- admin_panel/templates/admin_panel/baseHesabdari.html
- django_db_models_deletion
- django_conf
- 0051_auto_20260705_1356.py
- admin_panel/storeBK1.html
- ViewSmokeTests
- admin_panel/templates/admin_panel/customerAction.html
- User
- run_all
- admin_panel/admin.py
- stock_in
- admin_panel/templates/admin_panel/bedehi.html
- django_contrib
- models/__init__.py
- admin_panel/templates/admin_panel/customerBase.html
- hesabdariCheckPassFail.html
- Reference guide
- ProductInStore
- normalize_mobile
- graphify
- hesabdariResidNashode.html
- 0017_factoriteam_garantyesh.py
- django_apps
- scanner.py
- 0002_auto_20220114_1616.py
- 0021_alter_article_type.py
- SMSResult
- Rip'n Fast. Fewer Tokens. Better Code.
- 0027_alter_action_nazar.py
- admin_panel/entezar.html
- models/sms.py
- admin_panel/templates/admin_panel/baseStore.html
- views/finance.py
- Customer Modals UI
- 0029_alter_article_options.py
- views/factors.py
- 0034_alter_article_sarresid.py
- 0005_auto_20220121_2054.py
- views/sms.py
- 0035_alter_article_sarresid.py
- 0009_remove_storeroom_user.py
- django_db_models
- 0010_auto_20220122_1546.py
- 0040_customer_laghv_bazdid.py
- 0043_sarresid_user.py
- test_sms.py
- 0048_alter_productinstore_options.py
- 0032_article_paystatus.py
- 0033_article_nahvepardakht.py
- make_customer
- sms0098.py
- 0044_sarresid_desc.py
- models/__init__.py
- make_user
- GEMINI.md
- Customer Action Include
- New Customer Base Template
@@ -94,21 +97,57 @@
- toCurrency
- Extended Requirements File
- Requirements File
- django_shortcuts
- resolve_recipients
- factories.py
- write_tree
- TemplatesPageUITests
- reconciliation_tool.py
- ModelsPackageContractTests
- django_conf
- Command
- UrlContractTests
- customerIDFactorBackup.html
- system.py
- BehinSMSProvider
- test_customers.py
- Command
- Command
- 0061_sms_timing_and_primary.py
- bale/providers.py
- Command
- 0058_seed_sms_templates.py
- Customer
- customerNewBackup.html
- 0059_sms_flow_control.py
- 0060_sms_permission_label.py
- 0008_storeroom_user.py
- 0013_alter_factor_options.py
- 0018_alter_factoriteam_product.py
- 0020_auto_20220126_1532.py
- 0022_article_typepy.py
- 0025_action_actiontype.py
- 0028_action_grade.py
- 0030_auto_20220414_1047.py
- 0036_sarresid.py
- 0037_sarresid_sarresiddt.py
- 0039_customer_last_bazdid_dt.py
- 0041_auto_20240216_1607.py
- 0047_alter_productinstore_movement_type.py
- 0049_alter_productinstore_options.py
- 0057_sms.py
## God Nodes (most connected - your core abstractions)
1. `Rip'n Fast. Fewer Tokens. Better Code.` - 23 edges
2. `User` - 22 edges
3. `create_transfer_request()` - 22 edges
4. `Reference guide` - 18 edges
5. `current_stock()` - 16 edges
6. `make_user()` - 15 edges
7. `stock_in()` - 15 edges
8. `Customer ID Backup/New Factor Template` - 15 edges
9. `approve_transfer_request()` - 13 edges
10. `make_product()` - 13 edges
1. `make_customer()` - 37 edges
2. `make_user()` - 24 edges
3. `Rip'n Fast. Fewer Tokens. Better Code.` - 23 edges
4. `User` - 22 edges
5. `create_transfer_request()` - 22 edges
6. `enqueue_sms()` - 21 edges
7. `SMSResult` - 20 edges
8. `normalize_mobile()` - 20 edges
9. `SMSProvider` - 19 edges
10. `Reference guide` - 18 edges
## Surprising Connections (you probably didn't know these)
- `check_financial_discrepancy()` --uses--> `Customer` [INFERRED]
@@ -119,7 +158,7 @@
ripwire - README.md → admin/views.py
- `VisitQueue` --uses--> `User` [INFERRED]
admin_panel/models/actions.py → admin/models.py
- `StoreRoom` --uses--> `User` [INFERRED]
- `StoreTransferRequest` --uses--> `User` [INFERRED]
admin_panel/models/store.py → admin/models.py
## Import Cycles
@@ -153,71 +192,67 @@
- **New Factor Creation Flow** — admin_panel_templates_admin_panel_customeridbackup_html, django_url_admin_panel_productsjson, api_endpoint_panel_productmojodi, admin_panel_templates_admin_panel_customeridbackup_html_orderlist, admin_panel_templates_admin_panel_customeridbackup_html_buildorderlist, django_url_admin_panel_newfactor [INFERRED]
- **Store and Inventory Management Flow** — admin_panel_templates_admin_panel_basestore, concept_url_admin_panel_stores, concept_url_admin_panel_productadd, concept_url_admin_panel_operations, concept_url_admin_panel_store_transfers [INFERRED]
## Communities (93 total, 67 thin omitted)
## Communities (140 total, 93 thin omitted)
### Community 0 - "views/store.py"
Cohesion: 0.06
Nodes (28): StoreRoom, Meta, StoreRoomOwner, StoreTransferRequest, Meta, Status, approve_transfer_request(), cancel_transfer_request() (+20 more)
Cohesion: 0.07
Nodes (26): StoreTransferRequest, Meta, Status, approve_transfer_request(), cancel_transfer_request(), _check_source_store_permission(), _check_target_store_permission(), create_transfer_request() (+18 more)
### Community 1 - "project_tree.py"
Cohesion: 0.05
Nodes (18): remove_query_param(), check_financial_discrepancy(), entrypoint.sh script, run_360_audit(), main(), Counts, format_header(), generate_snapshot() (+10 more)
### Community 2 - "sqlite_db_backup.py"
Cohesion: 0.06
Nodes (3): Command, Command, Command
Cohesion: 0.21
Nodes (6): is_excluded_file(), main(), non_negative_integer(), parse_args(), sorted_entries(), warn()
### Community 3 - "django_db"
Cohesion: 0.06
Nodes (16): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+8 more)
Cohesion: 0.07
Nodes (14): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+6 more)
### Community 4 - "Customer ID Backup/New Factor Template"
Cohesion: 0.07
Nodes (17): Customer ID Backup/New Factor Template, all_products (JS variable), buildOrderList (JS function), orderList (JS variable), Client-side script in customerIDFactorBackup.html, URL: admin_panel:customerEdit, admin_panel:newFactor URL, Category Data (+9 more)
Cohesion: 0.15
Nodes (4): Customer ID Backup/New Factor Template, all_products (JS variable), buildOrderList (JS function), orderList (JS variable)
### Community 5 - "views/customers.py"
Cohesion: 0.05
Nodes (22): Customer, Meta, PhoneNumber, customer(), customerDel(), customerEdit(), customerFactor(), customerFactors() (+14 more)
Cohesion: 0.13
Nodes (16): customer_toggle_map(), customer(), customerDel(), customerEdit(), customerFactor(), customerFactors(), customerHesabdari(), customerHesabdariAdd() (+8 more)
### Community 6 - "cheque_inquiry.py"
Cohesion: 0.17
Nodes (6): api_check_sabt_inquiry(), api_check_status_inquiry(), parse_cheque_result_html(), run_bpi_cheque_inquiry_logic(), run_cbi_sayad_inquiry_logic(), solve_base64_captcha()
### Community 7 - "admin_panel/templates/admin_panel/baseHesabdari.html"
Cohesion: 0.08
Nodes (22): admin_panel/base.html, Django Custom Tags: custom_tags, URL: admin_panel:ActionListUserToday, URL: admin_panel:bedehi, URL: admin_panel:customers, URL: admin_panel:entezar, URL: admin_panel:hesabdari, URL: admin_panel:hesabdari_residnashone (+14 more)
Cohesion: 0.13
Nodes (14): URL: admin_panel:ActionListUserToday, URL: admin_panel:bedehi, URL: admin_panel:entezar, URL: admin_panel:hesabdari, URL: admin_panel:hesabdari_residnashone, URL: admin_panel:index, URL: admin_panel:p_logout, URL: admin_panel:rezayat (+6 more)
### Community 8 - "django_db_models_deletion"
Cohesion: 0.08
Nodes (12): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+4 more)
### Community 9 - "django_conf"
Cohesion: 0.09
Nodes (9): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+1 more)
Cohesion: 0.06
Nodes (15): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+7 more)
### Community 10 - "admin_panel/storeBK1.html"
Cohesion: 0.11
Nodes (17): Django CSRF Token, Django Custom Tags, Django Static Files, Django URL: admin_panel:newStore, Django URL: admin_panel:operations, Django URL: admin_panel:p_login, Django URL: admin_panel:store, Form Field: password (+9 more)
### Community 12 - "admin_panel/templates/admin_panel/customerAction.html"
Cohesion: 0.16
Nodes (10): admin_panel/templates/admin_panel/customerActionInc.html, admin_panel:customerNewAction URL, Action Data Model, DataTables Library, jQuery, User Data Model, Django 'url' template tag, Bulma CSS (+2 more)
Cohesion: 0.13
Nodes (13): admin_panel/templates/admin_panel/customerActionInc.html, admin_panel:customerNew, admin_panel:customerNewAction URL, Action Data Model, Articl Model, Customer Model, DataTables Library, jQuery (+5 more)
### Community 13 - "User"
Cohesion: 0.15
Nodes (19): User, Action, VisitQueue, Factor, Meta, FactorIteam, UserProfile, ActionList() (+11 more)
Cohesion: 0.07
Nodes (30): User, Action, VisitQueue, Factor, Meta, FactorIteam, Product, Meta (+22 more)
### Community 14 - "admin_panel/templates/admin_panel/bedehi.html"
Cohesion: 0.22
Nodes (8): Articl Model, Customer Model, articls, toJalali, admin_panel:bedehi, admin_panel:bedehiKhadamat, admin_panel:bedehiNasb, admin_panel:customer
### Community 14 - "run_all"
Cohesion: 0.05
Nodes (9): run_all(), activate_sms(), ColdStartTests, ConsoleEncodingTests, HttpProviderRobustnessTests, NormalizeTests, ScannerTests, WorkerTests (+1 more)
### Community 15 - "django_contrib"
Cohesion: 0.22
Nodes (3): StoreRoomAdmin, StoreRoomOwnerInline, StoreTransferRequestAdmin
### Community 15 - "admin_panel/admin.py"
Cohesion: 0.09
Nodes (10): CustomerSMSSettingAdmin, SMSLogAdmin, SMSProviderConfigAdmin, SMSProviderConfigForm, Meta, SMSQueueAdmin, SMSTemplateAdmin, StoreRoomAdmin (+2 more)
### Community 16 - "models/__init__.py"
Cohesion: 0.06
Nodes (22): Customer, Factor, FactorIteam, PhoneNumber, Product, ProductCategory, ProductInStore, StoreRoom (+14 more)
### Community 16 - "stock_in"
Cohesion: 0.11
Nodes (11): Customer, Factor, FactorIteam, Product, ProductInStore, StoreRoom, VisitQueue, Command (+3 more)
### Community 17 - "admin_panel/templates/admin_panel/bedehi.html"
Cohesion: 0.12
Nodes (11): admin_panel:pardakhtNesiye, /panel/passCheck/, all_sarresid, URL: admin_panel:newFactor, mandehesabKP, admin_panel.ViewReportMali, toJalali, admin_panel:bedehi (+3 more)
### Community 18 - "hesabdariCheckPassFail.html"
Cohesion: 0.20
@@ -227,62 +262,150 @@ Nodes (9): Customer Data Object, Factor Data Object, Sarresid Data Object, admin
Cohesion: 0.07
Nodes (27): 10. Quality evaluation, 11. Agent integration, 12. Supported languages and formats, 13. Performance, 14. Known limits, 15. Documentation, 16. Improvement, 17. License (+19 more)
### Community 20 - "ProductInStore"
Cohesion: 0.14
Nodes (8): Product, Meta, ProductCategory, ProductInStore, Meta, MovementType, ProductStockHistory, Meta
### Community 20 - "normalize_mobile"
Cohesion: 0.09
Nodes (12): customer_phone_entries(), _iter_sources(), normalize_primary_values(), PhoneEntry, primary_phones(), resolved_recipients(), save_primary_normalized(), stored_primary_normalized() (+4 more)
### Community 21 - "graphify"
Cohesion: 0.22
Nodes (8): Copilot Chat, graphify, graphify-out/graph.json, graphify-out/GRAPH_REPORT.md, graphify-out/wiki/index.md, graphify explain, graphify path, graphify query
### Community 22 - "hesabdariResidNashode.html"
Cohesion: 0.21
Nodes (11): admin_panel:customerNew, admin_panel:pardakhtNesiye, /panel/passCheck/, all_sarresid, mandehesabKP, admin_panel.ViewReportMali, jQuery, pDatepicker JS Library (+3 more)
Cohesion: 0.47
Nodes (5): jQuery, pDatepicker JS Library, custom_tags, toCurrency, toJalali
### Community 24 - "scanner.py"
Cohesion: 0.13
Nodes (12): enqueue_sms(), _aware(), _Budget, scan_cheque_due(), scan_cheque_registration(), scan_debt_due(), scan_nesiye_due(), scan_survey() (+4 more)
### Community 25 - "0002_auto_20220114_1616.py"
Cohesion: 0.29
Nodes (3): Migration, Migration, Migration
### Community 26 - "SMSResult"
Cohesion: 0.13
Nodes (8): SMSDeliveryResult, SMSProvider, SMSResult, webotp_message(), console_write(), ConsoleSMSProvider, HTTPSMSProvider, _FailingProvider
### Community 27 - "Rip'n Fast. Fewer Tokens. Better Code."
Cohesion: 0.05
Nodes (42): index(), 1. Codebase Navigation & Analysis (Ripwire vs. Graphify), 2. Dynamic Help, Self-Correction & Staleness, 3. Git Operations & Safety, 4. Development Server Lifecycle, 5. Environment, Frontend & PWA Safety, 6. End-to-End Browser Tests (Playwright), Project Operating & Agent Guidelines (+34 more)
### Community 29 - "admin_panel/entezar.html"
Cohesion: 0.40
Nodes (4): Customer Data Structure, kamaDatepicker Library, custom_tags, toJalali filter
### Community 28 - "models/sms.py"
Cohesion: 0.08
Nodes (12): CustomerSMSSetting, Meta, SMSLog, Meta, SMSProviderConfig, Meta, SMSQueue, Meta (+4 more)
### Community 29 - "admin_panel/templates/admin_panel/baseStore.html"
Cohesion: 0.13
Nodes (12): admin_panel/base.html, Customer Data Structure, Django Custom Tags: custom_tags, kamaDatepicker Library, URL: admin_panel:customers, URL: admin_panel:operations, URL: admin_panel:productAdd, URL: admin_panel:store (+4 more)
### Community 30 - "views/finance.py"
Cohesion: 0.14
Nodes (21): Migration, Article, Meta, ArticleType, Sarresid, addSarresid(), bedehi(), bedehiKhadamat() (+13 more)
Cohesion: 0.20
Nodes (18): Article, Meta, addSarresid(), bedehi(), bedehiKhadamat(), bedehiNasb(), financial_dashboard(), hesabdari() (+10 more)
### Community 31 - "Customer Modals UI"
Cohesion: 0.67
Nodes (3): createCS click handler, openRow click handler, Customer Modals UI
### Community 32 - "views/factors.py"
Cohesion: 0.16
Nodes (6): FactorDel(), newFactor2(), InventoryException, _parse_jalali_date(), _parse_timestamp(), _safe_int()
### Community 34 - "views/sms.py"
Cohesion: 0.17
Nodes (12): parse_scan_since(), resolve_recipients_test(), _sample_context(), sms_customer_toggle(), sms_primary_toggle(), sms_queue(), sms_queue_retry(), sms_send_custom() (+4 more)
### Community 36 - "django_db_models"
Cohesion: 0.15
Nodes (3): get_provider_class(), get_sms_provider(), reset_sms_provider()
### Community 39 - "test_sms.py"
Cohesion: 0.14
Nodes (4): apply_timing(), current_timing_value(), timing_for(), TimingsTests
### Community 43 - "make_customer"
Cohesion: 0.15
Nodes (6): PhoneNumber, make_customer(), PanelTestCase, PrimaryNumberPanelTests, RuleTests, WelcomeTriggerTests
### Community 44 - "sms0098.py"
Cohesion: 0.15
Nodes (5): _clean(), to_local(), _configured_codes(), _response_code(), ZeroZeroNinetyEightSMSProvider
### Community 46 - "models/__init__.py"
Cohesion: 0.15
Nodes (5): format_amount(), format_jalali(), render_template(), _SafeDict, is_sms_enabled()
### Community 93 - "django_shortcuts"
Cohesion: 0.13
Nodes (3): Migration, ArticleType, Sarresid
### Community 94 - "resolve_recipients"
Cohesion: 0.20
Nodes (3): resolve_recipients(), mark_primary(), RecipientTests
### Community 95 - "factories.py"
Cohesion: 0.27
Nodes (5): ProductCategory, make_category(), make_product(), make_store(), _next()
### Community 96 - "write_tree"
Cohesion: 0.21
Nodes (6): Counts, format_header(), generate_snapshot(), normalized_path(), write_tree(), visit()
### Community 98 - "reconciliation_tool.py"
Cohesion: 0.19
Nodes (4): remove_query_param(), check_financial_discrepancy(), run_360_audit(), run_audit()
### Community 99 - "ModelsPackageContractTests"
Cohesion: 0.05
Nodes (15): AdminConfig, AdminPanelConfig, make_std_streams_unicode_safe(), configure_sqlite_connection(), entrypoint.sh script, main(), run.sh script, start_service() (+7 more)
### Community 100 - "django_conf"
Cohesion: 0.22
Nodes (4): AnnouncementRejected, place_announcement_call(), place_voip_call(), VoipCallResult
### Community 103 - "customerIDFactorBackup.html"
Cohesion: 0.18
Nodes (8): Client-side script in customerIDFactorBackup.html, URL: admin_panel:customerEdit, Customer Data, Factor Data Model, Product Data Model, Product Category Data Model, User Data Model, URL: /panel/productMojodi/
### Community 110 - "0061_sms_timing_and_primary.py"
Cohesion: 0.22
Nodes (4): backfill_primary_phones(), Migration, _normalize(), normalize_template_timing()
### Community 111 - "bale/providers.py"
Cohesion: 0.31
Nodes (4): BaleBotProvider, ConsoleBaleProvider, get_bale_provider(), NotificationResult
### Community 116 - "Customer"
Cohesion: 0.29
Nodes (3): Customer, Meta, PhoneNumber
### Community 117 - "customerNewBackup.html"
Cohesion: 0.29
Nodes (6): admin_panel:newFactor URL, Category Data, Client-side Form Logic, CSRF Token, custom_tags, static
## Ambiguous Edges - Review These
- `admin_panel/base.html` → `admin_panel/templates/admin_panel/baseHesabdari.html` [AMBIGUOUS]
admin_panel/templates/admin_panel/baseHesabdari.html · relation: conceptually_related_to
## Knowledge Gaps
- **110 isolated node(s):** `DataTables Library`, `Customer Data Object`, `Factor Data Object`, `admin_panel:pardakhtNesiye URL`, `/panel/passCheck/ URL` (+105 more)
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 398 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
- **67 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 575 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
- **93 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `Rip'n Fast. Fewer Tokens. Better Code.` connect `Rip'n Fast. Fewer Tokens. Better Code.` to `Reference guide`?**
_High betweenness centrality (0.117) - this node is a cross-community bridge._
- **Are the 21 inferred relationships involving `User` (e.g. with `Action` and `VisitQueue`) actually correct?**
_`User` has 21 INFERRED edges - model-reasoned connections that need verification._
- **Why does `SMSProviderConfig` connect `models/sms.py` to `scanner.py`?**
_High betweenness centrality (0.031) - this node is a cross-community bridge._
- **Are the 2 inferred relationships involving `make_customer()` (e.g. with `Customer` and `PhoneNumber`) actually correct?**
_`make_customer()` has 2 INFERRED edges - model-reasoned connections that need verification._
- **What connects `DataTables Library`, `Customer Data Object`, `Factor Data Object` to the rest of the system?**
_110 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `views/store.py` be split into smaller, more focused modules?**
_Cohesion score 0.06247086247086247 - nodes in this community are weakly interconnected._
- **Why does `index()` connect `Rip'n Fast. Fewer Tokens. Better Code.` to `views/customers.py`?**
_High betweenness centrality (0.116) - this node is a cross-community bridge._
- **Should `project_tree.py` be split into smaller, more focused modules?**
_Cohesion score 0.052600818234950324 - nodes in this community are weakly interconnected._
_Cohesion score 0.06540825285338016 - nodes in this community are weakly interconnected._
- **Why does `Languages` connect `Rip'n Fast. Fewer Tokens. Better Code.` to `scanner.py`?**
_High betweenness centrality (0.026) - this node is a cross-community bridge._
- **Should `django_db` be split into smaller, more focused modules?**
_Cohesion score 0.06896551724137931 - nodes in this community are weakly interconnected._
### Low-confidence Hints
_AMBIGUOUS edges — the extractor was unsure. Verify before acting on these._
+4 -4
View File
@@ -63,12 +63,12 @@
</div>
<div id="legend"></div>
</div>
<div id="stats">821 nodes &middot; 1291 edges &middot; 93 communities</div>
<div id="stats">1280 nodes &middot; 2237 edges &middot; 140 communities</div>
</div>
<script>
const RAW_NODES = [{"id": "admin_panel_models_store_storeroom", "label": "StoreRoom", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 12.1, "font": {"size": 0, "color": "#ffffff"}, "title": "StoreRoom", "community": 0, "community_name": "views/store.py", "source_file": "admin_panel/models/store.py", "file_type": "code", "degree": 5}, {"id": "admin_panel_models_store_storeroom_meta", "label": "Meta", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.4, "font": {"size": 0, "color": "#ffffff"}, "title": "Meta", "community": 0, "community_name": "views/store.py", "source_file": "admin_panel/models/store.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_models_store_storeroomowner", "label": "StoreRoomOwner", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 11.3, "font": {"size": 0, "color": "#ffffff"}, "title": "StoreRoomOwner", "community": 0, "community_name": "views/store.py", "source_file": "admin_panel/models/store.py", "file_type": "code", "degree": 3}, {"id": "admin_panel_models_store_storetransferrequest", "label": "StoreTransferRequest", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 14.2, "font": {"size": 0, "color": "#ffffff"}, "title": "StoreTransferRequest", "community": 0, "community_name": "views/store.py", "source_file": "admin_panel/models/store.py", "file_type": "code", "degree": 10}, {"id": "admin_panel_models_store_storetransferrequest_meta", "label": "Meta", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.4, "font": {"size": 0, "color": "#ffffff"}, "title": "Meta", "community": 0, "community_name": "views/store.py", "source_file": "admin_panel/models/store.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_models_store_storetransferrequest_status", "label": "Status", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.4, "font": {"size": 0, "color": "#ffffff"}, "title": "Status", "community": 0, "community_name": "views/store.py", "source_file": "admin_panel/models/store.py", "file_type": "code", "degree": 1}, {"id": "tests_test_inventory_transferworkflowtests", "label": "TransferWorkflowTests", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 15.5, "font": {"size": 12, "color": "#ffffff"}, "title": "TransferWorkflowTests", "community": 0, "community_name": "views/store.py", "source_file": "tests/test_inventory.py", "file_type": "code", "degree": 13}, {"id": "project_tree_counts", "label": "Counts", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 12.5, "font": {"size": 0, "color": "#ffffff"}, "title": "Counts", "community": 1, "community_name": "project_tree.py", "source_file": "project_tree.py", "file_type": "code", "degree": 6}, {"id": "tests_test_urls_contract_urlcontracttests", "label": "UrlContractTests", "color": {"background": "#E15759", "border": "#E15759", "highlight": {"background": "#ffffff", "border": "#E15759"}}, "size": 12.5, "font": {"size": 0, "color": "#ffffff"}, "title": "UrlContractTests", "community": 102, "community_name": "UrlContractTests", "source_file": "tests/test_urls_contract.py", "file_type": "code", "degree": 6}, {"id": "admin_panel_models_system_cachetable", "label": "CacheTable", "color": {"background": "#59A14F", "border": "#59A14F", "highlight": {"background": "#ffffff", "border": "#59A14F"}}, "size": 10.8, "font": {"size": 0, "color": "#ffffff"}, "title": "CacheTable", "community": 104, "community_name": "system.py", "source_file": "admin_panel/models/system.py", "file_type": "code", "degree": 2}, {"id": "tests_test_view_smoke_viewsmoketests", "label": "ViewSmokeTests", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 13.8, "font": {"size": 0, "color": "#ffffff"}, "title": "ViewSmokeTests", "community": 11, "community_name": "ViewSmokeTests", "source_file": "tests/test_view_smoke.py", "file_type": "code", "degree": 9}, {"id": "admin_models_user", "label": "User", "color": {"background": "#76B7B2", "border": "#76B7B2", "highlight": {"background": "#ffffff", "border": "#76B7B2"}}, "size": 19.3, "font": {"size": 12, "color": "#ffffff"}, "title": "User", "community": 13, "community_name": "User", "source_file": "admin/models.py", "file_type": "code", "degree": 22}, {"id": "admin_panel_models_actions_action", "label": "Action", "color": {"background": "#76B7B2", "border": "#76B7B2", "highlight": {"background": "#ffffff", "border": "#76B7B2"}}, "size": Line truncated
const RAW_EDGES = [{"from": "admin_panel_models_store", "to": "admin_panel_models_store_storeroom", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_models_store_storeroom", "to": "admin_panel_models_store_storeroom_meta", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_models_store_storeroom", "to": "admin_panel_models_store_storeroom_str", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_views_store_newstore", "to": "admin_panel_models_store_storeroom", "label": "calls", "title": "calls [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "admin_panel_models_store_storeroom", "to": "admin_models_user", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "admin_panel_models_store", "to": "admin_panel_models_store_storeroomowner", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_models_store_rationale_34", "to": "admin_panel_models_store_storeroomowner", "label": "rationale_for", "title": "rationale_for [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_models_store_storeroomowner", "to": "admin_models_user", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "admin_panel_services_approve_transfer_request", "to": "admin_panel_models_store_storetransferrequest", "label": "references", "title": "references [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_services_cancel_transfer_request", "to": "admin_panel_models_store_storetransferrequest", "label": "references", "title": "references [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_services_create_transfer_request", "to": "admin_panel_models_store_storetransferrequest", "label": "references", "title": "references [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_services_reject_transfer_request", "to": "admin_panel_models_store_storetransferrequest", "label": "references", "title": "references [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_models_store", "to": "admin_panel_models_store_storetransferrequest", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_models_store_storetransferrequest", "to": "admin_panel_models_store_storetransferrequest_meta", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_models_store_storetransferrequest", "to": "admin_panel_models_store_storetransferrequest_status", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_models_store_storetransferrequest", "to": "admin_panel_models_store_storetransferrequest_str", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_models_store_storetransferrequest", "to": "admin_panel_models_store_storetransferrequest_clean", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_models_store_storetransferrequest", "to": "admin_models_user", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "tests_test_inventory", "to": "tests_test_inventory_transferworkflowtests", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_inventory_transferworkflowtests", "to": "tests_test_inventory_py_testcase", "label": "inherits", "title": "inherits [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_inventory_transferworkflowtests", "to": "tests_test_inventory_transferworkflowtests_setup", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_Line truncated
const LEGEND = [{"cid": 0, "color": "#4E79A7", "label": "views/store.py", "count": 66}, {"cid": 1, "color": "#F28E2B", "label": "project_tree.py", "count": 59}, {"cid": 2, "color": "#E15759", "label": "sqlite_db_backup.py", "count": 43}, {"cid": 3, "color": "#76B7B2", "label": "django_db", "count": 33}, {"cid": 4, "color": "#59A14F", "label": "Customer ID Backup/New Factor Template", "count": 31}, {"cid": 5, "color": "#EDC948", "label": "views/customers.py", "count": 62}, {"cid": 6, "color": "#B07AA1", "label": "cheque_inquiry.py", "count": 20}, {"cid": 7, "color": "#FF9DA7", "label": "admin_panel/templates/admin_panel/baseHesabdari.html", "count": 25}, {"cid": 8, "color": "#9C755F", "label": "django_db_models_deletion", "count": 25}, {"cid": 9, "color": "#BAB0AC", "label": "django_conf", "count": 24}, {"cid": 10, "color": "#4E79A7", "label": "admin_panel/storeBK1.html", "count": 19}, {"cid": 11, "color": "#F28E2B", "label": "ViewSmokeTests", "count": 8}, {"cid": 12, "color": "#E15759", "label": "admin_panel/templates/admin_panel/customerAction.html", "count": 14}, {"cid": 13, "color": "#76B7B2", "label": "User", "count": 27}, {"cid": 14, "color": "#59A14F", "label": "admin_panel/templates/admin_panel/bedehi.html", "count": 9}, {"cid": 15, "color": "#EDC948", "label": "django_contrib", "count": 10}, {"cid": 16, "color": "#B07AA1", "label": "models/__init__.py", "count": 72}, {"cid": 17, "color": "#FF9DA7", "label": "admin_panel/templates/admin_panel/customerBase.html", "count": 8}, {"cid": 18, "color": "#9C755F", "label": "hesabdariCheckPassFail.html", "count": 10}, {"cid": 19, "color": "#BAB0AC", "label": "Reference guide", "count": 27}, {"cid": 20, "color": "#4E79A7", "label": "ProductInStore", "count": 15}, {"cid": 21, "color": "#F28E2B", "label": "graphify", "count": 9}, {"cid": 22, "color": "#E15759", "label": "hesabdariResidNashode.html", "count": 13}, {"cid": 23, "color": "#76B7B2", "label": "0017_factoriteam_garantyesh.py", "count": 2}, {"cid": 24, "color": "#59A14F", "label": "django_apps", "count": 7}, {"cid": 25, "color": "#EDC948", "label": "0002_auto_20220114_1616.py", "count": 7}, {"cid": 26, "color": "#B07AA1", "label": "0021_alter_article_type.py", "count": 2}, {"cid": 27, "color": "#FF9DA7", "label": "Rip&#x27;n Fast. Fewer Tokens. Better Code.", "count": 44}, {"cid": 28, "color": "#9C755F", "label": "0027_alter_action_nazar.py", "count": 2}, {"cid": 29, "color": "#BAB0AC", "label": "admin_panel/entezar.html", "count": 5}, {"cid": 30, "color": "#4E79A7", "label": "views/finance.py", "count": 31}, {"cid": 31, "color": "#F28E2B", "label": "Customer Modals UI", "count": 3}, {"cid": 32, "color": "#E15759", "label": "0029_alter_article_options.py", "count": 2}, {"cid": 33, "color": "#76B7B2", "label": "0034_alter_article_sarresid.py", "count": 2}, {"cid": 34, "color": "#59A14F", "label": "0005_auto_20220121_2054.py", "count": 2}, {"cid": 35, "color": "#EDC948", "label": "0035_alter_article_sarresid.py", "count": 2}, {"cid": 36, "color": "#B07AA1", "label": "0009_remove_storeroom_user.py", "count": 2}, {"cid": 37, "color": "#FF9DA7", "label": "0010_auto_20220122_1546.py", "count": 2}, {"cid": 38, "color": "#9C755F", "label": "0040_customer_laghv_bazdid.py", "count": 2}, {"cid": 39, "color": "#BAB0AC", "label": "0043_sarresid_user.py", "count": 2}, {"cid": 40, "color": "#4E79A7", "label": "0048_alter_productinstore_options.py", "count": 2}, {"cid": 41, "color": "#F28E2B", "label": "0032_article_paystatus.py", "count": 2}, {"cid": 42, "color": "#E15759", "label": "0033_article_nahvepardakht.py", "count": 2}, {"cid": 45, "color": "#EDC948", "label": "0044_sarresid_desc.py", "count": 2}, {"cid": 48, "color": "#9C755F", "label": "GEMINI.md", "count": 2}, {"cid": 49, "color": "#BAB0AC", "label": "admin/migrations/__init__.py", "count": 1}, {"cid": 50, "color": "#4E79A7", "label": "Customer Action Include", "count": 1}, {"cid": 51, "color": "#F28E2B", "label": "New Customer Base Template", "count": 1}, {"cid": 52, "color": "#E15759", "label": "Customer Feedback Partial", "count": 1}, {"cid": 53, "color": "#76B7B2", "label": "Customer Factor Detail", "count": 1}, {"cid": 54, "color": "#59A14F", "label": "Customer Accounting Template", "count": 1}, {"cid": 55, "color": "#EDC948", "label": "Customer Factor List", "count": 1}, {"cid": 56, "color": "#B07AA1", "label": "Customer Feedback Page", "count": 1}, {"cid": 57, "color": "#FF9DA7", "label": "commands/__init__.py", "count": 1}, {"cid": 58, "color": "#9C755F", "label": "management/__init__.py", "count": 1}, {"cid": 59, "color": "#BAB0AC", "label": "admin_panel/migrations/__init__.py", "count": 1}, {"cid": 60, "color": "#4E79A7", "label": "Action List Template", "count": 1}, {"cid": 61, "color": "#F28E2B", "label": "base.html", "count": 1}, {"cid": 62, "color": "#E15759", "label": "Customer New Factor", "count": 1}, {"cid": 63, "color": "#76B7B2", "label": "Customer New Factor V2", "count": 1}, {"cid": 64, "color": "#59A14F", "label": "Customer SatisfactLine truncated
const RAW_NODES = [{"id": "admin_panel_models_store_storetransferrequest", "label": "StoreTransferRequest", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 13.8, "font": {"size": 0, "color": "#ffffff"}, "title": "StoreTransferRequest", "community": 0, "community_name": "views/store.py", "source_file": "admin_panel/models/store.py", "file_type": "code", "degree": 10}, {"id": "admin_panel_models_store_storetransferrequest_meta", "label": "Meta", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.4, "font": {"size": 0, "color": "#ffffff"}, "title": "Meta", "community": 0, "community_name": "views/store.py", "source_file": "admin_panel/models/store.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_models_store_storetransferrequest_status", "label": "Status", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.4, "font": {"size": 0, "color": "#ffffff"}, "title": "Status", "community": 0, "community_name": "views/store.py", "source_file": "admin_panel/models/store.py", "file_type": "code", "degree": 1}, {"id": "tests_test_inventory_productinstoremathtests", "label": "ProductInStoreMathTests", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 13.4, "font": {"size": 0, "color": "#ffffff"}, "title": "ProductInStoreMathTests", "community": 0, "community_name": "views/store.py", "source_file": "tests/test_inventory.py", "file_type": "code", "degree": 9}, {"id": "tests_test_inventory_transferworkflowtests", "label": "TransferWorkflowTests", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 14.9, "font": {"size": 12, "color": "#ffffff"}, "title": "TransferWorkflowTests", "community": 0, "community_name": "views/store.py", "source_file": "tests/test_inventory.py", "file_type": "code", "degree": 13}, {"id": "integrations_announcements_announcementrejected", "label": "AnnouncementRejected", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 11.5, "font": {"size": 0, "color": "#ffffff"}, "title": "AnnouncementRejected", "community": 100, "community_name": "django_conf", "source_file": "integrations/announcements.py", "file_type": "code", "degree": 4}, {"id": "integrations_voip_voipcallresult", "label": "VoipCallResult", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.8, "font": {"size": 0, "color": "#ffffff"}, "title": "VoipCallResult", "community": 100, "community_name": "django_conf", "source_file": "integrations/voip.py", "file_type": "code", "degree": 2}, {"id": "admin_panel_management_commands_sms_worker_command", "label": "Command", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 13.8, "font": {"size": 0, "color": "#ffffff"}, "title": "Command", "community": 101, "community_name": "Command", "source_file": "admin_panel/management/commands/sms_worker.py", "file_type": "code", "degree": 10}, {"id": "tests_test_urls_contract_urlcontracttests", "label": "UrlContractTests", "color": {"background": "#E15759", "border": "#E15759", "highlight": {"background": "#ffffff", "border": "#E15759"}}, "size": 12.3, "font": {"size": 0, "color": "#ffffff"}, "title": "UrlContractTests", "community": 102, "community_name": "UrlContractTests", "source_file": "tests/test_urls_contract.py", "file_type": "code", "degree": 6}, {"id": "admin_panel_models_system_cachetable", "label": "CacheTable", "color": {"background": "#59A14F", "border": "#59A14F", "highlight": {"background": "#ffffff", "border": "#59A14F"}}, "size": 10.8, "font": {"size": 0, "color": "#ffffff"}, "title": "CacheTable", "community": 104, "community_name": "system.py", "source_file": "admin_panel/models/system.py", "file_type": "code", "degree": 2}, {"id": "integrations_sms_behin_behinsmsprovider", "label": "BehinSMSProvider", "color": {"background": "#EDC948", "border": "#EDC948", "highlight": {"background": "#ffffff", "border": "#EDC948"}}, "size": 13.8, "font": {"size": 0, "color": "#ffffff"}, "title": "BehinSMSProvider", "community": 105, "community_name": "BehinSMSProvider", "source_file": "integrations/sms/behin.py", "file_type": "code", "degree": 10}, {"id": "tests_test_customers_customercrudtests", "label": "CustomerCrudTests", "color": {"background": "#B07AA1", "border": "#B07AA1", "highlight": {"background": "#ffffff", "border": "#B07AA1"}}, "size": 12.7, "font": {"size": 0, "color": "#ffffff"}, "title": "CustomerCrudTests", "community": 106, "community_name": "test_customers.py", "source_file": "tests/test_customers.py", "file_type": "code", "degreLine truncated
const RAW_EDGES = [{"from": "admin_panel_services_approve_transfer_request", "to": "admin_panel_models_store_storetransferrequest", "label": "references", "title": "references [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_services_cancel_transfer_request", "to": "admin_panel_models_store_storetransferrequest", "label": "references", "title": "references [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_services_create_transfer_request", "to": "admin_panel_models_store_storetransferrequest", "label": "references", "title": "references [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_services_reject_transfer_request", "to": "admin_panel_models_store_storetransferrequest", "label": "references", "title": "references [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_models_store", "to": "admin_panel_models_store_storetransferrequest", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_models_store_storetransferrequest", "to": "admin_panel_models_store_storetransferrequest_meta", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_models_store_storetransferrequest", "to": "admin_panel_models_store_storetransferrequest_status", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_models_store_storetransferrequest", "to": "admin_panel_models_store_storetransferrequest_str", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_models_store_storetransferrequest", "to": "admin_panel_models_store_storetransferrequest_clean", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_models_store_storetransferrequest", "to": "admin_models_user", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "tests_test_inventory", "to": "tests_test_inventory_productinstoremathtests", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_inventory_productinstoremathtests", "to": "tests_test_inventory_py_testcase", "label": "inherits", "title": "inherits [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_inventory_productinstoremathtests", "to": "tests_test_inventory_productinstoremathtests_setup", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_inventory_productinstoremathtests", "to": "tests_test_inventory_productinstoremathtests_test_initial_in_records_history_and_stock", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_inventory_productinstoremathtests", "to": "tests_test_inventory_productinstoremathtests_test_multiple_movements_accumulate_correctly", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_inventory_productinstoremathtests", "to": "tests_test_inventory_productinstoremathtests_test_editing_a_row_updates_stock_and_history", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_inventory_productinstoremathtests", "to": "tests_test_inventory_productinstoremathtests_test_deleting_a_row_creates_history_and_reduces_stock", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_inventory_productinstoremathtests", "to": "tests_test_inventory_productinstoremathtests_test_garbage_numeric_values_are_coerced_to_zero", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_inventory_productinstoremathtests", "to": "admin_models_productinstore", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "tests_test_inventory", "to": "tests_test_inventory_transferworkflowtests", "label": "contains", "tLine truncated
const LEGEND = [{"cid": 0, "color": "#4E79A7", "label": "views/store.py", "count": 68}, {"cid": 1, "color": "#F28E2B", "label": "project_tree.py", "count": 14}, {"cid": 2, "color": "#E15759", "label": "sqlite_db_backup.py", "count": 11}, {"cid": 3, "color": "#76B7B2", "label": "django_db", "count": 29}, {"cid": 4, "color": "#59A14F", "label": "Customer ID Backup/New Factor Template", "count": 14}, {"cid": 5, "color": "#EDC948", "label": "views/customers.py", "count": 26}, {"cid": 6, "color": "#B07AA1", "label": "cheque_inquiry.py", "count": 21}, {"cid": 7, "color": "#FF9DA7", "label": "admin_panel/templates/admin_panel/baseHesabdari.html", "count": 15}, {"cid": 8, "color": "#9C755F", "label": "django_db_models_deletion", "count": 31}, {"cid": 9, "color": "#BAB0AC", "label": "0051_auto_20260705_1356.py", "count": 2}, {"cid": 10, "color": "#4E79A7", "label": "admin_panel/storeBK1.html", "count": 19}, {"cid": 11, "color": "#F28E2B", "label": "ViewSmokeTests", "count": 8}, {"cid": 12, "color": "#E15759", "label": "admin_panel/templates/admin_panel/customerAction.html", "count": 17}, {"cid": 13, "color": "#76B7B2", "label": "User", "count": 48}, {"cid": 14, "color": "#59A14F", "label": "run_all", "count": 64}, {"cid": 15, "color": "#EDC948", "label": "admin_panel/admin.py", "count": 28}, {"cid": 16, "color": "#B07AA1", "label": "stock_in", "count": 24}, {"cid": 17, "color": "#FF9DA7", "label": "admin_panel/templates/admin_panel/bedehi.html", "count": 20}, {"cid": 18, "color": "#9C755F", "label": "hesabdariCheckPassFail.html", "count": 10}, {"cid": 19, "color": "#BAB0AC", "label": "Reference guide", "count": 27}, {"cid": 20, "color": "#4E79A7", "label": "normalize_mobile", "count": 34}, {"cid": 21, "color": "#F28E2B", "label": "graphify", "count": 9}, {"cid": 22, "color": "#E15759", "label": "hesabdariResidNashode.html", "count": 6}, {"cid": 23, "color": "#76B7B2", "label": "0017_factoriteam_garantyesh.py", "count": 2}, {"cid": 24, "color": "#59A14F", "label": "scanner.py", "count": 32}, {"cid": 25, "color": "#EDC948", "label": "0002_auto_20220114_1616.py", "count": 7}, {"cid": 26, "color": "#B07AA1", "label": "SMSResult", "count": 30}, {"cid": 27, "color": "#FF9DA7", "label": "Rip&#x27;n Fast. Fewer Tokens. Better Code.", "count": 44}, {"cid": 28, "color": "#9C755F", "label": "models/sms.py", "count": 26}, {"cid": 29, "color": "#BAB0AC", "label": "admin_panel/templates/admin_panel/baseStore.html", "count": 15}, {"cid": 30, "color": "#4E79A7", "label": "views/finance.py", "count": 24}, {"cid": 31, "color": "#F28E2B", "label": "Customer Modals UI", "count": 3}, {"cid": 32, "color": "#E15759", "label": "views/factors.py", "count": 22}, {"cid": 33, "color": "#76B7B2", "label": "0034_alter_article_sarresid.py", "count": 2}, {"cid": 34, "color": "#59A14F", "label": "views/sms.py", "count": 22}, {"cid": 35, "color": "#EDC948", "label": "0035_alter_article_sarresid.py", "count": 2}, {"cid": 36, "color": "#B07AA1", "label": "django_db_models", "count": 20}, {"cid": 37, "color": "#FF9DA7", "label": "0010_auto_20220122_1546.py", "count": 2}, {"cid": 38, "color": "#9C755F", "label": "0040_customer_laghv_bazdid.py", "count": 2}, {"cid": 39, "color": "#BAB0AC", "label": "test_sms.py", "count": 20}, {"cid": 40, "color": "#4E79A7", "label": "0048_alter_productinstore_options.py", "count": 2}, {"cid": 41, "color": "#F28E2B", "label": "0032_article_paystatus.py", "count": 2}, {"cid": 42, "color": "#E15759", "label": "0033_article_nahvepardakht.py", "count": 2}, {"cid": 43, "color": "#76B7B2", "label": "make_customer", "count": 19}, {"cid": 44, "color": "#59A14F", "label": "sms0098.py", "count": 19}, {"cid": 45, "color": "#EDC948", "label": "0044_sarresid_desc.py", "count": 2}, {"cid": 46, "color": "#B07AA1", "label": "models/__init__.py", "count": 18}, {"cid": 47, "color": "#FF9DA7", "label": "make_user", "count": 17}, {"cid": 48, "color": "#9C755F", "label": "GEMINI.md", "count": 2}, {"cid": 49, "color": "#BAB0AC", "label": "admin/migrations/__init__.py", "count": 1}, {"cid": 50, "color": "#4E79A7", "label": "Customer Action Include", "count": 1}, {"cid": 51, "color": "#F28E2B", "label": "New Customer Base Template", "count": 1}, {"cid": 52, "color": "#E15759", "label": "Customer Feedback Partial", "count": 1}, {"cid": 53, "color": "#76B7B2", "label": "Customer Factor Detail", "count": 1}, {"cid": 54, "color": "#59A14F", "label": "Customer Accounting Template", "count": 1}, {"cid": 55, "color": "#EDC948", "label": "Customer Factor List", "count": 1}, {"cid": 56, "color": "#B07AA1", "label": "Customer Feedback Page", "count": 1}, {"cid": 57, "color": "#FF9DA7", "label": "commands/__init__.py", "count": 1}, {"cid": 58, "color": "#9C755F", "label": "management/__init__.py", "count": 1}, {"cid": 59, "color": "#BAB0AC", "label": "admin_panel/migrations/__init__.py", "count": 1}, {"cid": 60, "color": "#4E79A7", "label": "Action List Template", "count": 1}, {"cid": 61, "color": "#F28E2B", "label": "base.html", "count": 1}, {"cid": 62,Line truncated
// HTML-escape helper — prevents XSS when injecting graph data into innerHTML
function esc(s) {
+19716 -3321
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
View File
Whitespace-only changes.
+26
View File
@@ -0,0 +1,26 @@
"""Outbound prerecorded announcement calls."""
import requests
from django.conf import settings
class AnnouncementRejected(ValueError):
"""The provider explicitly refused the requested announcement."""
def place_announcement_call(configuration, *, mobile, idempotency_key):
headers = {"Idempotency-Key": idempotency_key}
if configuration.announcement_api_key:
headers["X-API-Key"] = configuration.announcement_api_key
response = requests.post(
configuration.announcement_url,
json={"customer_number": mobile, "announcement_id": configuration.announcement_id},
headers=headers,
timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS,
)
response.raise_for_status()
if response.content:
payload = response.json()
if not isinstance(payload, dict):
raise ValueError("Invalid announcement provider response")
if payload.get("success") is False:
raise AnnouncementRejected("Announcement provider rejected the request")
View File
Whitespace-only changes.
+50
View File
@@ -0,0 +1,50 @@
from dataclasses import dataclass
import requests
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
@dataclass
class NotificationResult:
success: bool
provider_id: str = ""
error: str = ""
class ConsoleBaleProvider:
def send_message(self, chat_id, text):
if settings.DEBUG: print(f"[DEV BALE] {chat_id or 'dashboard'}: {text}")
return NotificationResult(True, "console")
class BaleBotProvider:
def send_photo(self, chat_id, photo, caption='', *, token=None, filename='receipt.jpg', mime_type='image/jpeg'):
"""Upload private evidence bytes; do not expose a media URL or log a bot token."""
token = settings.BALE_BOT_TOKEN if token is None else token
if not token or not chat_id:
return NotificationResult(False, error='توکن یا گروه بله تنظیم نشده است.')
field, method = ('photo', 'sendPhoto') if mime_type in ('image/jpeg', 'image/png') else ('document', 'sendDocument')
try:
response = requests.post(f"{settings.BALE_API_BASE.rstrip('/')}/bot{token}/{method}",
data={'chat_id': chat_id, 'caption': caption}, files={field: (filename, photo, mime_type)},
timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
if not response.ok:
return NotificationResult(False, error=f'خطای HTTP بله: {response.status_code}')
data = response.json()
result = data.get('result') if isinstance(data, dict) else None
if data.get('ok') is not True or not isinstance(result, dict) or not result.get('message_id'):
return NotificationResult(False, error='بله ارسال تصویر را تأیید نکرد.')
return NotificationResult(True, str(result['message_id']))
except (requests.RequestException, ValueError, AttributeError) as error:
return NotificationResult(False, error=f'خطا در ارتباط با بله ({type(error).__name__})')
def send_message(self, chat_id, text):
if not chat_id: return NotificationResult(False, error="Bale chat id is not configured")
response = requests.post(f"{settings.BALE_API_BASE.rstrip('/')}/bot{settings.BALE_BOT_TOKEN}/sendMessage", json={"chat_id": chat_id, "text": text, "disable_web_page_preview": True}, timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
response.raise_for_status(); data = response.json()
if not data.get("ok", True): return NotificationResult(False, error=str(data.get("description", "Bale rejected message")))
return NotificationResult(True, str((data.get("result") or {}).get("message_id", "bale")))
def get_bale_provider():
if settings.BALE_BOT_TOKEN:
return BaleBotProvider()
if settings.DEBUG:
return ConsoleBaleProvider()
raise ImproperlyConfigured("BALE_BOT_TOKEN is required to deliver Bale notifications in production.")
View File
Whitespace-only changes.
+30
View File
@@ -0,0 +1,30 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from urllib.parse import urlparse
from django.conf import settings
@dataclass(frozen=True, slots=True)
class SMSResult:
success: bool
provider_id: str = ""
error: str = ""
provider_code: str = ""
@dataclass(frozen=True, slots=True)
class SMSDeliveryResult:
status: str
provider_code: str
description: str
is_final: bool = True
def webotp_message(code):
host=settings.WEBOTP_HOST or urlparse(settings.PUBLIC_BASE_URL).hostname
if not host: raise ValueError("WEBOTP_HOST یا PUBLIC_BASE_URL معتبر تنظیم نشده است.")
return f"کد شما: {code}\n@{host} #{code}"
class SMSProvider(ABC):
"""Stable application-facing contract for every SMS vendor."""
def send_otp(self,mobile,code): return self.send_message(mobile,webotp_message(code))
@abstractmethod
def send_message(self,mobile,message): raise NotImplementedError
def get_delivery_status(self, provider_id):
"""Return None when a provider does not support delivery tracking."""
return None
+69
View File
@@ -0,0 +1,69 @@
import logging
import zeep
from django.conf import settings
from zeep.helpers import serialize_object
from .base import SMSProvider, SMSResult
logger = logging.getLogger(__name__)
class BehinSMSProvider(SMSProvider):
"""SOAP adapter for panel.behinsms.com, isolated from application code."""
def __init__(self):
self.username = settings.BEHIN_SMS_USERNAME
self.password = settings.BEHIN_SMS_PASSWORD
self.special_number = settings.BEHIN_SMS_SPECIAL_NUMBER
self.wsdl_url = settings.BEHIN_SMS_WSDL_URL
self._client = None
def _configuration_error(self):
missing = [name for name, value in (
("BEHIN_SMS_USERNAME", self.username),
("BEHIN_SMS_PASSWORD", self.password),
("BEHIN_SMS_SPECIAL_NUMBER", self.special_number)
) if not value]
return f"تنظیمات پیامک بهین ناقص است: {', '.join(missing)}" if missing else ""
@property
def client(self):
if self._client is None:
# استفاده از تنظیمات پیش‌فرض Zeep (مشابه نمونهٔ کاری)
self._client = zeep.Client(wsdl=self.wsdl_url)
return self._client
def get_credit(self):
error = self._configuration_error()
if error:
raise ValueError(error)
return self.client.service.GetCredit(UserName=self.username, Password=self.password)
def send_message(self, mobile, message):
return self.send_many([mobile], message)
def send_many(self, mobile_numbers, message, is_flash=False):
error = self._configuration_error()
if error:
return SMSResult(False, error=error)
recipients = list(dict.fromkeys(str(number).strip() for number in mobile_numbers if str(number).strip()))
if not recipients:
return SMSResult(False, error="شماره گیرنده پیامک خالی است.")
try:
response = self.client.service.SendArray(
UserName=self.username,
Password=self.password,
RecipientNumber={"string": recipients},
MessageBody=message,
SpecialNumber=self.special_number,
IsFlashMessage=is_flash,
CheckingMessageID={"long": [0] * len(recipients)}
)
serialized = serialize_object(response)
if serialized is False or serialized is None:
return SMSResult(False, error="وب‌سرویس بهین ارسال پیامک را تأیید نکرد.")
return SMSResult(True, provider_id=str(serialized)[:500])
except Exception as exc:
logger.exception("Behin SMS delivery failed")
return SMSResult(False, error=str(exc)[:1000])
+43
View File
@@ -0,0 +1,43 @@
"""Provider کنسول برای تست/دیباگ؛ پیامک واقعی ارسال نمی‌کند."""
import logging
import sys
from django.conf import settings
from .base import SMSProvider, SMSResult, webotp_message
logger = logging.getLogger(__name__)
def console_write(text):
"""چاپ روی کنسول به‌گونه‌ای که **هرگز** استثنا پرت نکند.
وظیفهٔ provider ارسال است، نه نمایش. کنسول ویندوز (cp1252) فارسی را کدگذاری
نمی‌کند و ``print`` مستقیم باعث ``UnicodeEncodeError`` می‌شد؛ چون فراخوانی‌کننده
(worker) هر استثنا را «ارسال ناموفق» می‌فهمد، یک خطای نمایشی پیامک را در صف
``failed`` می‌گذاشت. پس اول تلاش می‌کنیم متن کامل را بنویسیم، و اگر کنسول
توانش را نداشت نسخهٔ ASCII-شده را می‌نویسیم.
"""
for payload in (text, text.encode("ascii", "replace").decode("ascii")):
try:
sys.stdout.write(payload + "\n")
sys.stdout.flush()
return
except (UnicodeEncodeError, ValueError, OSError):
continue
class ConsoleSMSProvider(SMSProvider):
def __init__(self, config=None):
self.config = config
def send_otp(self, mobile, code):
if settings.DEBUG:
console_write(f"[DEV OTP] {mobile}: {webotp_message(code)}")
return SMSResult(True, provider_code="console")
def send_message(self, mobile, message):
logger.info("[DEV SMS] destination=***%s message=%s", (mobile or "")[-4:], message)
if settings.DEBUG:
console_write(f"[DEV SMS] {mobile}: {message}")
return SMSResult(True, provider_code="console")
+38
View File
@@ -0,0 +1,38 @@
"""انتخاب provider پیامک بر اساس تنظیمات ذخیره‌شده در دیتابیس (Django Admin)."""
from django.utils.module_loading import import_string
from .base import SMSProvider
# نگاشت کلید انتخاب‌شده در Admin به مسیر کلاس provider.
PROVIDER_CLASSES = {
"sms0098": "integrations.sms.sms0098.ZeroZeroNinetyEightSMSProvider",
"sms0098ws": "integrations.sms.sms0098ws.ZeroZeroNinetyEightWebServiceProvider",
"http": "integrations.sms.http.HTTPSMSProvider",
"console": "integrations.sms.console.ConsoleSMSProvider",
}
def get_provider_class(config):
path = PROVIDER_CLASSES.get(config.provider)
if not path:
raise ValueError(f"provider پیامک ناشناخته است: {config.provider}")
provider_class = import_string(path)
if not issubclass(provider_class, SMSProvider):
raise TypeError("کلاس provider باید از SMSProvider ارث‌بری کند.")
return provider_class
def get_sms_provider(config=None):
"""یک نمونه provider برای config داده‌شده (یا تنظیمات جاری) می‌سازد.
import مدل به‌صورت lazy انجام می‌شود تا از حلقهٔ import در زمان بارگذاری اپ جلوگیری شود.
"""
if config is None:
from admin_panel.models import SMSProviderConfig
config = SMSProviderConfig.load()
return get_provider_class(config)(config)
def reset_sms_provider():
"""سازگاری با فراخوانی‌های قبلی؛ provider دیگر کش نمی‌شود."""
return None
+49
View File
@@ -0,0 +1,49 @@
"""Provider عمومی مبتنی بر HTTP؛ مقادیر از SMSProviderConfig خوانده می‌شوند."""
import requests
from .base import SMSProvider, SMSResult
from .normalize import normalize_mobile
class HTTPSMSProvider(SMSProvider):
def __init__(self, config=None):
self.config = config
def send_message(self, mobile, message):
config = self.config
if config is None or not config.api_url:
return SMSResult(False, error="آدرس سرویس پیامک (api_url) تنظیم نشده است.")
if not normalize_mobile(str(mobile)):
return SMSResult(
False, provider_code="INVALID_NUMBER",
error="شمارهٔ موبایل گیرنده معتبر نیست.",
)
headers = {"Accept": "application/json", "Content-Type": "application/json"}
if config.api_token:
headers["Authorization"] = f"Bearer {config.api_token}"
try:
response = requests.post(
config.api_url,
json={"to": mobile, "message": message, "sender": config.sender},
headers=headers,
timeout=config.timeout_seconds or 30,
)
response.raise_for_status()
try:
data = response.json() if response.content else {}
except ValueError:
# بدنهٔ پاسخ JSON نبود (مثلاً صفحهٔ HTML خطای سرویس‌دهنده).
return SMSResult(
False, provider_code="BAD_RESPONSE",
error=f"پاسخ سرویس پیامک JSON معتبر نبود (HTTP {response.status_code}).",
)
# بعضی سرویس‌دهنده‌ها به‌جای آبجکت، لیست برمی‌گردانند.
if isinstance(data, list):
data = data[0] if data and isinstance(data[0], dict) else {}
elif not isinstance(data, dict):
data = {}
if data.get("success") is False:
return SMSResult(False, error=str(data.get("error", "Provider rejected SMS")))
return SMSResult(True, str(data.get("id") or data.get("message_id") or "http"))
except requests.RequestException as exc:
return SMSResult(False, error=str(exc))
+40
View File
@@ -0,0 +1,40 @@
"""نرمال‌سازی شماره موبایل ایرانی برای سرویس‌های پیامک."""
from persiantools import digits
def _clean(raw):
if raw is None:
return ""
text = str(raw).strip()
if not text:
return ""
# تبدیل ارقام فارسی/عربی به انگلیسی و حذف هر کاراکتر غیررقمی (به‌جز +).
text = digits.fa_to_en(digits.ar_to_fa(text))
return "".join(ch for ch in text if ch.isdigit() or ch == "+")
def normalize_mobile(raw):
"""شماره را به شکل یکنواخت ``+989xxxxxxxxx`` برمی‌گرداند؛ نامعتبر ⇒ رشته خالی."""
text = _clean(raw)
if not text:
return ""
if text.startswith("+"):
text = text[1:]
if text.startswith("0098"):
text = text[4:]
elif text.startswith("98") and len(text) == 12:
text = text[2:]
elif text.startswith("0"):
text = text[1:]
if len(text) != 10 or not text.startswith("9"):
return ""
return f"+98{text}"
def to_local(raw):
"""شماره را به شکل محلی ``09xxxxxxxxx`` برمی‌گرداند (لازمِ برخی وب‌سرویس‌ها)."""
normalized = normalize_mobile(raw)
if not normalized:
return ""
return "0" + normalized[3:]
+26
View File
@@ -0,0 +1,26 @@
"""نمای عمومی سازگاری برای integration پیامک.
توجه: providerهای وابسته به ``zeep`` (behin و sms0098ws) عمداً اینجا import
نمی‌شوند تا نبودِ zeep باعث خطای import کل اپ نشود؛ از مسیر
``factory.get_sms_provider`` و ``import_string`` به‌صورت تنبل بارگذاری می‌شوند.
(sms0098 لینکی دیگر SOAP نیست و zeep لازم ندارد.)
"""
from .base import SMSDeliveryResult, SMSProvider, SMSResult, webotp_message
from .console import ConsoleSMSProvider
from .factory import PROVIDER_CLASSES, get_sms_provider, reset_sms_provider
from .http import HTTPSMSProvider
from .normalize import normalize_mobile, to_local
__all__ = [
"SMSProvider",
"SMSResult",
"SMSDeliveryResult",
"webotp_message",
"ConsoleSMSProvider",
"HTTPSMSProvider",
"PROVIDER_CLASSES",
"get_sms_provider",
"reset_sms_provider",
"normalize_mobile",
"to_local",
]
+153
View File
@@ -0,0 +1,153 @@
"""ارسال پیامک از طریق سرویس لینکی 0098SMS (HTTP GET).
سرویس 0098SMS برای ارسال، یک درخواست GET به ``sendsmslink.aspx`` با پارامترهای
``USERNAME/PASSWORD/FROM/TO/TEXT`` می‌پذیرد و در بدنهٔ پاسخ یک کد کوتاه (مثلاً ``0``)
برمی‌گرداند. این سرویس SOAP/WSDL ندارد؛ اگر آدرس WSDL در تنظیمات گذاشته شود،
``zeep`` صفحهٔ HTML می‌گیرد و خطای «Invalid XML content received» می‌دهد.
مقادیر اتصال (URL، نام کاربری، رمز و شمارهٔ خط) از ``SMSProviderConfig`` خوانده
می‌شوند؛ یعنی همان‌هایی که ادمین در Django Admin یا ``/panel/sms/settings`` وارد می‌کند.
"""
import logging
import requests
from django.conf import settings
from .base import SMSProvider, SMSResult
from .normalize import to_local
logger = logging.getLogger(__name__)
# آدرس پیش‌فرض سرویس لینکی؛ اگر ادمین آدرس دیگری وارد کند، همان استفاده می‌شود.
LINK_SEND_URL = "http://www.0098sms.com/sendsmslink.aspx"
# نام پارامترها قرارداد خود vendor است؛ حروف بزرگ عمدی‌اند و نباید تغییر کنند.
USERNAME_PARAM = "USERNAME"
PASSWORD_PARAM = "PASSWORD"
SENDER_PARAM = "FROM"
RECIPIENT_PARAM = "TO"
TEXT_PARAM = "TEXT"
# کدهای پیش‌فرض پاسخ. با تنظیمات ``SMS_SUCCESS_CODES``/``SMS_FAILURE_CODES``
# (رشتهٔ جدا‌شده با کاما یا لیست) قابل تغییرند، بدون دست‌زدن به کد.
DEFAULT_SUCCESS_CODES = ("0",)
DEFAULT_FAILURE_CODES = ("error",)
def _configured_codes(name, default):
raw = getattr(settings, name, default)
if isinstance(raw, str):
raw = raw.split(",")
return {str(value).strip().casefold() for value in raw if str(value).strip()}
def _response_code(response):
"""کد برگشتی را از بدنهٔ پاسخ جدا می‌کند (BOM و فاصله‌های اضافی حذف می‌شوند)."""
return (response.text or "").strip().lstrip("\ufeff").strip()
class ZeroZeroNinetyEightSMSProvider(SMSProvider):
"""سرویس لینکی 0098SMS: یک درخواست GET و یک کد کوتاه در پاسخ."""
def __init__(self, config=None):
self.config = config
self.username = getattr(config, "username", "") or ""
self.password = getattr(config, "password", "") or ""
self.sender = getattr(config, "sender", "") or ""
self.url = (getattr(config, "api_url", "") or "").strip() or LINK_SEND_URL
self.timeout = getattr(config, "timeout_seconds", None) or 30
def _configuration_error(self):
missing = [
name for name, value in (
("نام کاربری", self.username),
("رمز عبور", self.password),
("شماره خط فرستنده", self.sender),
) if not value
]
return f"تنظیمات 0098SMS ناقص است: {', '.join(missing)}" if missing else ""
def _safe_exception(self, exc):
"""متن خطا را از لو رفتن نام کاربری/رمز در لاگ و UI پاک می‌کند."""
message = str(exc)
for secret in (self.username, self.password):
if secret:
message = message.replace(secret, "***")
return message[:500]
def _unconfirmed(self, detail):
"""پاسخی که «ارسال‌شده» را تأیید نمی‌کند ولی «رد‌شده» هم نیست.
وضعیت را ناموفق برمی‌گردانیم تا صف با backoff دوباره تلاش کند؛ در برابر
جایگزینِ «موفق فرض‌کردن»، این انتخاب صادقانه‌تر است و سبب گم‌شدن یادآوری
نمی‌شود. (vendor ممکن است پیامک را فرستاده و پاسخش گم شده باشد.)
"""
logger.warning("پاسخ نامشخص سرویس 0098SMS: %s", detail)
return SMSResult(
False, provider_code="UNCONFIRMED",
error=f"وضعیت ارسال 0098SMS تأیید نشد (پاسخ: {str(detail)[:120] or 'خالی'}).",
)
def send_message(self, mobile, message):
error = self._configuration_error()
if error:
return SMSResult(False, error=error, provider_code="CONFIG")
# این سرویس شماره را به شکل محلی «09xxxxxxxxx» می‌خواهد (نه +98 و نه 98).
recipient = to_local(mobile)
# تضمین صفر ابتدایی: عدد هرگز به int تبدیل نمی‌شود (int صفر ابتدایی را حذف می‌کند)
# و همیشه به‌صورت رشتهٔ «09xxxxxxxxx» فرستاده می‌شود.
if recipient and not recipient.startswith("0"):
recipient = "0" + recipient.lstrip("0")
if not recipient:
# بدون این گارد، شمارهٔ نامعتبر به vendor می‌رفت (هزینهٔ ارسال + خطای گنگ).
return SMSResult(
False, provider_code="INVALID_NUMBER",
error="شمارهٔ موبایل گیرنده معتبر نیست.",
)
params = {
USERNAME_PARAM: self.username,
PASSWORD_PARAM: self.password,
SENDER_PARAM: str(self.sender),
RECIPIENT_PARAM: recipient,
TEXT_PARAM: str(message),
}
try:
# سرویس 0098 داخل ایران میزبانی می‌شود؛ روی ماشین‌هایی که
# ``HTTP_PROXY`` برای دسترسی به سایت‌های خارجی تنظیم شده، عبور از
# پراکسی باعث timeout در تماس با این سرویس می‌شود. مقدار ``None``
# برای هر scheme، پراکسی محیط را برای همین درخواست بی‌اثر می‌کند.
response = requests.get(
self.url, params=params, timeout=self.timeout,
proxies={"http": None, "https": None},
)
except (requests.Timeout, requests.ConnectionError) as exc:
# مهلت تمام‌شده/قطعی ارتباط: ممکن است پیامک رفته باشد؛ قطعی «ناموفق» نیست.
return self._unconfirmed(self._safe_exception(exc))
except requests.RequestException as exc:
return SMSResult(
False, provider_code="CONNECTION",
error=f"خطا در ارتباط با سرویس 0098SMS: {self._safe_exception(exc)}",
)
if response.status_code >= 500:
# خطای سمت vendor؛ ممکن است پیش از خطا ارسال شده باشد.
return self._unconfirmed(f"HTTP {response.status_code}")
code = _response_code(response)
folded = code.casefold()
if folded in _configured_codes("SMS_SUCCESS_CODES", DEFAULT_SUCCESS_CODES):
return SMSResult(True, provider_code=code or "0")
if folded in _configured_codes("SMS_FAILURE_CODES", DEFAULT_FAILURE_CODES):
return SMSResult(
False, provider_code="REJECTED",
error=f"سرویس 0098SMS ارسال را نپذیرفت (پاسخ: {code[:120] or 'خالی'}).",
)
if response.status_code >= 400:
return SMSResult(
False, provider_code=f"HTTP_{response.status_code}",
error=f"سرویس 0098SMS درخواست را رد کرد (HTTP {response.status_code}).",
)
# بدنهٔ ناشناخته نشانهٔ رد نیست؛ اما «ارسال‌شده» هم قابل اثبات نیست.
return self._unconfirmed(code)
+91
View File
@@ -0,0 +1,91 @@
"""ترجمهٔ کدهای بازگشتی سامانهٔ 0098SMS به متن فارسی و رده‌بندی وضعیت.
دو جدول مستقل در راهنمای vendor آمده است:
* **کدهای ارسال/خطا** (لینک ارتباطی و وب‌سرویس): عدد کوتاه یا رشته‌ای مانند
``Hang``/``Wrong time``. صفر (یا شناسهٔ ۹رقمی در ``SendSMSWithID``) یعنی موفق.
* **کدهای وضعیت دلیوری** (خروجی ``smsdeliveryState``): ۵=رسیده به گوشی،
۶=نرسیده، ۷=منقضی، ۸=نامشخص.
این ماژول تنها منبع حقیقت این کدهاست تا provider و پنل هر دو از یک ترجمه استفاده کنند.
"""
from dataclasses import dataclass
# ---------------------------------------------------------------------------
# کدهای خطای ارسال (ترکیب جدول لینک ارتباطی و وب‌سرویس راهنما).
# ---------------------------------------------------------------------------
SEND_ERROR_CODES = {
"1": "شماره گیرنده اشتباه است.",
"2": "گیرنده تعریف نشده است.",
"3": "فرستنده تعریف نشده است.",
"4": "مخاطب دریافت پیام‌های تبلیغاتی خود را بسته است (بلک‌لیست).",
"5": "متن تنظیم نشده است.",
"6": "نام کاربری تنظیم نشده است.",
"7": "کلمه عبور تنظیم نشده است.",
"8": "نام دامین تنظیم نشده است.",
"9": "مجوز شما باطل شده است.",
"10": "اعتبار پیامک شما کافی نیست.",
"11": "برای این شماره لینک تعریف نشده است.",
"12": "عدم مجوز برای اتصال لینک.",
"13": "نام کاربری و کلمه عبور اشتباه است.",
"14": "کاراکتر غیرمجاز در متن وجود دارد.",
"15": "سقف ارسال روزانه پر شده است.",
"16": "عدم مجوز شماره برای ارسال از لینک.",
"17": "خطا در شماره پنل؛ لطفاً با پشتیبانی تماس بگیرید.",
"18": "اتمام تاریخ اعتبار شماره پنل؛ برای استفاده تمدید شود.",
"19": "تنظیمات کد opt انجام نشده است.",
"20": "فرمت کد opt صحیح نیست.",
"21": "تنظیمات کد opt توسط ادمین تأیید نشده است.",
"22": "اطلاعات مالک شماره ثبت و تأیید نشده است.",
"23": "هنوز اجازهٔ ارسال به این شماره پنل داده نشده است.",
"24": "ارسال از IP غیرمجاز انجام شده است.",
"66": "شارژ پنل شما برای ارسال کافی نیست؛ لطفاً شارژ نمایید.",
"1111": "شماره موبایل صحیح نیست.",
"-22": "خطای شارژ؛ لطفاً با پشتیبانی تماس بگیرید.",
"-23": "حساب کاربری شما مسدود است؛ لطفاً با پشتیبانی تماس بگیرید.",
"-24": "مرحلهٔ دوم ثبت‌نام شما انجام نگرفته است؛ ثبت‌نام را کامل نمایید.",
"-25": "عدم مجوز شماره برای ارسال از لینک.",
"-26": "خطا در شماره پنل؛ لطفاً با پشتیبانی تماس بگیرید.",
"-27": "اتمام تاریخ اعتبار شماره پنل؛ برای استفاده تمدید شود.",
"-28": "تنظیمات کد opt انجام نشده است.",
"-29": "فرمت کد opt صحیح نیست.",
"-31": "شناسهٔ پیامک (smsid) یافت نشد.",
"-32": "شناسهٔ پیامک (smsid) مطابقت ندارد.",
"hang": "عدم تطابق نام کاربری و کلمه عبور؛ لطفاً با پشتیبانی تماس بگیرید.",
"no doc": "کاراکتر غیرمجاز در متن وجود دارد.",
"wrong timestamp": "تایم‌استمپ ارسال‌شده صحیح نیست.",
"wrong time": "زمان در بازهٔ صحیح نیست (از همان روز تا یک سال بعد مجاز است).",
}
def describe_send_code(code):
"""متن فارسی کد خطای ارسال؛ اگر کد ناشناخته باشد ``None``."""
return SEND_ERROR_CODES.get(str(code).strip().casefold())
# ---------------------------------------------------------------------------
# کدهای وضعیت دلیوری (``smsdeliveryState``).
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class DeliveryCode:
status: str # مطابق SMSQueue.DeliveryStatus
description: str
is_final: bool # True = دیگر نیازی به استعلام مجدد نیست
_DELIVERY_CODES = {
"5": DeliveryCode("delivered", "رسیده به گوشی.", True),
"6": DeliveryCode("not_delivered", "نرسیده به گوشی.", True),
"7": DeliveryCode("expired", "وضعیت دریافت پیامک منقضی شده است.", True),
"8": DeliveryCode("unknown", "وضعیت پیامک نامشخص است.", False),
}
def classify_delivery(raw_code):
"""کد دلیوری خام را به :class:`DeliveryCode` تبدیل می‌کند.
عدد ۲ یا هر مقدار منفی/غیرشناخته‌شده ⇒ «نامشخص» و غیرنهایی، تا استعلام بعدی
دوباره تلاش کند (وضعیت ممکن است هنوز از سمت اپراتور برنگشته باشد).
"""
code = str(raw_code).strip()
return _DELIVERY_CODES.get(code, DeliveryCode("unknown", f"کد وضعیت ناشناخته: {code}", False))
+153
View File
@@ -0,0 +1,153 @@
"""ارسال و پیگیری پیامک 0098SMS از طریق وب‌سرویس (SOAP).
تفاوت با provider لینکی (``sms0098``): متد ``SendSMSWithID`` هم‌زمان با ارسال
**شناسهٔ پیامک (smsid)** را برمی‌گرداند و متد ``smsdeliveryState`` وضعیت تحویل به
گوشی را می‌دهد. به همین دلیل تنها این provider قابلیت رهگیری «رسیده/نرسیده به
گوشی» دارد.
آدرس وب‌سرویس، نام کاربری، رمز و شمارهٔ پنل از ``SMSProviderConfig`` خوانده
می‌شوند (نه هیچ مقدار ثابتی در کد).
"""
import logging
import requests
import zeep
from zeep.transports import Transport
from .base import SMSDeliveryResult, SMSProvider, SMSResult
from .normalize import to_local
from .sms0098_codes import classify_delivery, describe_send_code
logger = logging.getLogger(__name__)
# نام پارامترها را خودِ WSDL سرویس تعیین می‌کند و **تماماً با حرف کوچک** است
# (username/password/text/mobileno/pnlno/smsid). راهنمای vendor آن‌ها را با
# حرف بزرگ نوشته؛ zeep نام راهنما را نمی‌پذیرد و «unexpected keyword argument»
# می‌دهد. پس این‌جا دقیقاً همان امضای WSDL استفاده می‌شود.
_SEND_OPERATION = "SendSMSWithID"
_DELIVERY_OPERATION = "smsdeliveryState"
def _as_text(value):
"""پاسخ zeep/وب‌سرویس را به رشتهٔ ساده تبدیل می‌کند."""
if value is None:
return ""
if isinstance(value, bool):
return str(value)
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value).strip()
class ZeroZeroNinetyEightWebServiceProvider(SMSProvider):
"""وب‌سرویس SOAP 0098SMS: ارسال با شناسه + استعلام دلیوری."""
def __init__(self, config=None):
self.config = config
self.username = (getattr(config, "username", "") or "").strip()
self.password = (getattr(config, "password", "") or "").strip()
# شمارهٔ پنل وب‌سرویس؛ در نبود مقدار صریح، همان خط فرستنده است.
self.panel_no = (getattr(config, "panel_no", "") or "").strip() or \
(getattr(config, "sender", "") or "").strip()
self.url = (getattr(config, "webservice_url", "") or "").strip()
self.timeout = getattr(config, "timeout_seconds", None) or 30
self._client = None
# ------------------------------------------------------------------ helpers
def _configuration_error(self):
missing = [
name for name, value in (
("نام کاربری", self.username),
("رمز عبور", self.password),
("شماره پنل", self.panel_no),
("آدرس وب‌سرویس", self.url),
) if not value
]
return f"تنظیمات وب‌سرویس 0098SMS ناقص است: {', '.join(missing)}" if missing else ""
def _safe_exception(self, exc):
"""متن خطا را از لو رفتن نام کاربری/رمز پاک می‌کند."""
message = str(exc)
for secret in (self.username, self.password):
if secret:
message = message.replace(secret, "***")
return message[:500]
@property
def client(self):
if self._client is None:
# ``trust_env=False`` پراکسی محیط (مثلاً برای سایت‌های خارجی) را برای
# تماس با سرویس داخلی ایران خنثی می‌کند.
session = requests.Session()
session.trust_env = False
transport = Transport(
session=session, timeout=self.timeout, operation_timeout=self.timeout,
)
wsdl = self.url if self.url.lower().endswith("wsdl") else f"{self.url}?WSDL"
self._client = zeep.Client(wsdl=wsdl, transport=transport)
return self._client
# ------------------------------------------------------------------ sending
def send_message(self, mobile, message):
error = self._configuration_error()
if error:
return SMSResult(False, error=error, provider_code="CONFIG")
recipient = to_local(mobile)
if not recipient:
return SMSResult(
False, provider_code="INVALID_NUMBER",
error="شمارهٔ موبایل گیرنده معتبر نیست.",
)
try:
response = getattr(self.client.service, _SEND_OPERATION)(
username=self.username, password=self.password,
text=str(message), mobileno=recipient, pnlno=self.panel_no,
)
except Exception as exc: # (Connect|Timeout|zeep) — هر چه بود، ارسال تأیید نشد
logger.exception("ارسال پیامک 0098SMS (وب‌سرویس) شکست خورد")
return SMSResult(
False, provider_code="REQUEST_ERROR",
error=f"خطا در ارتباط با وب‌سرویس 0098SMS: {self._safe_exception(exc)}",
)
code = _as_text(response)
# موفقیت: یا ۰، یا خودِ شناسهٔ پیامک (عدد حداقل ۹ رقمی).
if code == "0":
return SMSResult(True, provider_code="0")
if code.isdigit() and len(code) >= 9:
return SMSResult(True, provider_id=code, provider_code=code)
description = describe_send_code(code)
if description:
return SMSResult(False, provider_code=code[:50] or "REJECTED", error=description)
return SMSResult(
False, provider_code=code[:50] or "UNKNOWN",
error=f"پاسخ نامشخص وب‌سرویس 0098SMS: {code[:200] or 'خالی'}",
)
# ----------------------------------------------------------------- delivery
def get_delivery_status(self, provider_id):
"""وضعیت تحویل پیامک را با شناسهٔ آن استعلام می‌کند.
در خطای ارتباط/تنظیمات ``None`` برمی‌گرداند تا فراخوان (دستور
``sms_delivery``) این ردیف را رد کند و در دور بعد دوباره تلاش نماید.
"""
if not provider_id or self._configuration_error():
return None
try:
response = getattr(self.client.service, _DELIVERY_OPERATION)(
username=self.username, password=self.password,
smsid=str(provider_id),
)
except Exception:
logger.exception("استعلام دلیوری 0098SMS برای smsid=%s شکست خورد", provider_id)
return None
code = _as_text(response)
result = classify_delivery(code)
return SMSDeliveryResult(
status=result.status, provider_code=code,
description=result.description, is_final=result.is_final,
)
+57
View File
@@ -0,0 +1,57 @@
import logging
from dataclasses import dataclass
from uuid import uuid4
import requests
from django.conf import settings
from apps.core.models import VoipConfiguration
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class VoipCallResult:
success: bool
error: str = ""
def place_voip_call(*, agent, customer, method):
configuration = VoipConfiguration.load()
if not configuration.voip_call_url:
return VoipCallResult(False, "VOIP_CALL_URL تنظیم نشده است.")
payload = {
"method": method,
"customer_number": customer.local_mobile,
"metadata": {
"agent_id": str(agent.public_id),
"customer_id": str(customer.public_id),
},
}
if method == "ip_phone" and agent.voip_extension:
payload["agent_extension"] = agent.voip_extension
key_prefix = "internal"
elif method == "mobile" and agent.voip_local_mobile:
payload["agent_mobile"] = agent.voip_local_mobile
key_prefix = "mobile"
else:
return VoipCallResult(False, "روش تماس انتخاب‌شده در دسترس نیست.")
try:
response = requests.post(
configuration.voip_call_url,
json=payload,
headers={"Idempotency-Key": f"{key_prefix}-{uuid4().hex}"},
timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS,
)
response.raise_for_status()
if response.content:
body = response.json()
if not isinstance(body, dict) or body.get("success") is False:
return VoipCallResult(False, "سرویس تماس درخواست را نپذیرفت.")
return VoipCallResult(True)
except (requests.RequestException, ValueError):
logger.exception("VOIP click-to-call failed")
return VoipCallResult(False, "خطا در ارتباط با سرویس تماس.")
+17
View File
@@ -128,3 +128,20 @@ STATIC_ROOT = "/app/static"
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
LOGIN_URL='/panel/p_login/'
# ---------------------------------------------------------------
# Integrations (SMS / Bale / VoIP / announcements)
# ---------------------------------------------------------------
# مهلت پیش‌فرض ارتباط با سرویس‌های بیرونی (ثانیه).
INTEGRATION_HTTP_TIMEOUT_SECONDS = 30
# کانال بله (در صورت عدم تنظیم، در حالت DEBUG از Console استفاده می‌شود).
BALE_API_BASE = 'https://tapi.bale.ai'
BALE_BOT_TOKEN = ''
# WebOTP (در صورت استفاده؛ در غیر این‌صورت خالی می‌ماند).
PUBLIC_BASE_URL = ''
WEBOTP_HOST = ''
# سامانه پیامک: مقادیر اصلی از مدل SMSProviderConfig (Django Admin) خوانده می‌شوند.
+2
View File
@@ -11,3 +11,5 @@ numpy==1.26.4
ddddocr==1.4.11
asgiref==3.4.1
requests==2.31.0
zeep==4.2.1
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env bash
#
# اجرای کامل کتیبه در محیط توسعهٔ محلی — بدون Docker.
#
# چهار سرویس را با هم بالا می‌آورد:
# web → runserver روی 0.0.0.0:8000 (طبق قرارداد پروژه، برای دسترسی از LAN)
# worker → تخلیهٔ صف پیامک (sms_worker)
# scanner → اسکن دوره‌ای و صف‌کردن یادآوری‌ها (sms_scan --loop)
# delivery → استعلام وضعیت تحویل پیامک‌ها (sms_delivery --sleep 30)
#
# استفاده:
# ./run.sh همه چیز
# ./run.sh --no-scan بدون اسکنر
# ./run.sh --no-worker بدون worker پیامک
# ./run.sh --no-delivery بدون استعلام دلیوری
# ./run.sh --no-web فقط سرویس‌های پس‌زمینه
# ./run.sh --no-migrate مایگریشن اجرا نشود
# ./run.sh --reload runserver با auto-reload (دو پروسه، فقط برای کدنویسی)
# ./run.sh -f پس از بالا آمدن، لاگ‌ها را دنبال کن (Ctrl+C فقط دنبال‌کردن را قطع می‌کند)
# ./run.sh -h راهنما
#
# لاگ‌ها و فایل‌های PID در .run/ قرار می‌گیرند. برای توقف: ./stop.sh
set -euo pipefail
cd "$(dirname "$(readlink -f "$0")")"
RUN_DIR=".run"
WEB_PORT="8000"
WEB_HOST="0.0.0.0"
WORKER_SLEEP="${WORKER_SLEEP:-3}"
SCAN_INTERVAL="${SCAN_INTERVAL:-300}"
START_WEB=1
START_WORKER=1
START_SCAN=1
START_DELIVERY=1
DO_MIGRATE=1
RELOAD=0
FOLLOW=0
usage() {
sed -n '3,20p' "$0" | sed 's/^# \{0,1\}//'
}
while [ $# -gt 0 ]; do
case "$1" in
--no-web) START_WEB=0 ;;
--no-worker) START_WORKER=0 ;;
--no-scan) START_SCAN=0 ;;
--no-delivery) START_DELIVERY=0 ;;
--no-migrate) DO_MIGRATE=0 ;;
--reload) RELOAD=1 ;;
-f|--follow|--logs) FOLLOW=1 ;;
-h|--help) usage; exit 0 ;;
*) echo "گزینهٔ ناشناخته: $1" >&2; echo; usage; exit 2 ;;
esac
shift
done
# ---------------------------------------------------------------------------
# ۱) پیدا کردن مفسر پایتون از venv مخزن (هرگز پایتون سیستم)
# ---------------------------------------------------------------------------
PY=""
for candidate in ".venv/Scripts/python.exe" ".venv/bin/python" ".venv/bin/python3"; do
if [ -x "$candidate" ]; then PY="$candidate"; break; fi
done
if [ -z "$PY" ]; then
echo "خطا: مفسر پایتون در .venv پیدا نشد." >&2
echo " ابتدا محیط مجازی مخزن را بساز/فعال کن (.venv)." >&2
exit 1
fi
mkdir -p "$RUN_DIR"
echo "==> کتیبه — اجرای محلی (dev)"
echo " python : $PY"
echo " run dir : $RUN_DIR/"
[ "$START_WEB" = "1" ] && echo " web : http://$WEB_HOST:$WEB_PORT"
[ "$START_WORKER" = "1" ] && echo " worker : sms_worker --sleep $WORKER_SLEEP"
[ "$START_SCAN" = "1" ] && echo " scanner : sms_scan --loop --interval $SCAN_INTERVAL"
[ "$START_DELIVERY" = "1" ] && echo " delivery: sms_delivery --sleep 30"
echo
# ---------------------------------------------------------------------------
# ۲) توقف نسخهٔ قبلی — جلوگیری از «database is locked» و پورت اشغال
# ---------------------------------------------------------------------------
echo "==> توقف نسخهٔ قبلی (اگر بالا باشد)"
bash ./stop.sh --quiet || true
echo
# ---------------------------------------------------------------------------
# ۳) مایگریشن‌ها (idempotent)
# ---------------------------------------------------------------------------
if [ "$DO_MIGRATE" = "1" ]; then
echo "==> اعمال مایگریشن‌ها"
"$PY" manage.py migrate --noinput
echo
fi
# ---------------------------------------------------------------------------
# ۴) بالا آوردن سرویس‌ها
# ---------------------------------------------------------------------------
start_service() {
local name="$1"; shift
local log="$RUN_DIR/$name.log"
local pidfile="$RUN_DIR/$name.pid"
: > "$log"
# -X utf8: حالت UTF-8 پایتون (PEP 540). روی ویندوز کنسول cp1252 است و بدون این،
# نوشتن متن فارسی (مثلاً در provider کنسول پیامک) خطای 'charmap' می‌دهد.
# دقت: PYTHONIOENCODING از طریق WSL interop به پروسهٔ ویندوزی منتقل نمی‌شود، ولی
# آرگومان خط فرمان منتقل می‌شود؛ برای همین به -X utf8 تکیه می‌کنیم.
PYTHONUNBUFFERED=1 PYTHONIOENCODING=utf-8 \
nohup "$PY" -X utf8 -u manage.py "$@" >>"$log" 2>&1 &
echo $! > "$pidfile"
printf ' %-8s pid %s\n' "$name" "$(cat "$pidfile")"
}
echo "==> بالا آوردن سرویس‌ها"
if [ "$START_WEB" = "1" ]; then
if [ "$RELOAD" = "1" ]; then
start_service web runserver "$WEB_HOST:$WEB_PORT"
else
start_service web runserver "$WEB_HOST:$WEB_PORT" --noreload
fi
fi
[ "$START_WORKER" = "1" ] && start_service worker sms_worker --sleep "$WORKER_SLEEP"
[ "$START_SCAN" = "1" ] && start_service scanner sms_scan --loop --interval "$SCAN_INTERVAL"
[ "$START_DELIVERY" = "1" ] && start_service delivery sms_delivery --sleep 30
echo
# ---------------------------------------------------------------------------
# ۵) تأیید سلامت وب‌سرور (فقط اگر درخواست شده باشد)
# ---------------------------------------------------------------------------
if [ "$START_WEB" = "1" ]; then
echo "==> بررسی سلامت وب‌سرور"
healthy=0
for _ in $(seq 1 30); do
# --noproxy: در این محیط یک پروکسی محلی (127.0.0.1:10808) هست و درخواست به
# localhost را از خودش رد می‌کند و کد 503 می‌دهد؛ باید صریحاً دور زده شود.
code="$(curl -s --noproxy '*' -o /dev/null -w '%{http_code}' \
"http://127.0.0.1:$WEB_PORT/panel/p_login/" || true)"
if [ "$code" = "200" ]; then healthy=1; break; fi
sleep 1
done
if [ "$healthy" = "1" ]; then
echo " http://127.0.0.1:$WEB_PORT/panel/p_login/ → HTTP 200"
else
echo " هشدار: وب‌سرور ظرف ۳۰ ثانیه پاسخ نداد؛ لاگ را ببین: $RUN_DIR/web.log" >&2
fi
echo
fi
# ---------------------------------------------------------------------------
# ۶) هشدار ایمنی پیامک — جلوگیری از ارسال واقعی در حین توسعه
# ---------------------------------------------------------------------------
provider_line="$("$PY" - <<'PYEOF' 2>/dev/null || true
import os, django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "khatibi.settings")
try:
django.setup()
from admin_panel.models import SMSProviderConfig
c = SMSProviderConfig.load()
print("%s|%s" % (c.provider, "on" if c.is_enabled else "off"))
except Exception:
print("unknown|unknown")
PYEOF
)"
provider="${provider_line%%|*}"
enabled="${provider_line##*|}"
if [ "$enabled" = "on" ] && [ "$provider" != "console" ]; then
echo "⚠️ هشدار: provider پیامک روی «$provider» و ارسال فعال است."
echo " worker ممکن است پیامک واقعی بفرستد. برای تست محلی، از"
echo " /panel/sms/settings آن را روی console بگذار."
echo
fi
echo "✅ راه‌اندازی تمام شد."
echo " لاگ‌ها : $RUN_DIR/{web,worker,scanner,delivery}.log → tail -f $RUN_DIR/worker.log"
echo " توقف : ./stop.sh"
if [ "$FOLLOW" = "1" ]; then
echo
echo "==> دنبال‌کردن لاگ‌ها (Ctrl+C فقط همین را قطع می‌کند؛ سرویس‌ها بالا می‌مانند)"
tail -n 0 -f "$RUN_DIR"/*.log
fi
Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env bash
#
# توقف همهٔ سرویس‌های محلی کتیبه (web / worker / scanner) که با ./run.sh بالا آمده‌اند.
#
# استفاده:
# ./stop.sh توقف همه + گزارش
# ./stop.sh --quiet بدون خروجی (برای صدا زدن از run.sh)
#
# ترتیب کار: اول PIDهای ثبت‌شده در .run/ (با SIGTERM و مهلت، سپس SIGKILL)،
# بعد پاک‌سازی هر پروسهٔ جامانده با الگوی manage.py — تا اگر pidfile گم/کهنه شده بود
# هم چیزی روی پورت ۸۰۰۰ نماند.
set -uo pipefail
cd "$(dirname "$(readlink -f "$0")")"
RUN_DIR=".run"
WEB_PORT="8000"
QUIET=0
[ "${1:-}" = "--quiet" ] && QUIET=1
say() { [ "$QUIET" = "1" ] || echo "$@"; }
# الگوی پروسه‌های همین پروژه. عمداً فقط روی manage.py و زیرفرمان‌هایش قفل می‌شود:
# cmdline این اسکریپت («bash ./stop.sh») با آن مطابقت نمی‌کند، پس خودش را نمی‌کشد.
PATTERN='manage\.py (runserver|sms_worker|sms_scan)'
# پاک‌سازی جان‌مانده‌های سمت ویندوز.
#
# چرا لازم است: از WSL، فایل ``.venv/Scripts/python.exe`` یک باینری ویندوزی است و از طریق
# WSL interop اجرا می‌شود. اگر والدِ interop از بین برود، پروسهٔ ویندوزی یتیم می‌شود و دیگر
# در ``ps`` داخل WSL دیده نمی‌شود، ولی پورت ۸۰۰۰ را همچنان گرفته نگه می‌دارد (نتیجه: سرور
# بعدی bind نمی‌شود یا نسخهٔ قدیمی کد سرو می‌شود). پس صریحاً با ابزار ویندوز پاک می‌شوند.
#
# فیلتر محافظتی: فقط پروسه‌های ``python`` که ``manage.py`` اجرا می‌کنند و *یا* از مسیر همین
# پروژه/venv آمده‌اند *یا* پورت ۸۰۰۰ را اشغال کرده‌اند. هیچ پروژهٔ دیگری آسیب نمی‌بیند.
windows_cleanup() {
command -v powershell.exe >/dev/null 2>&1 || return 0
command -v taskkill.exe >/dev/null 2>&1 || return 0
local pids
pids="$(powershell.exe -NoProfile -Command "
\$port = $WEB_PORT
\$listen = @()
try { \$listen = @((Get-NetTCPConnection -LocalPort \$port -State Listen -ErrorAction Stop).OwningProcess) } catch { \$listen = @() }
Get-CimInstance Win32_Process -Filter \"Name='python.exe' or Name='pythonw.exe'\" |
Where-Object {
\$_.CommandLine -match 'manage\.py' -and (
\$_.ExecutablePath -like '*khatibi*' -or
\$_.CommandLine -like '*khatibi*' -or
(\$listen -contains \$_.ProcessId)
)
} | ForEach-Object { \$_.ProcessId }
" 2>/dev/null | tr -d '\r' | grep -E '^[0-9]+$' || true)"
[ -n "$pids" ] || return 0
local p
for p in $pids; do
taskkill.exe /F /PID "$p" >/dev/null 2>&1 || true
done
say " پروسه‌های ویندوزی پاک شدند: $(echo "$pids" | tr '\n' ' ')"
}
is_our_process() {
local pid="$1" cmd
[ -r "/proc/$pid/cmdline" ] || return 1
cmd="$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null || true)"
case "$cmd" in *manage.py*) return 0 ;; *) return 1 ;; esac
}
stop_pid() {
local pid="$1" name="$2"
[ -n "$pid" ] || return 0
kill -0 "$pid" 2>/dev/null || return 0
# محافظت در برابر PID کهنه: اگر این PID دیگر مال پروژه نیست، دست نزن.
if ! is_our_process "$pid"; then
say " $name: pid $pid دیگر مربوط به این پروژه نیست؛ رد شد"
return 0
fi
kill -TERM "$pid" 2>/dev/null || true
for _ in $(seq 1 20); do
kill -0 "$pid" 2>/dev/null || { say " $name (pid $pid) متوقف شد"; return 0; }
sleep 0.25
done
kill -KILL "$pid" 2>/dev/null || true
say " $name (pid $pid) پس از مهلت مجبور به توقف شد"
}
say "==> توقف سرویس‌های محلی"
stopped_any=0
if [ -d "$RUN_DIR" ]; then
shopt -s nullglob
for pidfile in "$RUN_DIR"/*.pid; do
name="$(basename "$pidfile" .pid)"
pid="$(cat "$pidfile" 2>/dev/null || true)"
[ -n "$pid" ] && stopped_any=1
stop_pid "$pid" "$name"
rm -f "$pidfile"
done
shopt -u nullglob
fi
# پاک‌سازی باقی‌مانده‌ها (pidfile گم‌شده، اجرای دستی، کرش قبلی)
leftover=0
if command -v pkill >/dev/null 2>&1; then
if pgrep -f "$PATTERN" >/dev/null 2>&1; then
leftover=1
pkill -TERM -f "$PATTERN" 2>/dev/null || true
for _ in $(seq 1 20); do
pgrep -f "$PATTERN" >/dev/null 2>&1 || break
sleep 0.25
done
pkill -KILL -f "$PATTERN" 2>/dev/null || true
say " پروسه‌های جامانده پاک شدند"
fi
fi
if [ "$stopped_any" = "0" ] && [ "$leftover" = "0" ]; then
say " چیزی برای متوقف‌کردن نبود."
fi
windows_cleanup
# تأیید نهایی: پورت ۸۰۰۰ آزاد باشد.
# --noproxy اجباری است: در این محیط یک پروکسی محلی (127.0.0.1:10808) هست که درخواست به
# localhost را می‌گیرد و 503 برمی‌گرداند؛ بدون دور زدنش این بررسی false positive می‌دهد.
if command -v curl >/dev/null 2>&1; then
code="$(curl -s --noproxy '*' -o /dev/null -w '%{http_code}' --max-time 3 \
"http://127.0.0.1:$WEB_PORT/" 2>/dev/null || true)"
if [ -n "$code" ] && [ "$code" != "000" ]; then
say " هشدار: هنوز چیزی روی پورت $WEB_PORT پاسخ می‌دهد (HTTP $code) — سروری خارج از این اسکریپت."
fi
fi
say "✅ پایان."
+7
View File
@@ -43,6 +43,13 @@ def make_user(username=None, *, password='TestPass!2345', superuser=False,
return user
def login_superuser(client, username):
"""ساخت کاربر سوپریوزر و ورود آن به کلاینت تست؛ کاربر را برمی‌گرداند."""
user = make_user(username, superuser=True)
client.force_login(user)
return user
def make_store(name=None, *, viewers=(), owner=None):
"""
ساخت انبار.
+47 -3
View File
@@ -6,14 +6,13 @@ from django.urls import reverse
from admin_panel.models import Article, Customer, PhoneNumber
from .factories import make_customer, make_user
from .factories import login_superuser, make_customer, make_user
class CustomerCrudTests(TestCase):
def setUp(self):
self.user = make_user('csUser', superuser=True)
self.client.force_login(self.user)
self.user = login_superuser(self.client, 'csUser')
def test_add_customer_creates_phones(self):
url = reverse('admin_panel:customersAdd')
@@ -56,6 +55,51 @@ class CustomerCrudTests(TestCase):
self.assertEqual(phones[0].phone_number, '09120009999')
class InactiveCustomerHiddenTests(TestCase):
"""مشتری غیرفعال (is_active=False) در فهرست و در جستجو دیده نمی‌شود."""
def setUp(self):
self.user = login_superuser(self.client, 'hideUser')
self.active = make_customer('خطیبی فعال', phones=['09120000041'])
self.inactive = make_customer('خطیبی غیرفعال', is_active=False,
phones=['09120000042'])
# جستجوی تلفن از ستون phone1 می‌خواند (فیلد قدیمی مشتری).
Customer.objects.filter(pk=self.active.pk).update(phone1='09120000041')
Customer.objects.filter(pk=self.inactive.pk).update(phone1='09120000042')
def test_inactive_absent_from_default_listing(self):
html = self.client.get(reverse('admin_panel:customers')).content.decode('utf-8')
self.assertIn('خطیبی فعال', html)
self.assertNotIn('خطیبی غیرفعال', html)
def test_inactive_absent_from_search_by_name(self):
html = self.client.get(
reverse('admin_panel:customers'),
{'searchVal': 'خطیبی', 'searchOn': 'name'},
).content.decode('utf-8')
self.assertIn('خطیبی فعال', html)
self.assertNotIn('خطیبی غیرفعال', html)
def test_inactive_absent_from_search_by_phone(self):
html = self.client.get(
reverse('admin_panel:customers'),
{'searchVal': '0912000004', 'searchOn': 'phone'},
).content.decode('utf-8')
self.assertIn('09120000041', html)
self.assertNotIn('09120000042', html)
def test_inactive_absent_from_path_search_view(self):
# مسیر <slug:> فقط نویسه‌های لاتین می‌پذیرد.
make_customer('khatibi-active')
make_customer('khatibi-inactive', is_active=False)
html = self.client.get(
reverse('admin_panel:customersSearch',
kwargs={'searchVal': 'khatibi', 'searchOn': 'name'}),
).content.decode('utf-8')
self.assertIn('khatibi-active', html)
self.assertNotIn('khatibi-inactive', html)
class CustomerDeleteGuardTests(TestCase):
def setUp(self):
+1818
View File
File diff suppressed because it is too large. Load diff
+20 -1
View File
@@ -7,7 +7,7 @@
from django.test import SimpleTestCase
from django.urls import reverse
# snapshot قطعی نام‌های مسیرهای اپ admin_panel (۶۲ مسیر)
# snapshot قطعی نام‌های مسیرهای اپ admin_panel (۷۲ مسیر)
EXPECTED_URL_NAMES = {
'index',
'p_login',
@@ -73,6 +73,18 @@ EXPECTED_URL_NAMES = {
'update_check_info',
'addSarresid',
'pardakhtNesiye',
'sms_settings',
'sms_templates',
'sms_template_edit',
'sms_customer_toggle',
'sms_primary_toggle',
'sms_send_custom',
'sms_queue',
'sms_queue_retry',
'sms_queue_approve',
'sms_queue_delete',
'sms_queue_bulk',
'sms_queue_empty',
}
# مسیرهایی که آرگومان می‌گیرند: نام -> kwargs نمونه
@@ -111,6 +123,13 @@ PARAMETERIZED = {
'update_check_info': {'sarresid_id': 1},
'addSarresid': {'cs_id': 1},
'pardakhtNesiye': {'cs_id': 1},
'sms_template_edit': {'t_id': 1},
'sms_customer_toggle': {'c_id': 1},
'sms_primary_toggle': {'c_id': 1},
'sms_send_custom': {'c_id': 1},
'sms_queue_retry': {'q_id': 1},
'sms_queue_approve': {'q_id': 1},
'sms_queue_delete': {'q_id': 1},
}
+30 -2
View File
@@ -14,8 +14,8 @@ from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from admin_panel.models import (Action, Article, Factor, Sarresid,
StoreTransferRequest)
from admin_panel.models import (Action, Article, Factor, PhoneNumber, Sarresid,
SMSQueue, SMSTemplate, StoreTransferRequest)
from .factories import (make_category, make_customer, make_product, make_store,
make_user, stock_in)
@@ -67,6 +67,18 @@ class ViewSmokeTests(TestCase):
product=cls.product, quantity=1, requester=cls.user,
)
# --- پیامک ---
cls.phone = PhoneNumber.objects.create(
customer=cls.customer, title='موبایل', phone_number='09120000000',
)
cls.customer.sms_primary_phones = '+989120000000'
cls.customer.save(update_fields=['sms_primary_phones'])
cls.sms_template = SMSTemplate.objects.get(sms_type='welcome')
cls.sms_queue = SMSQueue.objects.create(
customer=cls.customer, sms_type='welcome',
phone_number='+989120000000', message='دودی', status='failed',
)
def setUp(self):
self.client.force_login(self.user)
# در صورت خطای سرور، استثنا را نگه می‌داریم تا کد وضعیت ۵۰۰ را ببینیم
@@ -198,6 +210,22 @@ class ViewSmokeTests(TestCase):
}),
('api_check_status_inquiry', {}, GET, None),
('api_check_sabt_inquiry', {}, GET, None),
# --- پیامک ---
('sms_settings', {}, GET, None),
('sms_templates', {}, GET, None),
('sms_template_edit', {'t_id': self.sms_template.id}, POST, {
'title': 'عنوان دودی', 'text': 'متن دودی',
'timing_value': '2',
}),
('sms_customer_toggle', {'c_id': c.id}, POST, {
'sms_type': 'welcome', 'is_enabled': 'false',
}),
('sms_primary_toggle', {'c_id': c.id}, POST, {
'phone': '09120000000', 'is_primary': 'true',
}),
('sms_send_custom', {'c_id': c.id}, POST, {'text': 'پیامک دودی'}),
('sms_queue', {}, GET, None),
('sms_queue_retry', {'q_id': self.sms_queue.id}, POST, None),
# در انتهای لیست: خروج از حساب، چون بعد از آن تمام درخواست‌ها بی‌اعتبار می‌شوند
('p_logout', {}, GET, None),
]