sms
This commit is contained in:
1 parent
250cdbab7f
commit
5c6069c3ef
87 files changed
+29209
-3697
No files matched your search
@@ -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
@@ -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)
|
||||
+97
-18
@@ -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
|
||||
+101
-1
@@ -74,4 +74,104 @@ class StoreRoomAdmin(admin.ModelAdmin):
|
||||
def get_owners_count(self, obj):
|
||||
"""نمایش تعداد مالکین در لیست اصلی انبارها"""
|
||||
return obj.owners.count()
|
||||
get_owners_count.short_description = "تعداد مالکین"
|
||||
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
|
||||
@@ -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
|
||||
@@ -3,6 +3,7 @@ import os
|
||||
import datetime
|
||||
|
||||
from .models import *
|
||||
from .sms.notices import resolve as resolve_sms_notice
|
||||
|
||||
def dashBoxData(request):
|
||||
|
||||
@@ -52,5 +53,8 @@ def dashBoxData(request):
|
||||
'entezarbazdid': entezarbazdid,
|
||||
'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)
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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='ارسال خودکار (بدون تأیید مدیر)'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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})"
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""منطق دامنهٔ زیرسیستم پیامک (رندر قالب، گیرندگان، قواعد، صف و اسکنر)."""
|
||||
@@ -0,0 +1,74 @@
|
||||
"""قرار دادن پیامک در صف ارسال (insert-only، بیخطر برای مسیر request)."""
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db import IntegrityError
|
||||
|
||||
from ..models import SMSDedup, SMSQueue, SMSTemplate, SMSType
|
||||
from .recipients import resolve_recipients
|
||||
from .render import render_template
|
||||
from .rules import is_sms_enabled
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def enqueue_sms(*, customer=None, sms_type, context=None, dedup_key=None,
|
||||
message=None, recipients=None, created_by=None, approved=False):
|
||||
"""پیامک را در صف قرار میدهد و تعداد رکوردهای ساختهشده را برمیگرداند.
|
||||
|
||||
بهصورت پیشفرض پیامک در وضعیت «در انتظار تأیید» ثبت میشود و تا وقتی مدیر
|
||||
آن را از پنل تأیید نکند، worker آن را ارسال نمیکند. دو راه برای پرش از این
|
||||
بازبینی هست: ``approved=True`` برای ارسالهای دستیِ صریح مدیر (مثل پیامک سفارشی
|
||||
یا آزمایشی)، و ``SMSTemplate.auto_send`` که مدیر در صفحهٔ قالبها برای هر نوع
|
||||
پیامک روشن میکند تا پیامکهای خودکارِ اسکنر بدون صف تأیید ارسال شوند.
|
||||
|
||||
هیچ استثنایی به فراخوان پرتاب نمیشود؛ خطاها فقط لاگ میشوند تا مسیر اصلی
|
||||
(مثلاً ایجاد مشتری یا اسکنر) هرگز بهخاطر پیامک مختل نشود.
|
||||
"""
|
||||
try:
|
||||
if message is None:
|
||||
template = SMSTemplate.objects.filter(sms_type=sms_type, is_enabled=True).first()
|
||||
if template is None:
|
||||
return 0
|
||||
message = render_template(template.text, context)
|
||||
# «ارسال خودکار» هر نوع پیامک در صفحهٔ قالبها تعیین میشود؛ خاموش = بازبینی مدیر.
|
||||
approved = approved or template.auto_send
|
||||
|
||||
if not is_sms_enabled(customer, sms_type):
|
||||
return 0
|
||||
|
||||
numbers = recipients if recipients is not None else resolve_recipients(customer)
|
||||
if not numbers:
|
||||
return 0
|
||||
|
||||
if dedup_key is None:
|
||||
dedup_key = f"{sms_type}:{uuid4().hex}"
|
||||
|
||||
created = 0
|
||||
for number in numbers:
|
||||
key = dedup_key if len(numbers) == 1 else f"{dedup_key}:{number}"
|
||||
try:
|
||||
# دفتر کلیدها مستقل از صف است: کلیدِ یکبار خورده هرگز دوباره
|
||||
# ایجاد نمیشود، حتی اگر رکورد صف را مدیر حذف کرده باشد.
|
||||
if not SMSDedup.objects.get_or_create(key=key)[1]:
|
||||
continue
|
||||
_, made = SMSQueue.objects.get_or_create(
|
||||
dedup_key=key,
|
||||
defaults={
|
||||
"customer": customer,
|
||||
"sms_type": sms_type,
|
||||
"phone_number": number,
|
||||
"message": message,
|
||||
"created_by": created_by,
|
||||
"status": (SMSQueue.Status.APPROVED if approved
|
||||
else SMSQueue.Status.PENDING),
|
||||
},
|
||||
)
|
||||
created += 1 if made else 0
|
||||
except IntegrityError:
|
||||
# رقابت همزمان روی dedup_key؛ نادیده گرفته میشود.
|
||||
continue
|
||||
return created
|
||||
except Exception:
|
||||
logger.exception("enqueue_sms برای نوع=%s شکست خورد", sms_type)
|
||||
return 0
|
||||
@@ -0,0 +1,59 @@
|
||||
"""پیامهای نتیجهٔ عملیات پنل پیامک.
|
||||
|
||||
جایگزین flash message جنگو است: پیامهای جنگو در session ذخیره میشوند و اگر
|
||||
قالب مقصد آنها را رندر نکند، تا صفحهٔ بعدِ دارای بلوک پیام «سرگردان» میمانند و
|
||||
بیربط به عملیات ظاهر میشوند. اینجا پیام با یک کد در querystring
|
||||
(``?notice=<code>``) رد و بدل میشود؛ چون کد فقط روی همان URL وجود دارد، پیام هم
|
||||
فقط همانجا دیده میشود و هیچجا سرگردان نمیماند.
|
||||
|
||||
مقدار ``{placeholder}`` در متن از پارامترهای querystring پر میشود
|
||||
(نبودِ پارامتر ⇒ رشتهٔ خالی).
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
# کد → (شدت، متن). شدت به کلاس Bulma نگاشت میشود.
|
||||
NOTICES = {
|
||||
# --- تنظیمات و ارسال آزمایشی ---
|
||||
"test_sent": ("success", "پیامک آزمایشی در صف ارسال قرار گرفت."),
|
||||
"settings_saved": ("success", "تنظیمات سامانه پیامک ذخیره شد."),
|
||||
# --- قالبها ---
|
||||
"tpl_saved": ("success", "قالب «{title}» ذخیره شد."),
|
||||
"tpl_bad_timing": ("error", "مقدار زمانبندی باید یک عدد صحیح باشد."),
|
||||
# --- مشتری ---
|
||||
"customer_sms_saved": ("success", "تنظیم پیامک مشتری بهروزرسانی شد."),
|
||||
"primary_updated": ("success", "شمارهٔ اصلی پیامک بهروزرسانی شد."),
|
||||
"primary_not_found": ("error", "شماره موردنظر برای این مشتری یافت نشد."),
|
||||
"custom_queued": ("success", "پیامک سفارشی در صف ارسال قرار گرفت."),
|
||||
"custom_empty": ("error", "متن پیامک خالی است."),
|
||||
"custom_no_phone": ("error", "شماره اصلی معتبری برای این مشتری یافت نشد."),
|
||||
# --- صف ---
|
||||
"approved": ("success", "پیامک تأیید شد و برای ارسال در صف قرار گرفت."),
|
||||
"not_pending": ("warning", "این پیامک در وضعیت «در انتظار تأیید» نیست."),
|
||||
"deleted": ("success", "پیامک از صف حذف شد."),
|
||||
"bulk_approved_all": ("success", "{count} پیامک تأیید شد و برای ارسال در صف قرار گرفت."),
|
||||
"bulk_approved": ("success", "{count} پیامک تأیید شد."),
|
||||
"bulk_deleted": ("success", "{count} پیامک از صف حذف شد."),
|
||||
"invalid_action": ("error", "اقدام نامعتبر است."),
|
||||
"queue_emptied": ("success", "کل صف خالی شد ({count} پیامک حذف شد)."),
|
||||
"retried": ("success", "پیامک برای ارسال مجدد تأیید شد."),
|
||||
}
|
||||
|
||||
_KIND_CLASSES = {
|
||||
"success": "is-success",
|
||||
"error": "is-danger",
|
||||
"warning": "is-warning",
|
||||
"info": "is-info",
|
||||
}
|
||||
|
||||
|
||||
def resolve(request):
|
||||
"""کد ``?notice=`` را به متن/کلاس قابل رندر در قالب تبدیل میکند."""
|
||||
get = getattr(request, "GET", None) or {}
|
||||
entry = NOTICES.get((get.get("notice") or "").strip())
|
||||
if not entry:
|
||||
return {}
|
||||
kind, text = entry
|
||||
return {
|
||||
"notice_text": text.format_map(defaultdict(str, get)),
|
||||
"notice_kind": _KIND_CLASSES.get(kind, "is-info"),
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"""پاکسازی یکبارهٔ شمارههای قدیمی مشتریان (فیلدهای ``phone1``/``phone2``/``phone3``).
|
||||
|
||||
سالها شمارهها در سه فیلد متنی ذخیره شدهاند و به هر شکلی وارد شدهاند:
|
||||
بدون صفر ابتدایی (``9133722815``)، با ارقام فارسی، با فاصله/خط تیره، یا کلاهی
|
||||
متن آزاد (نام، تاریخ نصب، «معرف …»). این ماژول قاعدهٔ تبدیل را یکجا تعریف میکند
|
||||
تا هم مهاجرت (اجرای خودکار، یکبار) و هم دستور ``fix_customer_phones`` (اجرای
|
||||
دستی روی دیتابیس production) از یک پیادهسازی استفاده کنند.
|
||||
|
||||
قاعده (طبق تصمیم کاربر):
|
||||
|
||||
* شمارهٔ موبایل — هرجای متن که باشد — استخراج و به شکل استاندارد ``09xxxxxxxxx``
|
||||
تبدیل میشود.
|
||||
* شمارهٔ ثابت ۸رقمی (مثل ``57387608``) **دستنخورده** میماند.
|
||||
* بقیه (متن آزاد، تاریخ، تکههای بیمعنی) از فیلد شماره خالی میشود و متن اصلیاش
|
||||
در فیلد ``Customer.notes`` میرود تا چیزی از سیستم گم نشود.
|
||||
|
||||
تابع ``clean_legacy_cell`` خودبسنده و idempotent است: بعد از یکبار اجرا، خروجی
|
||||
همان مقدار فعلی است و دیگر توضیحی اضافه نمیشود؛ پس اجرای دوباره بیخطر است.
|
||||
"""
|
||||
import re
|
||||
|
||||
from persiantools import digits
|
||||
|
||||
from integrations.sms.normalize import normalize_mobile, to_local
|
||||
|
||||
_NOTE_HEADER = "— پاکسازی خودکار فیلدهای تلفن —"
|
||||
|
||||
|
||||
def _numeric_tokens(raw):
|
||||
"""تکههای رقمی متن، پس از تبدیل ارقام فارسی/عربی به انگلیسی."""
|
||||
text = digits.fa_to_en(digits.ar_to_fa(str(raw)))
|
||||
return re.findall(r"\d+", text)
|
||||
|
||||
|
||||
def clean_legacy_cell(raw):
|
||||
"""یک فیلد تلفن را پاکسازی میکند.
|
||||
|
||||
خروجی ``(value, note)`` است:
|
||||
|
||||
* ``value`` مقدار جدید فیلد، یا ``None`` یعنی «دست نزن».
|
||||
* ``note`` متن اصلی برای افزودن به ``Customer.notes``، یا ``None``.
|
||||
|
||||
قاعده: هرچیزِ فیلد که در مقدار جدید نمیماند، **بیکموکاست** در
|
||||
``note`` برمیگردد تا هیچ دادهای گم نشود.
|
||||
"""
|
||||
original = (raw or "").strip()
|
||||
if not original:
|
||||
return None, None
|
||||
|
||||
# ۱) کل فیلد یک موبایل است (شاید با فاصله/خط تیره/پیششماره).
|
||||
whole = normalize_mobile(original)
|
||||
if whole:
|
||||
local = to_local(whole)
|
||||
if local == original:
|
||||
return None, None
|
||||
return local, original
|
||||
|
||||
tokens = _numeric_tokens(original)
|
||||
|
||||
# ۲) موبایلی داخل متن پنهان است (مثل «57322182/9130261671» یا «موبایل: 0912…»).
|
||||
for token in tokens:
|
||||
local = to_local(token)
|
||||
if local:
|
||||
return local, original
|
||||
|
||||
# ۳) شمارهٔ ثابت ۸رقمیِ تنها ⇒ دستنخورده.
|
||||
if len(tokens) == 1 and len(tokens[0]) == 8:
|
||||
return None, None
|
||||
|
||||
# ۴) بقیه (متن آزاد، تاریخ، تکهٔ بیمعنی) ⇒ خالی؛ متن اصلی در یادداشت میماند.
|
||||
return "", original
|
||||
|
||||
|
||||
def _append_note(notes, lines):
|
||||
"""خطوط توضیح را به انتهای یادداشت مشتری اضافه میکند (با سرصفحهٔ یکبار)."""
|
||||
current = (notes or "").strip()
|
||||
block = "\n".join(lines)
|
||||
if _NOTE_HEADER in current:
|
||||
return f"{current}\n{block}"
|
||||
if current:
|
||||
return f"{current}\n\n{_NOTE_HEADER}\n{block}"
|
||||
return f"{_NOTE_HEADER}\n{block}"
|
||||
|
||||
|
||||
def clean_customer(customer, *, dry_run=False):
|
||||
"""فیلدهای تلفن یک مشتری را اصلاح و متنهای حذفشده را در ``notes`` میریزد.
|
||||
|
||||
خروجی: تعداد فیلدهای تغییرکرده. اگر تغییری نباشد، چیزی ذخیره نمیشود.
|
||||
"""
|
||||
changed = 0
|
||||
note_lines = []
|
||||
updates = {}
|
||||
for field in ("phone1", "phone2", "phone3"):
|
||||
value, note = clean_legacy_cell(getattr(customer, field, ""))
|
||||
if value is not None and value != (getattr(customer, field) or ""):
|
||||
updates[field] = value
|
||||
changed += 1
|
||||
if note:
|
||||
note_lines.append(f"«{field}»: {note}")
|
||||
|
||||
if not changed and not note_lines:
|
||||
return 0
|
||||
if dry_run:
|
||||
return changed or (1 if note_lines else 0)
|
||||
|
||||
if note_lines:
|
||||
customer.notes = _append_note(customer.notes, note_lines)
|
||||
updates["notes"] = customer.notes
|
||||
if updates:
|
||||
for field, value in updates.items():
|
||||
setattr(customer, field, value)
|
||||
customer.save(update_fields=list(updates))
|
||||
return changed
|
||||
|
||||
|
||||
def clean_all(*, dry_run=False, batch_size=500, apps=None):
|
||||
"""همهٔ مشتریان را پاکسازی میکند. خروجی: دیکشنری آمار.
|
||||
|
||||
``apps`` فقط برای مهاجرتها است تا مدل را از رجیستری تاریخی بگیرد (نه import
|
||||
زماناجرا)؛ در غیر این صورت مدل زندهٔ ``Customer`` استفاده میشود.
|
||||
"""
|
||||
if apps is not None:
|
||||
Customer = apps.get_model("admin_panel", "Customer")
|
||||
else:
|
||||
from ..models import Customer
|
||||
|
||||
stats = {"customers": 0, "fields": 0, "noted": 0}
|
||||
qs = Customer.objects.all().only(
|
||||
"id", "phone1", "phone2", "phone3", "notes"
|
||||
).order_by("id")
|
||||
for customer in qs.iterator(chunk_size=batch_size):
|
||||
before_notes = (customer.notes or "").strip()
|
||||
changed = clean_customer(customer, dry_run=dry_run)
|
||||
if changed:
|
||||
stats["customers"] += 1
|
||||
stats["fields"] += changed
|
||||
if (not dry_run) and (customer.notes or "").strip() != before_notes:
|
||||
stats["noted"] += 1
|
||||
return stats
|
||||
@@ -0,0 +1,163 @@
|
||||
"""یافتن شمارههای یک مشتری از همهٔ روشهای ذخیرهسازی.
|
||||
|
||||
شمارهها در این پروژه به چند شکل ذخیره میشوند:
|
||||
|
||||
* رکوردهای مدل ``PhoneNumber`` (روش جدید؛ عنوان + شماره)
|
||||
* فیلدهای قدیمی ``Customer.phone1`` / ``phone2`` / ``phone3``
|
||||
* بدون صفر ابتدایی (``9133722815`` بهجای ``09133722815``) و بعضاً مقدارهای
|
||||
بیمعنی مثل ``'0'`` یا شمارهٔ ثابت.
|
||||
|
||||
این ماژول تنها مرجع خواندن شمارههاست: هر جای دیگر (پنل، اسکنر، worker) باید از
|
||||
همین توابع استفاده کند تا رفتار یکسان باشد.
|
||||
|
||||
«شمارهٔ اصلی» بهصورت **مقدارِ خودِ شماره** (نرمالشده) در ``Customer.sms_primary_phones``
|
||||
ذخیره میشود، نه با کلید رکورد. دلیل: دادهٔ مشتریان دائماً تغییر میکند (افزودن، ویرایش،
|
||||
حذف) و کلید رکورد در بازسازیها از بین میرود؛ ولی شماره یک مقدار پایدار است. اگر شمارهای
|
||||
از مشتری حذف یا ویرایش شود، انتخاب اصلی خودبهخود بیاثر میشود و سیستم به شمارهٔ اول
|
||||
برمیگردد.
|
||||
"""
|
||||
from integrations.sms.normalize import normalize_mobile
|
||||
|
||||
|
||||
class PhoneEntry:
|
||||
"""یک شمارهٔ مشتری، مستقل از اینکه کجا ذخیره شده است."""
|
||||
|
||||
__slots__ = ("raw", "display", "normalized", "title", "source", "is_primary")
|
||||
|
||||
def __init__(self, raw, title, source, is_primary=False):
|
||||
self.raw = raw
|
||||
self.title = title
|
||||
self.source = source
|
||||
self.normalized = normalize_mobile(raw)
|
||||
self.display = to_display(raw)
|
||||
self.is_primary = is_primary
|
||||
|
||||
@property
|
||||
def is_mobile(self):
|
||||
return bool(self.normalized)
|
||||
|
||||
def as_dict(self):
|
||||
return {
|
||||
"raw": self.raw,
|
||||
"display": self.display,
|
||||
"normalized": self.normalized,
|
||||
"title": self.title,
|
||||
"source": self.source,
|
||||
"is_primary": self.is_primary,
|
||||
"is_mobile": self.is_mobile,
|
||||
}
|
||||
|
||||
def __repr__(self): # pragma: no cover - کمککنندهٔ دیباگ
|
||||
return f"<PhoneEntry {self.display!r} primary={self.is_primary} source={self.source}>"
|
||||
|
||||
|
||||
def to_display(raw):
|
||||
"""شماره را برای نمایش با صفر ابتدایی برمیگرداند (بدون دستبردن در مقدار ذخیرهشده)."""
|
||||
text = "" if raw is None else str(raw).strip()
|
||||
if not text:
|
||||
return ""
|
||||
normalized = normalize_mobile(text)
|
||||
if normalized:
|
||||
return "0" + normalized[3:] # +989xxxxxxxxx → 09xxxxxxxxx
|
||||
return text
|
||||
|
||||
|
||||
def _iter_sources(customer):
|
||||
"""(title, raw, source) از همهٔ منابع، بدون تکرار — ابتدا رکوردها، سپس فیلدهای قدیمی."""
|
||||
from ..models import PhoneNumber
|
||||
|
||||
seen = set()
|
||||
rows = PhoneNumber.objects.filter(customer=customer).order_by("id")
|
||||
for row in rows:
|
||||
raw = (row.phone_number or "").strip()
|
||||
if not raw:
|
||||
continue
|
||||
key = normalize_mobile(raw) or raw
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
yield (row.title or "تلفن", raw, "phone_number")
|
||||
|
||||
legacy = (
|
||||
("تلفن 1", getattr(customer, "phone1", "")),
|
||||
("تلفن 2", getattr(customer, "phone2", "")),
|
||||
("تلفن 3", getattr(customer, "phone3", "")),
|
||||
)
|
||||
for title, raw in legacy:
|
||||
raw = (raw or "").strip()
|
||||
if not raw:
|
||||
continue
|
||||
key = normalize_mobile(raw) or raw
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
yield (title, raw, "legacy")
|
||||
|
||||
|
||||
def stored_primary_normalized(customer):
|
||||
"""مجموعهٔ شمارههای نرمالشدهای که مدیر بهعنوان «اصلی» انتخاب کرده است."""
|
||||
raw = (getattr(customer, "sms_primary_phones", "") or "").strip()
|
||||
if not raw:
|
||||
return set()
|
||||
return {part.strip() for part in raw.split(",") if part.strip()}
|
||||
|
||||
|
||||
def normalize_primary_values(values):
|
||||
"""فهرست شمارههای خام → رشتهٔ نرمالشدهٔ یکتا برای ذخیره در ``sms_primary_phones``.
|
||||
|
||||
شمارههای نامعتبر (ثابت، ناقص، ``'0'``) نادیده گرفته میشوند؛ پس علامتزدن یک شمارهٔ
|
||||
غیرموبایل بهعنوان «اصلی» بیاثر میماند و سیستم به شمارهٔ اولِ معتبر برمیگردد.
|
||||
"""
|
||||
unique = []
|
||||
for raw in values:
|
||||
number = normalize_mobile(raw)
|
||||
if number and number not in unique:
|
||||
unique.append(number)
|
||||
return ",".join(unique)
|
||||
|
||||
|
||||
def save_primary_normalized(customer, numbers, *, update_fields=("sms_primary_phones",)):
|
||||
"""ذخیرهٔ فهرست شمارههای اصلی (نرمالشده، بدون تکرار، به ترتیب ورودی)."""
|
||||
customer.sms_primary_phones = normalize_primary_values(numbers)
|
||||
customer.save(update_fields=list(update_fields))
|
||||
return stored_primary_normalized(customer)
|
||||
|
||||
|
||||
def customer_phone_entries(customer):
|
||||
"""همهٔ شمارههای مشتری از هر منبعی که ذخیره شده باشند (بدون تکرار)."""
|
||||
if customer is None:
|
||||
return []
|
||||
primary = stored_primary_normalized(customer)
|
||||
return [
|
||||
PhoneEntry(raw, title, source, is_primary=(entry_norm in primary) if entry_norm else False)
|
||||
for title, raw, source in _iter_sources(customer)
|
||||
for entry_norm in [normalize_mobile(raw)]
|
||||
]
|
||||
|
||||
|
||||
def usable_entries(customer):
|
||||
"""فقط شمارههایی که موبایل معتبرند و واقعاً قابل ارسال پیامک هستند."""
|
||||
return [entry for entry in customer_phone_entries(customer) if entry.is_mobile]
|
||||
|
||||
|
||||
def primary_phones(customer):
|
||||
"""شمارههای خام گیرندهٔ پیامک: انتخابشدههای مدیر، وگرنه شمارهٔ اولِ قابل استفاده."""
|
||||
entries = usable_entries(customer)
|
||||
if not entries:
|
||||
return []
|
||||
chosen = [entry for entry in entries if entry.is_primary]
|
||||
if chosen:
|
||||
return [entry.raw for entry in chosen]
|
||||
return [entries[0].raw]
|
||||
|
||||
|
||||
def resolved_recipients(customer):
|
||||
"""گیرندگان نهاییِ نرمالشده (``+989xxxxxxxxx``) و یکتا."""
|
||||
seen = []
|
||||
for entry in usable_entries(customer):
|
||||
if entry.is_primary and entry.normalized not in seen:
|
||||
seen.append(entry.normalized)
|
||||
if seen:
|
||||
return seen
|
||||
entries = usable_entries(customer)
|
||||
return [entries[0].normalized] if entries else []
|
||||
@@ -0,0 +1,22 @@
|
||||
"""تعیین گیرندگان پیامک.
|
||||
|
||||
منطق واقعیِ یافتن شمارهها در ``admin_panel.sms.phones`` است (تنها مرجع خواندن
|
||||
شمارهها). این ماژول فقط نامهای قدیمی را برای سازگاری نگه میدارد و آنها را به
|
||||
همان توابع واگذار میکند تا رفتار در همهٔ مسیرها یکسان بماند.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from .phones import primary_phones as _primary_phones
|
||||
from .phones import resolved_recipients
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def primary_phones(customer):
|
||||
"""شمارههای خام گیرندهٔ پیامک (اصلیهای انتخابشده، وگرنه شمارهٔ اولِ معتبر)."""
|
||||
return _primary_phones(customer)
|
||||
|
||||
|
||||
def resolve_recipients(customer):
|
||||
"""فهرست شمارههای نرمالشدهٔ یکتای گیرنده."""
|
||||
return resolved_recipients(customer)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""رندر قالب متن پیامک با placeholderهای امن."""
|
||||
from persiantools.jdatetime import JalaliDate, JalaliDateTime
|
||||
|
||||
|
||||
class _SafeDict(dict):
|
||||
"""دیکشنری که برای کلیدهای ناشناخته رشته خالی برمیگرداند (بدون خطا)."""
|
||||
|
||||
def __missing__(self, key):
|
||||
return ""
|
||||
|
||||
|
||||
def format_amount(value):
|
||||
try:
|
||||
return f"{int(value):,}"
|
||||
except (TypeError, ValueError):
|
||||
return str(value or "")
|
||||
|
||||
|
||||
def format_jalali(value):
|
||||
"""تبدیل datetime/date به تاریخ جلالی ``YYYY/MM/DD``."""
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
if hasattr(value, "date") and callable(getattr(value, "date")):
|
||||
value = value.date()
|
||||
return JalaliDate(value).strftime("%Y/%m/%d")
|
||||
except Exception:
|
||||
try:
|
||||
return JalaliDateTime(value).strftime("%Y/%m/%d")
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
|
||||
def render_template(text, context=None):
|
||||
ctx = dict(context or {})
|
||||
if "amount" in ctx:
|
||||
ctx.setdefault("amount_sep", format_amount(ctx["amount"]))
|
||||
if "date" in ctx:
|
||||
ctx.setdefault("date_raw", ctx["date"])
|
||||
ctx["date"] = format_jalali(ctx["date"])
|
||||
return (text or "").format_map(_SafeDict(ctx))
|
||||
@@ -0,0 +1,25 @@
|
||||
"""قواعد فعال/غیرفعال بودن پیامک برای مشتری (مدل opt-out)."""
|
||||
from ..models import CustomerSMSSetting, SMSType
|
||||
|
||||
|
||||
def is_sms_enabled(customer, sms_type):
|
||||
"""اگر رکوردی با is_enabled=False وجود داشته باشد غیرفعال است؛ در غیر اینصورت فعال."""
|
||||
if customer is None or sms_type == SMSType.CUSTOM:
|
||||
return True
|
||||
setting = CustomerSMSSetting.objects.filter(customer=customer, sms_type=sms_type).first()
|
||||
if setting is None:
|
||||
return True
|
||||
return setting.is_enabled
|
||||
|
||||
|
||||
def customer_toggle_map(customer):
|
||||
"""فهرست نوعهای پیامک با وضعیت مؤثر (برای نمایش در پنل مشتری)."""
|
||||
overrides = {
|
||||
s.sms_type: s.is_enabled
|
||||
for s in CustomerSMSSetting.objects.filter(customer=customer)
|
||||
}
|
||||
return [
|
||||
{"code": code, "label": label, "enabled": overrides.get(code, True)}
|
||||
for code, label in SMSType.choices
|
||||
if code != SMSType.CUSTOM
|
||||
]
|
||||
@@ -0,0 +1,336 @@
|
||||
"""اسکنر دورهای: پیدا کردن موارد سررسیدشده و قرار دادن پیامک در صف.
|
||||
|
||||
قواعد کنترل جریان (همه از پنل مدیریت قابل تنظیم):
|
||||
|
||||
* **خط شروع (``scan_since``)**: فقط مواردی صف میشوند که «لحظهٔ باز شدن پنجرهٔ یادآوری»
|
||||
آنها از این لحظه به بعد باشد. بنابراین در نخستین اجرا هیچکدام از موارد قدیمی موجود
|
||||
در دیتابیس یکباره ارسال نمیشوند.
|
||||
* **اجازهٔ موارد قدیمی (``allow_backlog`` + ``backlog_max``)**: اگر مدیر صریحاً روشن کند،
|
||||
در هر اجرا حداکثر ``backlog_max`` مورد قدیمی هم صف میشود.
|
||||
* **سقف هر اجرا (``max_per_run``)**: سقف کل صفکردن هر اسکنر در یک اجرا.
|
||||
|
||||
هر اسکنر مستقل و مقاوم به خطاست؛ یک خطا بقیه را متوقف نمیکند. ``dedup_key`` از
|
||||
ارسال تکراری جلوگیری میکند و تضمین میکند اجرای دوبارهٔ اسکنر رکورد جدیدی نسازد.
|
||||
|
||||
یادآوریهای **دورهای** (بازدید و ثبت چک) کلیدشان شامل شمارهٔ دوره است. یعنی اگر
|
||||
مدیر یک مورد را از صف حذف کند، فقط همان دوره کنسل میشود و در دورهٔ بعد دوباره
|
||||
ساخته میشود؛ برای همیشه خاموش نمیماند.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, time, timedelta
|
||||
|
||||
from django.db.models import Q, Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from ..models import (Article, Customer, Factor, Sarresid, SMSProviderConfig,
|
||||
SMSTemplate, SMSType)
|
||||
from .enqueue import enqueue_sms
|
||||
from .timing import DEFAULT_VISIT_MONTHS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# پیشفرض فاصلهٔ تکرار «یادآوری ثبت چک» (روز) وقتی قالبی تنظیم نشده باشد.
|
||||
CHEQUE_REG_INTERVAL_DEFAULT = 3
|
||||
|
||||
|
||||
|
||||
def _template(sms_type):
|
||||
return SMSTemplate.objects.filter(sms_type=sms_type, is_enabled=True).first()
|
||||
|
||||
|
||||
def _aware(value):
|
||||
"""تبدیل تاریخ/ساعت naive به aware (کد قدیمی پروژه جاهایی مقدار naive ذخیره میکند)."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
if timezone.is_naive(value):
|
||||
return timezone.make_aware(value, timezone.get_current_timezone())
|
||||
return value
|
||||
# date → آغاز همان روز
|
||||
return timezone.make_aware(
|
||||
datetime.combine(value, time.min), timezone.get_current_timezone(),
|
||||
)
|
||||
|
||||
|
||||
class _Budget:
|
||||
"""ظرفیت صفکردن یک اسکنر در یک اجرا (خط شروع + سقف قدیمی + سقف کل)."""
|
||||
|
||||
def __init__(self, config):
|
||||
self.since = config.scan_since
|
||||
self.allow_backlog = bool(config.allow_backlog)
|
||||
self.backlog_max = config.backlog_max or 0
|
||||
self.max_per_run = config.max_per_run or 0
|
||||
self.new_count = 0
|
||||
self.backlog_count = 0
|
||||
|
||||
def is_backlog(self, trigger):
|
||||
trigger = _aware(trigger)
|
||||
return self.since is not None and trigger < self.since
|
||||
|
||||
def can_send(self, trigger):
|
||||
if self.max_per_run and (self.new_count + self.backlog_count) >= self.max_per_run:
|
||||
return False
|
||||
if self.is_backlog(trigger):
|
||||
return self.allow_backlog and self.backlog_count < self.backlog_max
|
||||
return True
|
||||
|
||||
def record(self, trigger):
|
||||
if self.is_backlog(trigger):
|
||||
self.backlog_count += 1
|
||||
else:
|
||||
self.new_count += 1
|
||||
|
||||
|
||||
def scan_cheque_due(today):
|
||||
template = _template(SMSType.CHEQUE_DUE)
|
||||
if template is None:
|
||||
return 0
|
||||
budget = _Budget(SMSProviderConfig.load())
|
||||
offset = template.offset_days or 0
|
||||
qs = Sarresid.objects.filter(
|
||||
type="check", payStatus="false",
|
||||
sarresidDT__date__gte=today,
|
||||
sarresidDT__date__lte=today + timedelta(days=offset),
|
||||
).select_related("customer").order_by("sarresidDT")
|
||||
count = 0
|
||||
for s in qs:
|
||||
if not s.customer or not s.sarresidDT:
|
||||
continue
|
||||
# پنجرهٔ یادآوری از «سررسید منهای offset» باز میشود.
|
||||
trigger = _aware(s.sarresidDT) - timedelta(days=offset)
|
||||
if not budget.can_send(trigger):
|
||||
continue
|
||||
days = (s.sarresidDT.date() - today).days
|
||||
made = enqueue_sms(
|
||||
customer=s.customer, sms_type=SMSType.CHEQUE_DUE,
|
||||
context={
|
||||
"name": s.customer.name_family, "amount": s.price,
|
||||
"date": s.sarresidDT, "days": days, "check_row": s.check_row_number,
|
||||
},
|
||||
dedup_key=f"cheque_due:{s.id}",
|
||||
)
|
||||
if made:
|
||||
budget.record(trigger)
|
||||
count += made
|
||||
return count
|
||||
|
||||
|
||||
def scan_nesiye_due(today):
|
||||
template = _template(SMSType.NESIYE_DUE)
|
||||
if template is None:
|
||||
return 0
|
||||
budget = _Budget(SMSProviderConfig.load())
|
||||
offset = template.offset_days or 0
|
||||
qs = Sarresid.objects.filter(
|
||||
type="nesiye", payStatus="false",
|
||||
sarresidDT__date__gte=today,
|
||||
sarresidDT__date__lte=today + timedelta(days=offset),
|
||||
).select_related("customer").order_by("sarresidDT")
|
||||
count = 0
|
||||
for s in qs:
|
||||
if not s.customer or not s.sarresidDT:
|
||||
continue
|
||||
trigger = _aware(s.sarresidDT) - timedelta(days=offset)
|
||||
if not budget.can_send(trigger):
|
||||
continue
|
||||
days = (s.sarresidDT.date() - today).days
|
||||
made = enqueue_sms(
|
||||
customer=s.customer, sms_type=SMSType.NESIYE_DUE,
|
||||
context={
|
||||
"name": s.customer.name_family, "amount": s.price,
|
||||
"date": s.sarresidDT, "days": days,
|
||||
},
|
||||
dedup_key=f"nesiye_due:{s.id}",
|
||||
)
|
||||
if made:
|
||||
budget.record(trigger)
|
||||
count += made
|
||||
return count
|
||||
|
||||
|
||||
def scan_cheque_registration(today):
|
||||
"""یادآوری «ثبت اطلاعات چک» برای چکهای پرداختنشدهٔ ثبتنشده.
|
||||
|
||||
هر ``interval`` روز یکبار تکرار میشود و بهمحض اینکه شمارهٔ صیادی چک ثبت شود
|
||||
(یا چک پرداخت شود) متوقف میگردد. دورهٔ تکرار از پنل قابل تنظیم است.
|
||||
"""
|
||||
template = _template(SMSType.CHEQUE_REGISTRATION)
|
||||
if template is None:
|
||||
return 0
|
||||
budget = _Budget(SMSProviderConfig.load())
|
||||
interval = template.repeat_interval_days or CHEQUE_REG_INTERVAL_DEFAULT
|
||||
qs = Sarresid.objects.filter(
|
||||
type="check", payStatus="false", sarresidDT__date__gte=today,
|
||||
).filter(
|
||||
Q(check_sayyad_id="") | Q(check_sayyad_id__isnull=True),
|
||||
).select_related("customer")
|
||||
count = 0
|
||||
for s in qs:
|
||||
if not s.customer:
|
||||
continue
|
||||
anchor = s.createdDT or s.sarresidDT
|
||||
if not anchor:
|
||||
continue
|
||||
days = (today - anchor.date()).days
|
||||
if days <= 0 or days % interval != 0:
|
||||
continue
|
||||
cycle = days // interval
|
||||
# لحظهٔ باز شدن همین دورهٔ تکرار (نه خودِ چک) ملاک خط شروع است.
|
||||
trigger = _aware(anchor) + timedelta(days=days)
|
||||
if not budget.can_send(trigger):
|
||||
continue
|
||||
made = enqueue_sms(
|
||||
customer=s.customer, sms_type=SMSType.CHEQUE_REGISTRATION,
|
||||
context={
|
||||
"name": s.customer.name_family, "amount": s.price,
|
||||
"date": s.sarresidDT, "days": (s.sarresidDT.date() - today).days
|
||||
if s.sarresidDT else "",
|
||||
"check_row": s.check_row_number,
|
||||
},
|
||||
dedup_key=f"cheque_reg:{s.id}:{cycle}",
|
||||
)
|
||||
if made:
|
||||
budget.record(trigger)
|
||||
count += made
|
||||
return count
|
||||
|
||||
|
||||
def scan_debt_due(today):
|
||||
"""یادآوری بدهی برای ماندهدارها روی سررسید وصول مطالبات."""
|
||||
template = _template(SMSType.DEBT_DUE)
|
||||
if template is None:
|
||||
return 0
|
||||
budget = _Budget(SMSProviderConfig.load())
|
||||
offset = template.offset_days or 0
|
||||
target = today - timedelta(days=offset)
|
||||
qs = Sarresid.objects.filter(
|
||||
payStatus="false", sarresidDT__date=target,
|
||||
).select_related("customer").order_by("sarresidDT")
|
||||
count = 0
|
||||
seen = set()
|
||||
for s in qs:
|
||||
if not s.customer or s.customer_id in seen or not s.sarresidDT:
|
||||
continue
|
||||
trigger = _aware(s.sarresidDT)
|
||||
if not budget.can_send(trigger):
|
||||
continue
|
||||
totals = Article.objects.filter(customer=s.customer).aggregate(
|
||||
d=Sum("debit"), c=Sum("credit"),
|
||||
)
|
||||
net = (totals["d"] or 0) - (totals["c"] or 0)
|
||||
if net <= 0:
|
||||
continue
|
||||
made = enqueue_sms(
|
||||
customer=s.customer, sms_type=SMSType.DEBT_DUE,
|
||||
context={"name": s.customer.name_family, "amount": net},
|
||||
dedup_key=f"debt_due:{s.id}",
|
||||
)
|
||||
if made:
|
||||
seen.add(s.customer_id)
|
||||
budget.record(trigger)
|
||||
count += made
|
||||
return count
|
||||
|
||||
|
||||
def scan_survey(today):
|
||||
"""نظرسنجی چند روز پس از ایجاد فاکتور."""
|
||||
template = _template(SMSType.SURVEY)
|
||||
if template is None:
|
||||
return 0
|
||||
budget = _Budget(SMSProviderConfig.load())
|
||||
offset = template.offset_days or 3
|
||||
target = today - timedelta(days=offset)
|
||||
qs = Factor.objects.filter(
|
||||
is_active=True, create_date__date=target,
|
||||
).exclude(pay_status="cancell").select_related("customer").order_by("create_date")
|
||||
count = 0
|
||||
for f in qs:
|
||||
if not f.customer or not f.create_date:
|
||||
continue
|
||||
trigger = _aware(f.create_date) + timedelta(days=offset)
|
||||
if not budget.can_send(trigger):
|
||||
continue
|
||||
made = enqueue_sms(
|
||||
customer=f.customer, sms_type=SMSType.SURVEY,
|
||||
context={"name": f.customer.name_family, "amount": f.amount},
|
||||
dedup_key=f"survey:{f.id}",
|
||||
)
|
||||
if made:
|
||||
budget.record(trigger)
|
||||
count += made
|
||||
return count
|
||||
|
||||
|
||||
def scan_visit(today):
|
||||
"""یادآوری بازدید برای مشتریان عقبافتاده از بازدید (منطق صفحهٔ «انتظار»).
|
||||
|
||||
لحظهٔ واجدشرطشدن هر مشتری = «آخرین بازدید + دورهٔ مجاز» (یا تاریخ نصب برای
|
||||
مشتریانی که هرگز بازدید نشدهاند). اگر این لحظه پیش از خط شروع باشد، مورد قدیمی
|
||||
تلقی میشود و فقط با اجازهٔ صریح مدیر (و در سقف تعیینشده) ارسال میگردد.
|
||||
"""
|
||||
template = _template(SMSType.VISIT)
|
||||
if template is None:
|
||||
return 0
|
||||
budget = _Budget(SMSProviderConfig.load())
|
||||
# دورهٔ بازدید: اول از قالب (قابل تنظیم از پنل)، وگرنه متغیر محیطی legacy، وگرنه پیشفرض.
|
||||
months = template.repeat_interval_months
|
||||
if not months:
|
||||
try:
|
||||
months = int(os.environ.get("bazdid", DEFAULT_VISIT_MONTHS))
|
||||
except (TypeError, ValueError):
|
||||
months = DEFAULT_VISIT_MONTHS
|
||||
period = timedelta(weeks=int(months * 4.3))
|
||||
period_days = max(period.days, 1)
|
||||
now = timezone.now()
|
||||
qs = Customer.objects.filter(
|
||||
laghv_bazdid=False,
|
||||
is_active=True,
|
||||
installDate__lte=(now - period).date(),
|
||||
).order_by("id")
|
||||
count = 0
|
||||
for customer in qs:
|
||||
anchor = _aware(customer.last_bazdid_dt) or _aware(customer.installDate)
|
||||
if anchor is None:
|
||||
continue
|
||||
trigger = anchor + period
|
||||
if trigger > now:
|
||||
continue # هنوز عقبافتاده نیست
|
||||
if not budget.can_send(trigger):
|
||||
continue
|
||||
# چرخه = شمارهٔ دورهٔ کاملِ سپریشده از لنگر. کلید به چرخه گره میخورد تا
|
||||
# حذفِ یادآوری توسط مدیر فقط همان «دوره» را کنسل کند و در دورههای بعدی
|
||||
# دوباره ساخته شود (نه اینکه برای همیشه خاموش بماند).
|
||||
cycle = max((today - anchor.date()).days // period_days, 0)
|
||||
made = enqueue_sms(
|
||||
customer=customer, sms_type=SMSType.VISIT,
|
||||
context={"name": customer.name_family},
|
||||
dedup_key=f"visit:{customer.id}:{cycle}",
|
||||
)
|
||||
if made:
|
||||
budget.record(trigger)
|
||||
count += made
|
||||
return count
|
||||
|
||||
|
||||
SCANNERS = [
|
||||
("cheque_due", scan_cheque_due),
|
||||
("nesiye_due", scan_nesiye_due),
|
||||
("cheque_registration", scan_cheque_registration),
|
||||
("debt_due", scan_debt_due),
|
||||
("survey", scan_survey),
|
||||
("visit", scan_visit),
|
||||
]
|
||||
|
||||
|
||||
def run_all(today=None):
|
||||
"""همهٔ اسکنرها را اجرا و نگاشت «نام → تعداد درجشده» را برمیگرداند."""
|
||||
today = today or timezone.localdate()
|
||||
results = {}
|
||||
for name, func in SCANNERS:
|
||||
try:
|
||||
results[name] = func(today)
|
||||
except Exception:
|
||||
logger.exception("اسکن پیامک شکست خورد: %s", name)
|
||||
results[name] = -1
|
||||
return results
|
||||
@@ -0,0 +1,112 @@
|
||||
"""معنای «زمانبندی» برای هر نوع پیامک.
|
||||
|
||||
هر نوع پیامک زمانبندی متفاوتی دارد و نمیشود همه را زیر یک برچسب «روز قبل از سررسید»
|
||||
جمع کرد. این ماژول تنها مرجع این معناست: هم پنل قالبها بر اساسش فیلد درست را نشان
|
||||
میدهد، هم اسکنر بر اساسش تصمیم میگیرد. هر نوع دقیقاً به یک فیلد زمانی نگاشت میشود
|
||||
(``field``) تا داده هرگز ناهمخوان نشود؛ بقیهٔ فیلدهای زمانی صفر/خالی میمانند.
|
||||
"""
|
||||
from ..models import SMSType
|
||||
|
||||
NONE = "none"
|
||||
BEFORE_DUE = "before_due"
|
||||
AFTER_CREATION = "after_creation"
|
||||
RECURRING = "recurring"
|
||||
PERIOD_MONTHS = "period_months"
|
||||
|
||||
# پیشفرض دورهٔ بازدید (ماه) وقتی مدیر مقداری در قالب نگذاشته باشد.
|
||||
DEFAULT_VISIT_MONTHS = 9
|
||||
|
||||
TIMING = {
|
||||
SMSType.WELCOME: {
|
||||
"kind": NONE,
|
||||
"field": None,
|
||||
"note": "بهمحض ثبت مشتری و بدون تأخیر ارسال میشود؛ تنظیم زمانی ندارد.",
|
||||
},
|
||||
SMSType.CHEQUE_DUE: {
|
||||
"kind": BEFORE_DUE,
|
||||
"field": "offset_days",
|
||||
"label": "چند روز قبل از سررسید چک",
|
||||
"default": 2,
|
||||
"note": "پنجرهٔ یادآوری از «سررسید − این تعداد روز» باز میشود و تا سررسید ادامه دارد.",
|
||||
},
|
||||
SMSType.NESIYE_DUE: {
|
||||
"kind": BEFORE_DUE,
|
||||
"field": "offset_days",
|
||||
"label": "چند روز قبل از سررسید نسیه",
|
||||
"default": 2,
|
||||
"note": "پنجرهٔ یادآوری از «سررسید − این تعداد روز» باز میشود و تا سررسید ادامه دارد.",
|
||||
},
|
||||
SMSType.CHEQUE_REGISTRATION: {
|
||||
"kind": RECURRING,
|
||||
"field": "repeat_interval_days",
|
||||
"label": "هر چند روز یکبار",
|
||||
"default": 3,
|
||||
"note": "تا زمانی که اطلاعات ثبت چک (شمارهٔ صیادی) وارد نشده و چک پرداخت نشده، "
|
||||
"با این فاصله تکرار میشود.",
|
||||
},
|
||||
SMSType.SURVEY: {
|
||||
"kind": AFTER_CREATION,
|
||||
"field": "offset_days",
|
||||
"label": "چند روز بعد از صدور فاکتور",
|
||||
"default": 3,
|
||||
"note": "این تعداد روز پس از ثبت فاکتور ارسال میشود.",
|
||||
},
|
||||
SMSType.DEBT_DUE: {
|
||||
"kind": NONE,
|
||||
"field": None,
|
||||
"note": "روی سررسیدِ وصول مطالبات و فقط برای مشتریانی که مانده بدهکارند ارسال میشود.",
|
||||
},
|
||||
SMSType.VISIT: {
|
||||
"kind": PERIOD_MONTHS,
|
||||
"field": "repeat_interval_months",
|
||||
"label": "هر چند ماه یکبار",
|
||||
"default": DEFAULT_VISIT_MONTHS,
|
||||
"note": "برای مشتریانی که از این مدت بازدید نشدهاند ارسال میشود.",
|
||||
},
|
||||
SMSType.CUSTOM: {
|
||||
"kind": NONE,
|
||||
"field": None,
|
||||
"note": "فقط بهصورت دستی از صفحهٔ مشتری ارسال میشود؛ تنظیم زمانی ندارد.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def timing_for(sms_type):
|
||||
"""اطلاعات زمانبندی یک نوع پیامک (با پیشفرض بیخطر برای انواع ناشناخته)."""
|
||||
return TIMING.get(sms_type, {"kind": NONE, "field": None, "note": ""})
|
||||
|
||||
|
||||
def apply_timing(template, value):
|
||||
"""فیلد زمانیِ متناسب با نوع قالب را مینشاند و بقیه را صفر/خالی میکند.
|
||||
|
||||
``value`` عدد صحیحِ واردشده از فرم است (یا ``None``). این کار تضمین میکند صرفنظر از
|
||||
اینکه فرم چه بفرستد، فقط یک فیلد زمانی برای هر قالب معنا داشته باشد.
|
||||
"""
|
||||
info = timing_for(template.sms_type)
|
||||
field = info["field"]
|
||||
template.offset_days = 0
|
||||
template.repeat_interval_days = None
|
||||
template.repeat_interval_months = None
|
||||
if field == "offset_days":
|
||||
template.offset_days = value if value is not None else info["default"]
|
||||
elif field == "repeat_interval_days":
|
||||
template.repeat_interval_days = value if value else info["default"]
|
||||
elif field == "repeat_interval_months":
|
||||
template.repeat_interval_months = value if value else None
|
||||
return template
|
||||
|
||||
|
||||
def current_timing_value(template):
|
||||
"""مقدار فعلی فیلد زمانیِ متناسب (برای نمایش در فرم).
|
||||
|
||||
اگر مقدار خالی باشد، پیشفرض همان نوع برگردانده میشود تا فیلدِ تنظیمنشده هم در
|
||||
فرم دیده شود و مدیر بتواند مقدارش را تغییر دهد.
|
||||
"""
|
||||
info = timing_for(template.sms_type)
|
||||
field = info["field"]
|
||||
if field is None:
|
||||
return None
|
||||
value = getattr(template, field)
|
||||
if value is None:
|
||||
value = info.get("default")
|
||||
return value
|
||||
@@ -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,9 +120,26 @@
|
||||
|
||||
|
||||
|
||||
{% 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>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="navbar-end ml-0">
|
||||
@@ -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,86 +45,29 @@
|
||||
<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 %}
|
||||
<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="عنوان">
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<input disabled value="0{{ phone_obj.phone_number }}" 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>
|
||||
</div>
|
||||
{% if perms.admin_panel.ViewStore %}
|
||||
<div class="control">
|
||||
<button type="button" disabled class="button is-danger remove-phone-btn">-</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% 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 %}
|
||||
{# همهٔ شمارهها از هر منبعی که ذخیره شدهاند (جدول شمارهها + فیلدهای قدیمی)، بدون تکرار #}
|
||||
{% for entry in sms_phone_entries %}
|
||||
<div class="field has-addons mt-2 ss02">
|
||||
<div class="control" style="width: 30%;">
|
||||
<input disabled value="{{ entry.title }}" name="phone_titles" class="input" type="text" placeholder="عنوان">
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<input disabled value="{{ entry.display }}" name="phones" class="input an_phone" type="text" placeholder="تلفن">
|
||||
</div>
|
||||
<div class="control">
|
||||
<a href="tel:{{ entry.display }}" class="button is-success" title="تماس با {{ entry.display }}">
|
||||
📞
|
||||
</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>
|
||||
{% empty %}
|
||||
<p class="help mt-2">شمارهای برای این مشتری ثبت نشده است.</p>
|
||||
{% endfor %}
|
||||
</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,9 +180,11 @@
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
{% include "./_customer_sms.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block BodyCustomer2 %}
|
||||
<div id="addNewIteam" class="modal">
|
||||
|
||||
@@ -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">
|
||||
{% csrf_token %}
|
||||
<div class="field column is-4-desktop is-12-mobile m-0">
|
||||
<label class="label">نام ونام خانوادگی</label>
|
||||
<div class="control">
|
||||
<input required name="an_name" class="input an_name" type="text" placeholder="نام نام خانوادگی">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-8-desktop is-12-mobile m-0">
|
||||
<label class="label">آدرس</label>
|
||||
<div class="control">
|
||||
<input name="an_address" class="input an_address" type="text" placeholder="آدرس کامل">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-6-desktop is-12-mobile m-0">
|
||||
<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="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="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" id="addName" class="input an_name" type="text"
|
||||
autocomplete="name" placeholder="نام و نام خانوادگی">
|
||||
</div>
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<input name="phones" class="input an_phone" type="text" placeholder="شماره تماس">
|
||||
|
||||
<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="idnCode" id="addIdnCode" class="input idnCode" type="text"
|
||||
placeholder="کد شناسایی مشتری">
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="button" class="button is-info add-phone-btn">+</button>
|
||||
|
||||
<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="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="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>
|
||||
|
||||
<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>
|
||||
|
||||
</form>
|
||||
|
||||
|
||||
<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,83 +276,168 @@
|
||||
</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
|
||||
|
||||
});
|
||||
$(".modal-close, .modal-background").click(function(){
|
||||
$(".mEdit1").removeClass("is-active")
|
||||
});
|
||||
$(".closeSearch").click(function(){
|
||||
resetAddForm();
|
||||
});
|
||||
$(".modal-close, .modal-background").click(function(){
|
||||
$(".mEdit1").removeClass("is-active")
|
||||
});
|
||||
$(".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('');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
$("#addNewUser").click(function(){
|
||||
$(".addNewUser").addClass("is-active")
|
||||
});
|
||||
$("#openSearchBox").click(function(){
|
||||
$("#addCustomerForm").on('submit', function(){
|
||||
// جلوگیری از ارسال دوباره؛ خودِ فرم همین حالا دادهها را بسته است.
|
||||
$(this).find('#DcreateCS').prop('disabled', true).text('در حال ثبت…');
|
||||
});
|
||||
|
||||
$("#addNewUser").click(function(){
|
||||
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")
|
||||
});
|
||||
$("#bedehiCN01").click(function(){
|
||||
});
|
||||
$("#bedehiCN01").click(function(){
|
||||
var factorId = $(this).parent().parent().find(".fid").val();
|
||||
if ($("#bedehiStatus").val() == "tasfiye"){
|
||||
alert("send To server to tasfiye");
|
||||
$("tbody").find('.fidv-'+factorId).parent().parent().remove();
|
||||
$(".mEdit1").removeClass("is-active");
|
||||
}
|
||||
});
|
||||
});
|
||||
} );
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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('has_group', has_group)
|
||||
register.filter('toJalali', toJalali)
|
||||
register.filter('toJalaliDT', toJalaliDT)
|
||||
+15
-1
@@ -79,5 +79,19 @@ 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)
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -11,8 +11,13 @@ from django.contrib.auth.decorators import login_required, permission_required
|
||||
from persiantools.jdatetime import JalaliDate
|
||||
|
||||
from ..models import (Customer, PhoneNumber, ProductCategory, Product, Factor,
|
||||
FactorIteam, Article, Sarresid, Action, CacheTable)
|
||||
from ._helpers import logger, _parse_jalali_date
|
||||
FactorIteam, Article, Sarresid, Action, CacheTable, SMSType)
|
||||
from ..sms.enqueue import enqueue_sms
|
||||
from ..sms.phones import (customer_phone_entries, normalize_primary_values,
|
||||
save_primary_normalized)
|
||||
from ..sms.recipients import resolve_recipients
|
||||
from ..sms.rules import customer_toggle_map
|
||||
from ._helpers import logger, _parse_jalali_date, _parse_phone_rows
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@@ -23,19 +28,21 @@ def customers(request):
|
||||
searchStatus = True
|
||||
full_path = request.get_full_path()
|
||||
|
||||
# مشتریان غیرفعال هرگز نمایش داده نمیشوند — نه در فهرست کامل و نه در جستجو.
|
||||
all_customers = Customer.objects.filter(is_active=True)
|
||||
if searchOn == 'code':
|
||||
all_customers = Customer.objects.filter(idCode__contains=searchVal).order_by('-create_date')
|
||||
all_customers = all_customers.filter(idCode__contains=searchVal)
|
||||
elif searchOn == 'phone':
|
||||
all_customers = Customer.objects.filter(Q(phone1__contains=searchVal) | Q(phone2__contains=searchVal) | Q(phone3__contains=searchVal) ).order_by('-create_date')
|
||||
all_customers = all_customers.filter(Q(phone1__contains=searchVal) | Q(phone2__contains=searchVal) | Q(phone3__contains=searchVal))
|
||||
elif searchOn == 'name':
|
||||
all_customers = Customer.objects.filter(name_family__contains=searchVal).order_by('-create_date')
|
||||
all_customers = all_customers.filter(name_family__contains=searchVal)
|
||||
elif searchOn == 'address':
|
||||
all_customers = Customer.objects.filter(address__contains=searchVal).order_by('-create_date')
|
||||
all_customers = all_customers.filter(address__contains=searchVal)
|
||||
elif searchOn == 'laghv':
|
||||
all_customers = Customer.objects.filter(laghv_bazdid=True).order_by('-create_date')
|
||||
all_customers = all_customers.filter(laghv_bazdid=True)
|
||||
else:
|
||||
searchStatus = False
|
||||
all_customers = Customer.objects.filter(is_active=True).order_by('-create_date')
|
||||
all_customers = all_customers.order_by('-create_date')
|
||||
|
||||
paginator = Paginator(all_customers, 25) # Show 25 contacts per page.
|
||||
page_number = request.GET.get('page', None)
|
||||
@@ -50,17 +57,19 @@ def customers(request):
|
||||
def customersSearch(request, searchVal,searchOn):
|
||||
searchStatus = True
|
||||
|
||||
# همان قاعدهٔ فهرست اصلی: مشتری غیرفعال در هیچ نتیجهای نمایش داده نمیشود.
|
||||
all_customers = Customer.objects.filter(is_active=True)
|
||||
if searchOn == 'code':
|
||||
all_customers = Customer.objects.filter(idCode__contains=searchVal).order_by('-create_date')
|
||||
all_customers = all_customers.filter(idCode__contains=searchVal)
|
||||
elif searchOn == 'phone':
|
||||
all_customers = Customer.objects.filter(phone1__contains=searchVal).order_by('-create_date')
|
||||
all_customers = all_customers.filter(phone1__contains=searchVal)
|
||||
elif searchOn == 'name':
|
||||
all_customers = Customer.objects.filter(name_family__contains=searchVal).order_by('-create_date')
|
||||
all_customers = all_customers.filter(name_family__contains=searchVal)
|
||||
elif searchOn == 'address':
|
||||
all_customers = Customer.objects.filter(address__contains=searchVal).order_by('-create_date')
|
||||
all_customers = all_customers.filter(address__contains=searchVal)
|
||||
else:
|
||||
searchStatus = False
|
||||
all_customers = Customer.objects.all().order_by('-create_date')
|
||||
all_customers = all_customers.order_by('-create_date')
|
||||
|
||||
paginator = Paginator(all_customers, 25) # Show 25 contacts per page.
|
||||
page_number = request.GET.get('page', None)
|
||||
@@ -112,7 +121,17 @@ def customerNew2(request ,c_id):
|
||||
#all_prd = serializers.serialize("json", all_prda)
|
||||
#return JsonResponse(all_prd,safe = False)
|
||||
dateNow = datetime.datetime.now().date
|
||||
data = {'customer': customer, 'categorys': categorys, 'dateNow': dateNow, 'factorha': factorha, 'mandehesabCs':mandehesabCs}
|
||||
sms_toggles = customer_toggle_map(customer)
|
||||
# همهٔ شمارهها از هر منبعی که ذخیره شدهاند (PhoneNumber و phone1/2/3 قدیمی).
|
||||
sms_phone_entries = customer_phone_entries(customer)
|
||||
sms_effective = {n for n in resolve_recipients(customer)}
|
||||
data = {
|
||||
'customer': customer, 'categorys': categorys, 'dateNow': dateNow,
|
||||
'factorha': factorha, 'mandehesabCs': mandehesabCs,
|
||||
'sms_toggles': sms_toggles, 'sms_phone_entries': sms_phone_entries,
|
||||
'sms_effective': sms_effective,
|
||||
'sms_recipients': sorted(sms_effective),
|
||||
}
|
||||
return render(request, 'admin_panel/customerNew2.html', data)
|
||||
|
||||
|
||||
@@ -223,18 +242,8 @@ def customersAdd(request):
|
||||
installDate = request.POST.get('installDate', '')
|
||||
idnCode = request.POST.get('idnCode', '')
|
||||
|
||||
# دریافت آرایههای عنوان و شماره تلفن
|
||||
titles_list = request.POST.getlist('phone_titles')
|
||||
phones_list = request.POST.getlist('phones')
|
||||
|
||||
# ترکیب عنوانها و شمارهها و حذف ردیفهای خالی
|
||||
valid_phones = []
|
||||
for title, phone in zip(titles_list, phones_list):
|
||||
if phone.strip():
|
||||
valid_phones.append({
|
||||
'title': title.strip() if title.strip() else 'تلفن',
|
||||
'phone': phone.strip()
|
||||
})
|
||||
# آرایههای عنوان/شمارهٔ فرم + ایندکس ردیفهای علامتخورده بهعنوان «اصلی»
|
||||
valid_phones, phones_list, primary_indices = _parse_phone_rows(request)
|
||||
|
||||
year, month, day = map(int, installDate.split('/'))
|
||||
installDate_gregorian = JalaliDate(year, month, day).to_gregorian()
|
||||
@@ -249,34 +258,43 @@ def customersAdd(request):
|
||||
newCs.phone1 = valid_phones[0]['phone'] if len(valid_phones) > 0 else ''
|
||||
newCs.phone2 = valid_phones[1]['phone'] if len(valid_phones) > 1 else ''
|
||||
newCs.phone3 = valid_phones[2]['phone'] if len(valid_phones) > 2 else ''
|
||||
# شمارههای «اصلی» پیامک، بر مبنای مقدارِ نرمالشدهٔ خودِ شمارهها (پایدار در بازسازیها).
|
||||
newCs.sms_primary_phones = normalize_primary_values(
|
||||
phones_list[i] for i in primary_indices if i < len(phones_list)
|
||||
)
|
||||
newCs.save()
|
||||
|
||||
# ذخیره شمارهها به صورت نامحدود در جدول PhoneNumber
|
||||
for item in valid_phones:
|
||||
PhoneNumber.objects.create(customer=newCs, title=item['title'], phone_number=item['phone'])
|
||||
PhoneNumber.objects.create(
|
||||
customer=newCs, title=item['title'], phone_number=item['phone'],
|
||||
)
|
||||
|
||||
# پیامک خوشآمدگویی (در صورت فعال بودن قالب و تنظیم مشتری)
|
||||
enqueue_sms(
|
||||
customer=newCs, sms_type=SMSType.WELCOME,
|
||||
context={'name': newCs.name_family},
|
||||
dedup_key=f'welcome:{newCs.id}',
|
||||
created_by=request.user,
|
||||
)
|
||||
|
||||
return redirect('/panel/customers/')
|
||||
|
||||
|
||||
@login_required(login_url='/panel/p_login/')
|
||||
def customerEdit(request, c_id):
|
||||
# این مسیر فقط endpoint فرم ویرایش است؛ دسترسی مستقیم با GET نباید ۵۰۰ بدهد.
|
||||
if request.method != 'POST':
|
||||
return redirect('/panel/customer/' + str(c_id))
|
||||
|
||||
an_name = request.POST.get('an_name', '')
|
||||
an_address = request.POST.get('an_address', '')
|
||||
installDate = request.POST.get('installDate', '')
|
||||
idnCode = request.POST.get('idnCode', '')
|
||||
laghv_bazdid = request.POST.get('laghv_bazdid', False)
|
||||
|
||||
# دریافت آرایههای عنوان و شماره تلفن
|
||||
titles_list = request.POST.getlist('phone_titles')
|
||||
phones_list = request.POST.getlist('phones')
|
||||
|
||||
valid_phones = []
|
||||
for title, phone in zip(titles_list, phones_list):
|
||||
if phone.strip():
|
||||
valid_phones.append({
|
||||
'title': title.strip() if title.strip() else 'تلفن',
|
||||
'phone': phone.strip()
|
||||
})
|
||||
# آرایههای عنوان/شمارهٔ فرم + ایندکس ردیفهای علامتخورده بهعنوان «اصلی»
|
||||
valid_phones, phones_list, primary_indices = _parse_phone_rows(request)
|
||||
|
||||
year, month, day = map(int, installDate.split('/'))
|
||||
installDate_gregorian = JalaliDate(year, month, day).to_gregorian()
|
||||
@@ -287,17 +305,33 @@ def customerEdit(request, c_id):
|
||||
newCs.installDate = installDate_gregorian
|
||||
newCs.idCode = idnCode
|
||||
newCs.laghv_bazdid = True if laghv_bazdid == 'true' else False
|
||||
# فقط وقتی فرم فیلد توضیحات را فرستاده باشد مقدارش عوض میشود؛ فرمهای قدیمی
|
||||
# که این فیلد را ندارند نباید یادداشتهای پاکسازی را پاک کنند.
|
||||
if 'notes' in request.POST:
|
||||
newCs.notes = request.POST.get('notes', '')
|
||||
|
||||
# آپدیت فیلدهای قدیمی جهت حفظ سازگاری
|
||||
newCs.phone1 = valid_phones[0]['phone'] if len(valid_phones) > 0 else ''
|
||||
newCs.phone2 = valid_phones[1]['phone'] if len(valid_phones) > 1 else ''
|
||||
newCs.phone3 = valid_phones[2]['phone'] if len(valid_phones) > 2 else ''
|
||||
# آپدیت فیلدهای قدیمی جهت حفظ سازگاری (تنها وقتی شمارهای ارسال شده باشد)
|
||||
if valid_phones:
|
||||
newCs.phone1 = valid_phones[0]['phone'] if len(valid_phones) > 0 else ''
|
||||
newCs.phone2 = valid_phones[1]['phone'] if len(valid_phones) > 1 else ''
|
||||
newCs.phone3 = valid_phones[2]['phone'] if len(valid_phones) > 2 else ''
|
||||
newCs.save()
|
||||
|
||||
# بروزرسانی جدول بینهایت (حذف قبلیها و درج مجدد)
|
||||
PhoneNumber.objects.filter(customer=newCs).delete()
|
||||
for item in valid_phones:
|
||||
PhoneNumber.objects.create(customer=newCs, title=item['title'], phone_number=item['phone'])
|
||||
# بروزرسانی جدول شمارهها فقط در صورت ارسال شمارههای جدید.
|
||||
# اگر فرم شمارهای نفرستاده باشد (فیلدهای غیرفعال)، شمارههای فعلی دستنخورده میمانند.
|
||||
if valid_phones:
|
||||
PhoneNumber.objects.filter(customer=newCs).delete()
|
||||
for item in valid_phones:
|
||||
PhoneNumber.objects.create(
|
||||
customer=newCs, title=item['title'], phone_number=item['phone'],
|
||||
)
|
||||
|
||||
# اگر فرم ویرایش ردیفهای «اصلی» را مشخص کرده باشد، همانها ذخیره میشوند
|
||||
# (مبتنی بر خودِ شماره، نه ایندکس رکورد).
|
||||
if primary_indices:
|
||||
save_primary_normalized(
|
||||
newCs, [phones_list[i] for i in primary_indices if i < len(phones_list)],
|
||||
)
|
||||
|
||||
return redirect('/panel/customer/'+str(c_id))
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
+16
-3
@@ -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)
|
||||
exec gunicorn --bind 0.0.0.0:8000 khatibi.wsgi:application
|
||||
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
|
||||
@@ -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 که هنوز اجرا نشده، همهچیز کامل حفظ میشود.
|
||||
@@ -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 @@
|
||||
{"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
@@ -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._
|
||||
|
||||
@@ -63,12 +63,12 @@
|
||||
</div>
|
||||
<div id="legend"></div>
|
||||
</div>
|
||||
<div id="stats">821 nodes · 1291 edges · 93 communities</div>
|
||||
<div id="stats">1280 nodes · 2237 edges · 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'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'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) {
|
||||
|
||||
+19717
-3322
File diff suppressed because it is too large.
Load diff
+1304
-1
File diff suppressed because it is too large.
Load diff
Whitespace-only changes.
@@ -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")
|
||||
Whitespace-only changes.
@@ -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.")
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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])
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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:]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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, "خطا در ارتباط با سرویس تماس.")
|
||||
@@ -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) خوانده میشوند.
|
||||
|
||||
+3
-1
@@ -10,4 +10,6 @@ opencv-python-headless==4.9.0.80
|
||||
numpy==1.26.4
|
||||
ddddocr==1.4.11
|
||||
asgiref==3.4.1
|
||||
requests==2.31.0
|
||||
requests==2.31.0
|
||||
zeep==4.2.1
|
||||
|
||||
@@ -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 |
@@ -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 "✅ پایان."
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
Reference in new issue
Block a user