Compare commits

..
31 Commits
Author SHA1 Message Date
javad fe7a10cb61 block user in django admin 2026-10-14 12:27:55 +03:30
javad 089db0afa4 link view ticket for staff 2026-10-13 16:09:45 +03:30
javad 6b546aad76 estelam ticket, varizha , others 2026-10-13 15:48:24 +03:30
javad 35a1ec1794 fix fish variz name, dalilrad, sms, bale 2026-10-11 15:19:58 +03:30
javad 90931a5a64 fix payment UI UX 2026-10-07 14:06:14 +03:30
javad bb3ddd1610 fix payment 2026-10-07 13:39:29 +03:30
javad 4f7486152c fix resid taeid accsess 2026-10-06 15:06:35 +03:30
javad b53a3e0c5c delete option staff varizi 2026-10-06 12:30:28 +03:30
javad d99e47ecce fix UI UX 2026-10-06 12:08:16 +03:30
javad 1cbbbbd0dc fix payement link and gallery image 2026-10-06 11:18:31 +03:30
javad de3135b588 add payment_link and banks 2026-10-06 10:03:22 +03:30
javad 2ae79b1ca4 design-prototypes/chaharsu 2026-10-05 12:36:58 +03:30
javad 79fe1cddbb share ticket and ticket message 2026-10-04 16:42:19 +03:30
javad 39e64854b8 fix api contact search 2026-09-30 11:09:12 +03:30
javad 04d2bc957f api contact 2026-09-30 08:39:26 +03:30
javad 0b56766f96 fix ui ux contact log 2026-09-29 11:01:14 +03:30
javad d5c22a39dc fix customer view and edit 2026-09-29 10:30:54 +03:30
javad 81335b1439 fix bug worker 2026-09-28 22:04:53 +03:30
javad 4525ec3e6a fix button force anoncecall 2026-09-28 21:13:19 +03:30
javad 9af91941d9 button force anoncecall 2026-09-28 20:35:27 +03:30
javad c7993aae44 voip announcement 2026-09-28 19:37:19 +03:30
javad 38b1cf06df voip click2calll 2026-09-26 12:51:12 +03:30
javad cb92abbb41 add retry sms manually 2026-09-23 18:23:18 +03:30
javad 33930e972c fix ui composer 2026-09-17 11:08:16 +03:30
javad a26ae28189 fix keyboard ui 2026-09-17 10:11:18 +03:30
javad 83f4d7c728 fix ui view 2026-09-17 09:41:15 +03:30
javad 863ca89485 some fix sms retry 2026-09-16 14:45:10 +03:30
javad 0ed677e5fc some fix 2026-09-16 14:16:05 +03:30
javad 6b841dc5fa fix focus on ticket new, sitebranding, customer edit error 2026-09-16 12:44:43 +03:30
javad d72381f0ac fix pwa, wizard focus, supervisor 5 2026-09-16 11:06:38 +03:30
javad ffc1ec0dcf some improve for sms delivery 2026-09-16 10:40:21 +03:30
327 changed files with 21921 additions and 655 deletions

No files matched your search

+2
View File
@@ -45,6 +45,8 @@ SMS_MAX_RETRIES=3
SMS_RETRY_DELAY_SECONDS=180 SMS_RETRY_DELAY_SECONDS=180
SMS_DELIVERY_CHECK_DELAY_SECONDS=180 SMS_DELIVERY_CHECK_DELAY_SECONDS=180
SMS_DELIVERY_MAX_CHECKS=15 SMS_DELIVERY_MAX_CHECKS=15
SMS_DEFINITIVE_DELIVERY_RETRY_AFTER_SECONDS=2700
SMS_HISTORICAL_DELIVERY_RETRY_STAGGER_SECONDS=30
SECURE_SSL_REDIRECT=true SECURE_SSL_REDIRECT=true
SECURE_HSTS_SECONDS=31536000 SECURE_HSTS_SECONDS=31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS=true SECURE_HSTS_INCLUDE_SUBDOMAINS=true
+71 -32
View File
@@ -1,42 +1,81 @@
# Project operating rules # Project Operating & Agent Guidelines
## Development server ## 1. Codebase Navigation & Analysis (Ripwire vs. Graphify)
- Always bind the Django development server to `0.0.0.0:8000` so the project is reachable from phones and other devices through the computer's local-network IP address. Do not start it on `127.0.0.1` unless the user explicitly requests localhost-only access. > **Rule of Thumb:** Default to **Ripwire** for all hands-on coding, AST, edits, call graphs, and tests. Reserve **Graphify** strictly for high-level architectural understanding, cross-artifact relations, and non-code assets.
- Before starting or restarting Django on port `8000`, inspect the port and identify every existing listener.
- Stop the previous project server and its parent/child process tree before launching the replacement.
- After launch, verify that exactly one process is listening on port `8000`.
- Verify the listener is bound to `0.0.0.0:8000`, then fetch the site locally through `127.0.0.1:8000` as a health check.
- Fetch a changed page/static asset from the live server and confirm the expected new content is actually being served.
- When frontend assets change, remind the user to perform a hard refresh, but do not treat browser cache as the cause until the live listener and served content have been verified.
- Keep the active server session identifier and shut it down cleanly before starting another instance.
## Graphify queries CLI-only (never use MCP). Route strictly by question and artifact type. Never query both tools for the same sub-task; never execute in parallel. Always prefer `--expand` over reading entire files.
- Treat questions about this codebase as Graphify queries first whenever `graphify-out/graph.json` exists. | Metric / Dimension | **Ripwire** (Deterministic AST) | **Graphify** (Semantic KG) |
- Before querying, expand the user's Persian question against the actual vocabulary in `graphify-out/graph.json`. |---|---|---|
- Use the best combination of Persian and English terms that are present in the graph vocabulary; never rely on automatic translation, stemming, or synonyms inside Graphify. | **Primary Scope** | Local source code AST, symbol graphs, in-repo text | Cross-artifact: Code + Docs + PDFs + Media + SQL Schema |
- For code queries, prioritize the graph's English identifiers (class, function, model, template, route, and file names), because the structural code graph is predominantly English; add Persian terms when they actually exist in the graph, especially for requirements and documentation. | **Best at** | Callers, callees, impact, tests, quality delta, `--arch`, `--doc-drift` | Conceptual "How A relates to B", ADR/RFC context, schemas |
- Choose the traversal deliberately: BFS for broad relationships, DFS for dependency/data-flow traces, `path` for a shortest connection, and `explain` for one node and its neighbors. | **Primary CLI** | `ripwire . --for="…"`, `--callers=SYM`, `--impact=SYM`, `--test-gate` | `graphify query "…"`, `graphify path "A" "B"`, `graphify explain "node"` |
- Validate important Graphify findings against the referenced source code or project documentation before acting on them. | **Boundary** | Prose meaning, non-code files, cross-asset concepts | Exact symbol references, impact analysis, code test gates |
## Python package mirror ### Routing Gate & Pipeline
1. **Trivial edits (≤1 file, no exported symbols):** Modify directly without querying either tool.
2. **Execution routing:**
- Hands-on coding, symbols, AST, usages, impact, and tests → **Ripwire**.
- Architecture discovery, cross-artifact concepts (PDF, media, docs, ADR/RFC), and SQL schemas → **Graphify**. (Note: `.sql` symbols use Ripwire; schema semantics use Graphify).
- Ambiguous/Mixed tasks → Run `ripwire . --help-task="task"` to classify, then route by artifact.
3. **Graphify query rules (when selected):**
- Requires `graphify-out/graph.json`; if absent, route code questions to Ripwire.
- **Bilingual alignment:** Match terms directly against `graphify-out/graph.json` vocabulary. Prioritize English code identifiers; include Persian terms only when verified in graph documentation/requirements. Never rely on internal translation or stemming.
- **Traversal selection:** Use BFS for broad relationships, DFS for dependency traces, `path` for shortest linkage, and `explain` for node neighbors.
- Always cross-verify critical graph findings against source code before editing.
4. **Pre-edit impact gate:** Before any non-trivial code change, inspect the target symbol with both `ripwire . --impact=SYM` and `ripwire . --uses=SYM`; if either query refuses or is ambiguous, resolve the symbol using the CLI guidance before editing.
5. **Post-edit pipeline:** Run `ripwire . --test-gate` followed by `ripwire . --quality-delta`.
- Use Liara's PyPI mirror for Python dependency resolution and installation: `https://package-mirror.liara.ir/repository/pypi/simple`. ---
- Prefer the persistent user-level pip configuration over repeating `--index-url` in individual commands.
- If the mirror is temporarily unavailable or missing a required package/version, report it before using the public PyPI index as a fallback.
## Frontend and PWA cache safety ## 2. Dynamic Help, Self-Correction & Staleness
- Every locally served frontend asset in templates must include `static_asset_version` in its URL. Never stall on tool errors or hallucinate flags. Dynamic CLI help always takes precedence over memorized parameters.
- Keep the service-worker registration URL versioned with the same value and keep `updateViaCache: 'none'`; changing only the service-worker cache name is not sufficient.
- Any change to frontend caching or PWA lifecycle code must preserve the regression assertions in `tests/test_views.py` and pass `tests/e2e/test_pwa_versioning.py` with service workers enabled.
- Before blaming a user's browser cache, verify the live listener and served asset content according to the development-server rules above.
## End-to-end browser tests - **Help Precedence:** Before using an unfamiliar or ambiguous capability, inspect `ripwire --help` or `ripwire --help=--FLAG`; if any instruction or remembered syntax conflicts with that output, the CLI help is authoritative.
- **Dynamic Inspection (on error or flag confusion):**
- Ripwire: `ripwire --help`, `ripwire --help=--FLAG`, `ripwire . --doctor`, `ripwire . --help-task="task"`
- Graphify: `graphify --help`, `graphify <cmd> --help`
- **Fallback & Retries (Cap at 1):**
- If a command fails or rejects flags: inspect its `--help` and retry once.
- If a tool returns empty results or an explicit refusal: try the alternative tool once; if still unresolved, report to user.
- If data is suspected to be stale: run `graphify update .` or `ripwire . --no-cache` and retry once.
- If a tool binary is missing (`command -v` check fails): state missing dependency clearly; do not guess syntax.
- Put browser scenarios under `tests/e2e/`, mark them `e2e`, and use Django's transactional `live_server`. ---
- Use the managed Chromium and isolated BrowserContext provided by `tests/e2e/conftest.py`; do not launch browsers directly inside tests. Keep Playwright function-scoped because a session-scoped sync Playwright event loop conflicts with Django's synchronous test-database setup.
- Keep selectors and reusable browser actions in `tests/e2e/pages/` Page Objects, while tests remain focused on user-visible outcomes. ## 3. Git Operations & Safety
- Never use fixed sleeps for synchronization. Use Playwright locators, `expect`, navigation, response, or application-state waits.
- Preserve automatic screenshot and trace capture on failure. PWA scenarios must explicitly enable service workers and carry the `pwa` marker. - **Read-only (Agent allowed):** `git status`, `git diff`, `git log`.
- **Human-gated (User execution only):** `git add`, `commit`, `push`, `checkout`, `merge`, `rebase`, and branch management. If needed, request user action and pause.
- **Exception:** `graphify hook install` is permitted (read-side only).
---
## 4. Development Server Lifecycle
- **Binding:** Always bind Django to `0.0.0.0:8000` (LAN accessible). Never use `127.0.0.1` unless explicitly requested.
- **Process Management:** Before launching, inspect port `8000`. Terminate previous project server instances and their child process trees cleanly.
- **Health Verification:** Confirm exactly one process listens on `0.0.0.0:8000`, then fetch `127.0.0.1:8000` locally to verify health.
- **Asset Confirmation:** When checking frontend changes, verify the served content on the live server before suggesting browser cache issues to the user.
---
## 5. Environment, Frontend & PWA Safety
- **Python virtual environment:** Use the repository `.venv` for all Python and Django commands during development, tests, dependency management, and server startup. From WSL, run `.venv/Scripts/python.exe`; from Windows, run `.venv\Scripts\python.exe`. Run pip with `<venv-python> -m pip` and Django with `<venv-python> manage.py ...`. Do not use system Python or pip or create a second environment unless the user requests it.
- **PyPI Mirror:** Use Liara's mirror: `https://package-mirror.liara.ir/repository/pypi/simple`. Use user-level pip configuration. Report downtime before falling back to public PyPI.
- **Asset Versioning:** All locally served frontend assets must include `static_asset_version` in their URLs.
- **PWA Lifecycle:** Keep the service worker registration URL versioned with `updateViaCache: 'none'`.
- **Cache Regression Gate:** Changes to frontend caching or PWA code must pass assertions in `tests/test_views.py` and `tests/e2e/test_pwa_versioning.py`.
---
## 6. End-to-End Browser Tests (Playwright)
- **Organization:** Place tests in `tests/e2e/`, mark with `e2e`, and run against Django's transactional `live_server`.
- **Scope & Lifecycle:** Use managed Chromium and BrowserContext from `tests/e2e/conftest.py`. Keep Playwright function-scoped to prevent event loop deadlocks with Django's synchronous database setup.
- **Page Objects:** Encapsulate interactions inside `tests/e2e/pages/`. Tests must assert user-visible outcomes only.
- **Synchronization:** Never use hardcoded sleeps. Rely on Playwright locators, `expect`, navigation, and state-change assertions.
- **Diagnostics:** Maintain automatic screenshot/trace capture on failure. PWA scenarios must enable service workers and use the `pwa` marker.
+179
View File
@@ -0,0 +1,179 @@
# اتصال مخاطبان CRM و سرویس‌های بیرونی به Phone
این راهنما هم برای پیاده‌سازی endpoint جست‌وجو در CRM است و هم برای تنظیم آن در `/x5admin` ← «منابع بیرونی». Phone هنگام جست‌وجو از سرور خود به API درخواست می‌فرستد، پاسخ JSON را به نام و شماره‌های قابل تماس تبدیل می‌کند و نتیجه را در «مخاطبان» نشان می‌دهد. مخاطب بیرونی به دفترچهٔ محلی کپی نمی‌شود.
## قرارداد پیشنهادی برای تیم CRM
ساده‌ترین قرارداد، یک endpoint خواندنی با پاسخ JSON است:
```http
GET /api/phone-contacts?search=%D8%B9%D9%84%DB%8C&limit=50 HTTP/1.1
Host: crm.example.com
Authorization: Bearer <service-token>
Accept: application/json
```
```json
{
"contacts": [
{
"name": "علی رضایی",
"phones": [
{"type": "همراه", "number": "09123456789"},
{"type": "دفتر", "number": "02112345678"}
]
}
]
}
```
CRM برای نبود نتیجه `{"contacts":[]}` با کد موفقیت `2xx` برگرداند. برای احراز هویت نامعتبر `401/403` و برای خطای سرویس کد `5xx` مناسب است. پاسخ باید JSON معتبر باشد؛ Phone متن یا XML را تفسیر نمی‌کند. CRM باید نتایج مرتبط را در **همان پاسخ اول** برگرداند، چون Phone صفحه‌بندی API بیرونی را دنبال نمی‌کند.
| ویژگی پاسخ | رفتار Phone |
| --- | --- |
| نام | متن غیرخالی؛ فاصلهٔ ابتدا و انتها حذف و طول نمایش به ۱۲۰ نویسه محدود می‌شود. |
| شماره | به متن تبدیل می‌شود؛ فاصله، پرانتز، نقطه و خط تیره حذف و رقم‌های فارسی/عربی لاتین می‌شوند. حاصل باید فقط رقم، `+`، `*` یا `#` و حداکثر ۶۴ نویسه باشد. |
| نتیجهٔ بدون شمارهٔ معتبر | نمایش داده نمی‌شود. شماره‌های تکراری یک مخاطب فقط یک‌بار نمایش داده می‌شوند. |
| سقف پردازش | ۵۰ مورد نخست هر پاسخ، تا ۵ شماره برای هر مخاطب، حداکثر ۱ MiB برای کل پاسخ. |
| زمان پاسخ | مهلت هر منبع در پنل ۱ تا ۸ ثانیه است؛ مقدار پیش‌فرض ۵ ثانیه. |
شناسهٔ مخاطب CRM برای این قابلیت لازم نیست. Phone شمارهٔ `0912...` را خودکار به `+98912...` تبدیل نمی‌کند؛ CRM باید شماره را در قالب قابل شماره‌گیری PBX بدهد.
## نمونهٔ پیاده‌سازی endpoint در CRM
قرارداد بالا به زبان یا فریم‌ورک خاصی وابسته نیست. منطق endpoint می‌تواند چنین باشد:
```text
GET /api/phone-contacts?search=<text>&limit=50
1. Authorization: Bearer <service-token> را بررسی کن.
2. متن search را trim و طول آن را محدود کن؛ برای متن کوتاه‌تر از ۲ نویسه نتیجهٔ خالی بده.
3. جست‌وجو را با query پارامتری در دیتابیس CRM اجرا کن؛ رکوردهایی را که نباید
برای کاربران Phone قابل مشاهده باشند، همین‌جا حذف کن.
4. نام و آرایهٔ شماره‌ها را در JSON قرارداد بالا برگردان؛ حداکثر ۵۰ نتیجه.
```
اگر در SQL از `LIKE` استفاده می‌کنید، مقدار ورودی را مستقیم وارد متن SQL نکنید؛ پارامتر bind کنید و اگر `%` و `_` نباید wildcard باشند، آن‌ها را مطابق موتور دیتابیس escape کنید. جست‌وجوی نام فارسی، شکل‌های متفاوت «ی/ي» و «ک/ك» و ترتیب نتایج را در CRM حل کنید؛ Phone فقط رشتهٔ واردشده را ارسال می‌کند. برای کارایی، ایندکس و حد نتیجه در CRM بگذارید. اگر API موجود ساختار دیگری دارد، به کمک نگاشت پنل آن را تطبیق دهید یا یک endpoint واسط JSON بسازید.
## تنظیم قدم‌به‌قدم در x5admin
1. در `/x5admin`، «منابع بیرونی» ← «افزودن منبع» را باز کنید. ابتدا گزینهٔ «فعال برای جست‌وجوی کاربران» را خاموش نگه دارید.
2. «نام منبع» یکتا و ۲ تا ۸۰ نویسه، مانند «CRM فروش»، تعیین کنید. این نام کنار نتایج دیده می‌شود.
3. روش `GET` یا `POST` و «آدرس ثابت API» را وارد کنید. آدرس باید کامل و به‌طور پیش‌فرض `https://` باشد؛ `{{query}}` را در خود آدرس نگذارید.
4. «پارامترهای URL»، «هدرها» و «بدنهٔ POST» را به شکل **شیء JSON معتبر** وارد کنید؛ مقدار خالی `{}` است. دست‌کم یک مقدار در پارامترها یا بدنه باید `{{query}}` داشته باشد.
5. برای `GET`، بدنه را `{}` بگذارید. برای `POST`، فرمت `JSON` یا `فرم` را مطابق CRM انتخاب کنید. پارامترهای URL در هر دو روش قابل استفاده‌اند.
6. «مسیر فهرست»، «مسیر نام مخاطب» و «نگاشت شماره‌ها» را از روی نمونهٔ **پاسخ واقعی** CRM تکمیل کنید. برای قرارداد ابتدای راهنما: `contacts`، `name` و `[{"path":"phones[]","value_path":"number","label_path":"type"}]`.
7. منبع را ذخیره کنید. برای آزمون واقعی، آن را فعال کنید و در `/phone` نام شناخته‌شده‌ای را جست‌وجو کنید. فعال‌سازی بلافاصله اعمال می‌شود و راه‌اندازی دوباره لازم ندارد.
در نسخهٔ فعلی، پنل دکمهٔ «آزمایش درخواست» ندارد؛ ذخیرهٔ منبع غیرفعال، درخواستی به CRM نمی‌فرستد. برای بررسی مستقل، درخواست نمونه را با `curl` از **میزبان اجرای Phone** به CRM بفرستید. نمونه در بخش اعتبارنامه آمده است.
### معنی فیلدهای درخواست
| فیلد پنل | نمونه | توضیح |
| --- | --- | --- |
| آدرس ثابت API | `https://crm.example.com/api/phone-contacts` | مقصد ثابت؛ جای‌نگهدار جست‌وجو/رمز، نام کاربری یا رمز داخل URL و fragment پذیرفته نمی‌شود. |
| روش درخواست | `GET` / `POST` | سایر روش‌ها پشتیبانی نمی‌شوند. |
| پارامترهای URL | `{"search":"{{query}}","limit":50}` | به query string اضافه و توسط HTTP client کدگذاری می‌شوند. |
| هدرها | `{"Authorization":"Bearer {{env:PHONE_EXTERNAL_CRM_TOKEN}}"}` | هدرهای ثابت یا ارجاع به متغیر محیطی؛ `{{query}}` در هدر مجاز نیست. |
| بدنهٔ POST | `{"filter":{"name":"{{query}}"}}` | فقط برای POST؛ فرمت JSON یا فرم در فیلد بعدی تعیین می‌شود. |
| فرمت بدنه | `JSON` / `فرم` | JSON برای دادهٔ تو در تو؛ فرم برای فیلدهای سادهٔ `application/x-www-form-urlencoded`. |
| زمان انتظار | `5` | عدد ۱ تا ۸، از جمله مقدار اعشاری مانند `2.5`. |
| اجازهٔ HTTP | خاموش | فقط برای مقصد `http://` در شبکهٔ مورد اعتماد؛ ارتباط رمزگذاری نمی‌شود. |
`{{query}}` در مقدارهای تو در توی پارامترها یا بدنه نیز جایگزین می‌شود. برای مثال `"%{{query}}%"` زمانی مفید است که خود CRM الگوی دارای `%` بخواهد. کلیدهای JSON جایگزین نمی‌شوند. Phone از امضای پویا، SOAP/XML، OAuth با refresh خودکار، GraphQL اختصاصی یا صفحه‌بندی خودکار پشتیبانی نمی‌کند؛ برای چنین قراردادهایی یک API واسط JSON بسازید.
## نگاشت پاسخ JSON
مسیر از نام کلیدهای JSON و نقطه تشکیل می‌شود؛ `[]` روی یک کلید، عضوهای آرایهٔ آن کلید را باز می‌کند. نام کلید در مسیر باید با حرف لاتین یا `_` شروع شود و سپس فقط حرف، رقم یا `_` داشته باشد. نحوهایی مانند `$.data`، `contacts[0]`، شرط‌های JSONPath و کلیدهای دارای خط تیره پشتیبانی نمی‌شوند.
| پاسخ CRM | مسیر فهرست | مسیر نام | نگاشت شماره‌ها |
| --- | --- | --- | --- |
| `[{"name":"علی","mobile":"0912..."}]` | خالی | `name` | `[{"path":"mobile","label":"همراه"}]` |
| `{"data":{"contacts":[{"full_name":"علی","mobile":"0912..."}]}}` | `data.contacts` | `full_name` | `[{"path":"mobile","label":"همراه"}]` |
| `{"data":{"contacts":[{"name":"علی","phones":[{"type":"همراه","number":"0912..."}]}]}}` | `data.contacts` | `name` | `[{"path":"phones[]","value_path":"number","label_path":"type"}]` |
| `{"results":[{"person":{"name":"علی"},"mobile":"0912...","office":"021..."}]}` | `results` | `person.name` | `[{"path":"mobile","label":"همراه"},{"path":"office","label":"دفتر"}]` |
«مسیر فهرست» نسبت به ریشهٔ پاسخ است. «مسیر نام» و هر `path` شماره نسبت به **هر عضو فهرست** هستند. `value_path` و `label_path` نسبت به مقدار یافته‌شده در همان `path` هستند. مثلاً در `phones[]` هر عضو یک شیء است؛ `value_path=number` شماره و `label_path=type` برچسب را می‌خواند. اگر `label_path` مقدار نداشته باشد، `label` ثابت استفاده می‌شود و در نبود آن برچسب «شماره» است. باید ۱ تا ۸ نگاشت شماره تعریف کنید.
نمونهٔ کامل دیگر:
```json
{
"data": {
"people": [
{
"profile": {"display_name": "مریم احمدی"},
"phones": [
{"kind": "همراه", "value": "+989121234567"},
{"kind": "داخلی", "value": "102"}
]
}
]
}
}
```
تنظیم متناظر: مسیر فهرست `data.people`، مسیر نام `profile.display_name` و نگاشت `[{"path":"phones[]","value_path":"value","label_path":"kind"}]`.
## نمونهٔ POST
اگر CRM جست‌وجو را به صورت JSON می‌پذیرد، روش را `POST`، پارامترهای URL را `{}`، فرمت را `JSON` و بدنه را به شکل زیر بگذارید:
```json
{"filter":{"name":"{{query}}"},"page_size":50}
```
این تنظیم برای جست‌وجوی «علی» بدنهٔ `{"filter":{"name":"علی"},"page_size":50}` می‌فرستد. اگر CRM فرم ساده می‌خواهد، فرمت `فرم` و بدنه را مثلاً `{"term":"{{query}}","limit":"50"}` بگذارید. برای بدنهٔ تو در تو از JSON استفاده کنید؛ نحوهٔ تفسیر فرم تو در تو به CRM بستگی دارد.
## اعتبارنامه و هدرها
رمز API را در `.env` پروژه یا محیط process اجرای Phone با پیشوند `PHONE_EXTERNAL_` قرار دهید:
```dotenv
PHONE_EXTERNAL_CRM_TOKEN=replace-with-a-long-random-token
```
سپس در فیلد هدرها یکی از الگوهای زیر را وارد کنید:
```json
{"Authorization":"Bearer {{env:PHONE_EXTERNAL_CRM_TOKEN}}","Accept":"application/json"}
```
```json
{"X-API-Key":"{{env:PHONE_EXTERNAL_CRM_TOKEN}}","Accept":"application/json"}
```
برای `Authorization`، `X-API-Key`، `Cookie` و `Proxy-Authorization` مقدار خام رمز پذیرفته نمی‌شود؛ ارجاع کامل به متغیر `PHONE_EXTERNAL_` لازم است. در `Authorization` پیشوند `Bearer ` یا `Basic ` نیز مجاز است. Phone ارجاع را هنگام هر درخواست، ابتدا از محیط process و سپس از `.env` کنار پروژه می‌خواند. تغییر متغیر محیط process نیازمند راه‌اندازی دوبارهٔ سرویس است؛ تغییر فایل `.env` برای درخواست بعدی خوانده می‌شود. توکن واقعی را در URL، نام منبع، تصویر پنل یا مستندات قرار ندهید.
نمونهٔ بررسی از میزبان Phone، با توکنی که از قبل در محیط همان shell موجود است:
```bash
curl --fail-with-body --get 'https://crm.example.com/api/phone-contacts' \
--data-urlencode 'search=علی' \
--data-urlencode 'limit=50' \
-H "Authorization: Bearer ${PHONE_EXTERNAL_CRM_TOKEN}" \
-H 'Accept: application/json'
```
اگر توکن در shell موجود نیست، آن را از مسیر امن سازمانی دریافت کنید. همچنین از میزبان Phone دسترسی DNS، TLS و شبکه به CRM را بررسی کنید. Phone تغییر مسیر HTTP را دنبال نمی‌کند و proxyهای محیطی را برای این درخواست‌ها به کار نمی‌گیرد.
## دسترسی کاربران و جریان تماس
1. کاربر وارد `/phone` می‌شود و در «مخاطبان» دست‌کم دو نویسه می‌نویسد. درخواست جست‌وجو حداکثر ۸۰ نویسه می‌پذیرد؛ Phone فاصلهٔ دو سر متن را حذف می‌کند و آن را به **همهٔ منابع فعال** می‌فرستد.
2. هر نتیجه نام، نام منبع و تا پنج گزینهٔ شماره دارد. اگر «مشاهدهٔ شمارهٔ مخاطبان» برای کاربر خاموش باشد، مقدار شماره در پاسخ Phone فرستاده نمی‌شود؛ گزینه‌هایی مثل «شمارهٔ ۱» همچنان قابل تماس‌اند.
3. Phone برای هر نتیجه شناسهٔ تصادفی موقت و وابسته به همان کاربر می‌سازد. این شناسه ۱۰ دقیقه معتبر است و شماره را در خود ندارد. هنگام تماس، مجوز «اجازهٔ تماس» و داشتن همراه یا داخلی تخصیص‌یافتهٔ کاربر بررسی می‌شود.
4. تماس از منطق click2call موجود انجام می‌شود. ویرایش، غیرفعال‌سازی یا حذف منبع، شناسه‌های تماس قبلی آن را باطل می‌کند؛ کاربر باید دوباره جست‌وجو کند.
**مرز دسترسی CRM:** درخواست بیرونی با اعتبارنامهٔ خود منبع انجام می‌شود؛ در این نسخه شناسه، نقش یا توکن کاربر Phone به CRM فرستاده نمی‌شود. بنابراین هر نامی که با آن اعتبارنامه قابل جست‌وجو باشد، برای همهٔ کاربران واردشدهٔ Phone قابل مشاهده است. مجوز «مشاهدهٔ شمارهٔ مخاطبان» فقط شماره را در پاسخ Phone پنهان می‌کند؛ جلوی ارسال متن دلخواه جست‌وجو یا تطبیق احتمالی شماره توسط CRM را نمی‌گیرد. اگر CRM مخاطبان خصوصی یا سیاست‌های جداگانه برای کاربران دارد، endpoint یا اعتبارنامهٔ محدود مخصوص Phone بسازید و خروجی آن را در CRM محدود کنید.
## محدودیت‌ها و عیب‌یابی
| نشانه | بررسی |
| --- | --- |
| منبع ذخیره نمی‌شود و خطای `422` می‌آید | JSON هر فیلد معتبر باشد؛ `{{query}}` در پارامتر یا بدنه باشد؛ مسیر نام و نگاشت شماره تعریف شوند؛ برای GET بدنه `{}` باشد؛ آدرس HTTPS یا گزینهٔ اجازهٔ HTTP فعال باشد. |
| «موردی پیدا نشد» با وجود نتیجه در CRM | نمونهٔ پاسخ واقعی را ببینید؛ مسیر فهرست، نام و شماره را نسبت به محل درست تنظیم کنید. نام خالی یا همهٔ شماره‌های نامعتبر باعث حذف نتیجه می‌شوند. |
| «برخی منابع پاسخ ندادند» | اتصال DNS/TLS، کد HTTP، اعتبارنامه، زمان انتظار، JSON معتبر و سقف ۱ MiB را از سرور Phone بررسی کنید. خطای یک منبع، نتیجهٔ منابع سالم را حذف نمی‌کند. |
| پس از ویرایش منبع تماس قبلی برقرار نمی‌شود | شناسه‌های موقت با هر ویرایش باطل می‌شوند؛ دوباره جست‌وجو کنید. |
| شماره برای یک کاربر دیده نمی‌شود | مجوز «مشاهدهٔ شمارهٔ مخاطبان» همان کاربر را بررسی کنید. پنهان‌بودن شماره مانع تماس از دکمهٔ نتیجه نیست. |
| دکمهٔ تماس کار نمی‌کند | مجوز «اجازهٔ تماس»، همراه/داخلی تخصیص‌یافته، فعال‌بودن منبع و اعتبار ۱۰ دقیقه‌ای شناسه را بررسی کنید. |
حداکثر ۲۰ منبع تعریف می‌شود و حداکثر چهار درخواست بیرونی هم‌زمان اجرا می‌شود. دادهٔ پاسخ در دفترچهٔ محلی ذخیره نمی‌شود؛ فقط شماره‌های لازم برای شناسه‌های تماس موقت در SQLite نگهداری می‌شوند. شناسه پس از ۱۰ دقیقه قابل استفاده نیست و ردیف منقضی‌شده هنگام ایجاد شناسه‌های بعدی پاک می‌شود.
+990 -163
View File
File diff suppressed because it is too large. Load diff
+36
View File
@@ -0,0 +1,36 @@
def generate_id_algorithm(id_code: str) -> str:
"""
Calculates the verification code based on the reverse-engineered logic:
1. Iterate digits from right to left (index i = 0, 1, 2...).
2. Weights:
- If i < 8: Weight = 8 - i (Sequence: 8, 7, 6, 5, 4, 3, 2, 1)
- If i >= 8: Weight = i + 1 (Sequence: 9, 10, ...)
3. Sum = Sum(Digit * Weight)
4. Check Digit = Sum % 99
"""
# تمیزکاری ورودی (حذف فاصله‌ها و اطمینان از عددی بودن)
if not id_code.isdigit():
return "Error: ورودی باید فقط عدد باشد."
total_sum = 0
# پیمایش از راست به چپ
# reversed(id_code) ارقام را برعکس می‌کند
for i, char in enumerate(reversed(id_code)):
digit = int(char)
# تعیین وزن بر اساس ایندکس (مکان رقم از راست)
if i < 8:
weight = 8 - i
else:
weight = i + 1
total_sum += digit * weight
# محاسبه باقی‌مانده بر 99
check_digit = total_sum % 99
# فرمت‌دهی دو رقمی (مثلاً 6 می‌شود 06)
check_digit_str = f"{check_digit:02}"
return f"{id_code}{check_digit_str}"
+4 -4
View File
@@ -58,10 +58,10 @@ class StaffProfileInline(admin.StackedInline):
@admin.register(User) @admin.register(User)
class CustomUserAdmin(UserAdmin): class CustomUserAdmin(UserAdmin):
fieldsets = UserAdmin.fieldsets + (("سامانه تیکت", {"fields": ("mobile", "account_type")}),) fieldsets = UserAdmin.fieldsets + (("سامانه تیکت", {"fields": ("mobile", "account_type", "voip_extension", "voip_mobile", "voip_call")}),)
add_fieldsets = UserAdmin.add_fieldsets + (("سامانه تیکت", {"fields": ("mobile", "account_type")}),) add_fieldsets = UserAdmin.add_fieldsets + (("سامانه تیکت", {"fields": ("mobile", "account_type", "voip_extension", "voip_mobile", "voip_call")}),)
list_display = ("username", "mobile", "account_type", "is_active", "is_staff") list_display = ("username", "mobile", "voip_extension", "voip_mobile", "voip_call", "account_type", "is_active", "is_staff")
list_filter = UserAdmin.list_filter + ("account_type",) list_filter = UserAdmin.list_filter + ("account_type", "voip_call")
inlines = (StaffProfileInline,) inlines = (StaffProfileInline,)
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
+3
View File
@@ -2,3 +2,6 @@ from django.apps import AppConfig
class AccountsConfig(AppConfig): class AccountsConfig(AppConfig):
name = "apps.accounts" name = "apps.accounts"
def ready(self):
from . import customer_access # Register the login-session receiver.
+81
View File
@@ -0,0 +1,81 @@
"""Restrictions for full customer logins; staff and scoped links stay independent."""
from django.contrib import messages
from django.contrib.auth import logout
from django.contrib.auth.signals import user_logged_in
from django.dispatch import receiver
from django.http import JsonResponse
from django.shortcuts import redirect
from django.urls import reverse
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_GET
from django.utils.cache import add_never_cache_headers
from apps.customers.models import CustomerProfile
from .models import User
ACCESS_VERSION_KEY = "customer_login_access_version"
BLOCK_REASON_KEY = "customer_login_block_reason"
DEFAULT_BLOCK_REASON = "ورود شما مسدود شده است. برای پیگیری با پشتیبانی تماس بگیرید."
def is_customer_login(user):
return user.account_type == User.AccountType.CUSTOMER and not (user.is_staff or user.is_superuser)
def customer_login_profile(user):
if not is_customer_login(user):
return None
return CustomerProfile.objects.filter(user_id=user.pk).first()
def blocked_login_reason(mobile):
user = User.objects.filter(mobile=mobile).first()
profile = customer_login_profile(user) if user else None
if profile and profile.login_blocked:
return profile.login_block_reason.strip() or DEFAULT_BLOCK_REASON
return ""
@receiver(user_logged_in, dispatch_uid="customer_login_access_version")
def remember_customer_access(sender, request, user, **kwargs):
if not is_customer_login(user):
return
profile = customer_login_profile(user)
request.session[ACCESS_VERSION_KEY] = profile.login_access_version if profile else 0
request.session.pop(BLOCK_REASON_KEY, None)
class CustomerLoginAccessMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.user.is_authenticated:
profile = customer_login_profile(request.user)
if profile and (
profile.login_blocked
or request.session.get(ACCESS_VERSION_KEY, 0) != profile.login_access_version
):
logout(request)
if profile.login_blocked:
request.session[BLOCK_REASON_KEY] = profile.login_block_reason.strip() or DEFAULT_BLOCK_REASON
else:
messages.error(request, "نشست قبلی شما باطل شده است. لطفاً دوباره وارد شوید.")
if request.path == reverse("accounts:login_access"):
response = JsonResponse({"authenticated": False, "login_url": reverse("accounts:login")})
else:
response = redirect("accounts:login")
add_never_cache_headers(response)
return response
return self.get_response(request)
@never_cache
@require_GET
def customer_login_access(request):
return JsonResponse({
"authenticated": request.user.is_authenticated,
"login_url": reverse("accounts:login"),
})
+2 -4
View File
@@ -1,4 +1,5 @@
from django.utils import timezone from django.utils import timezone
from apps.core.session_access import grant_scoped_session_access
from .models import TicketMagicLink from .models import TicketMagicLink
@@ -8,10 +9,7 @@ MAGIC_LINK_SESSION_KEY = "ticket_magic_link_id"
def grant_ticket_magic_access(request, link): def grant_ticket_magic_access(request, link):
"""Grant an anonymous session access to exactly one ticket until link expiry.""" """Grant an anonymous session access to exactly one ticket until link expiry."""
request.session.cycle_key() grant_scoped_session_access(request, MAGIC_LINK_SESSION_KEY, link.pk, link.expires_at)
request.session[MAGIC_LINK_SESSION_KEY] = link.pk
remaining = max(1, int((link.expires_at - timezone.now()).total_seconds()))
request.session.set_expiry(remaining)
def get_ticket_magic_access(request, ticket=None): def get_ticket_magic_access(request, ticket=None):
@@ -0,0 +1,23 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("accounts", "0005_magiclinkaccesslog_magic_opened_idx_and_more")]
operations = [
migrations.AddField(
model_name="user",
name="voip_call",
field=models.BooleanField(default=True, verbose_name="امکان تماس VOIP"),
),
migrations.AddField(
model_name="user",
name="voip_extension",
field=models.CharField(blank=True, max_length=20, verbose_name="شماره داخلی کارشناس"),
),
migrations.AlterField(
model_name="user",
name="mobile",
field=models.CharField(blank=True, max_length=16, null=True, unique=True, verbose_name="شماره موبایل"),
),
]
@@ -0,0 +1,42 @@
from django.db import migrations, models
def copy_staff_mobile_to_voip(apps, schema_editor):
User = apps.get_model("accounts", "User")
User.objects.filter(account_type="STAFF", voip_mobile="", mobile__isnull=False).exclude(mobile="").update(
voip_mobile=models.F("mobile")
)
def clear_copied_voip_mobile(apps, schema_editor):
User = apps.get_model("accounts", "User")
User.objects.filter(account_type="STAFF", voip_mobile=models.F("mobile")).update(voip_mobile="")
class Migration(migrations.Migration):
dependencies = [("accounts", "0006_user_voip_call_user_voip_extension_and_more")]
operations = [
migrations.AddField(
model_name="user",
name="voip_mobile",
field=models.CharField(
blank=True,
help_text="مستقل از شماره حساب مشتری است و می‌تواند تکراری باشد.",
max_length=16,
verbose_name="شماره موبایل کارشناس برای VOIP",
),
),
migrations.AlterField(
model_name="user",
name="mobile",
field=models.CharField(
blank=True,
max_length=16,
null=True,
unique=True,
verbose_name="شماره موبایل حساب",
),
),
migrations.RunPython(copy_staff_mobile_to_voip, clear_copied_voip_mobile),
]
+13 -1
View File
@@ -19,14 +19,26 @@ class User(PublicIdModel, AbstractUser):
class AccountType(models.TextChoices): class AccountType(models.TextChoices):
CUSTOMER = "CUSTOMER", "مشتری" CUSTOMER = "CUSTOMER", "مشتری"
STAFF = "STAFF", "کارشناس" STAFF = "STAFF", "کارشناس"
mobile = models.CharField(max_length=16, unique=True, null=True, blank=True) mobile = models.CharField("شماره موبایل حساب", max_length=16, unique=True, null=True, blank=True)
account_type = models.CharField(max_length=10, choices=AccountType.choices, default=AccountType.CUSTOMER) account_type = models.CharField(max_length=10, choices=AccountType.choices, default=AccountType.CUSTOMER)
voip_extension = models.CharField("شماره داخلی کارشناس", max_length=20, blank=True)
voip_mobile = models.CharField(
"شماره موبایل کارشناس برای VOIP",
max_length=16,
blank=True,
help_text="مستقل از شماره حساب مشتری است و می‌تواند تکراری باشد.",
)
voip_call = models.BooleanField("امکان تماس VOIP", default=True)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.mobile: self.mobile = normalize_mobile(self.mobile) if self.mobile: self.mobile = normalize_mobile(self.mobile)
if self.voip_mobile: self.voip_mobile = normalize_mobile(self.voip_mobile)
super().save(*args, **kwargs) super().save(*args, **kwargs)
@property @property
def local_mobile(self): def local_mobile(self):
return f"0{self.mobile[3:]}" if self.mobile and self.mobile.startswith("+98") else (self.mobile or "") return f"0{self.mobile[3:]}" if self.mobile and self.mobile.startswith("+98") else (self.mobile or "")
@property
def voip_local_mobile(self):
return f"0{self.voip_mobile[3:]}" if self.voip_mobile.startswith("+98") else self.voip_mobile
class OTPChallenge(models.Model): class OTPChallenge(models.Model):
class Purpose(models.TextChoices): LOGIN = "LOGIN", "ورود" class Purpose(models.TextChoices): LOGIN = "LOGIN", "ورود"
+2
View File
@@ -1,9 +1,11 @@
from django.urls import path from django.urls import path
from . import views from . import views
from .customer_access import customer_login_access
app_name = "accounts" app_name = "accounts"
urlpatterns = [ urlpatterns = [
path("login/access/", customer_login_access, name="login_access"),
path("", views.home, name="home"), path("", views.home, name="home"),
path("login/", views.customer_login, name="login"), path("login/", views.customer_login, name="login"),
path("login/password/", views.customer_password_login, name="password_login"), path("login/password/", views.customer_password_login, name="password_login"),
+72 -13
View File
@@ -2,8 +2,11 @@ from django.contrib import messages
from django.conf import settings from django.conf import settings
from django.contrib.auth import login, logout from django.contrib.auth import login, logout
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils.http import url_has_allowed_host_and_scheme
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.views.decorators.http import require_GET, require_http_methods, require_POST from django.views.decorators.http import require_GET, require_http_methods, require_POST
from django.views.decorators.cache import never_cache
from .forms import CustomerPasswordLoginForm, CustomerProfileForm, MobileForm, OTPForm, StaffLoginForm from .forms import CustomerPasswordLoginForm, CustomerProfileForm, MobileForm, OTPForm, StaffLoginForm
from apps.customers.models import CustomerProfile from apps.customers.models import CustomerProfile
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
@@ -11,14 +14,28 @@ from django.db import transaction
from django.db.models import F from django.db.models import F
from .models import MagicLinkAccessLog, OTPChallenge, TicketMagicLink, User from .models import MagicLinkAccessLog, OTPChallenge, TicketMagicLink, User
from .magic_access import grant_ticket_magic_access from .magic_access import grant_ticket_magic_access
from .customer_access import BLOCK_REASON_KEY, blocked_login_reason
from apps.integrations.sms.providers import SMSResult, get_sms_provider from apps.integrations.sms.providers import SMSResult, get_sms_provider
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _remember_destination(request):
target = request.GET.get("next", "")
if target and target.startswith(("/chaharsu/", "/tickets/")) and url_has_allowed_host_and_scheme(target, allowed_hosts={request.get_host()}, require_https=request.is_secure()):
request.session["next"] = target
def _login_destination(request):
target = request.session.pop("next", "")
if target.startswith(("/chaharsu/", "/tickets/")) and url_has_allowed_host_and_scheme(target, allowed_hosts={request.get_host()}, require_https=request.is_secure()):
return target
return "staff_tickets:list" if request.user.account_type == User.AccountType.STAFF else "payments:home"
def _authenticated_home(request): def _authenticated_home(request):
if not request.user.is_authenticated: return None if not request.user.is_authenticated: return None
return redirect("staff_tickets:list" if request.user.account_type==User.AccountType.STAFF else "tickets:list") return redirect("staff_tickets:list" if request.user.account_type==User.AccountType.STAFF else "payments:home")
@require_GET @require_GET
def home(request): def home(request):
@@ -27,9 +44,14 @@ def home(request):
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def customer_login(request): def customer_login(request):
_remember_destination(request)
if response:=_authenticated_home(request): return response if response:=_authenticated_home(request): return response
form = MobileForm(request.POST or None) form = MobileForm(request.POST or None)
block_reason = request.session.pop(BLOCK_REASON_KEY, "")
if request.method == "POST" and form.is_valid(): if request.method == "POST" and form.is_valid():
block_reason = blocked_login_reason(form.cleaned_data["mobile"])
if block_reason:
return render(request, "accounts/customer_login.html", {"form": form, "block_reason": block_reason})
challenge = None challenge = None
try: try:
challenge, code = OTPChallenge.issue(form.cleaned_data["mobile"], request) challenge, code = OTPChallenge.issue(form.cleaned_data["mobile"], request)
@@ -45,13 +67,18 @@ def customer_login(request):
challenge.delete() challenge.delete()
logger.exception("OTP SMS delivery failed") logger.exception("OTP SMS delivery failed")
form.add_error(None, "ارسال پیامک ورود در حال حاضر ممکن نیست؛ لطفاً کمی بعد دوباره تلاش کنید.") form.add_error(None, "ارسال پیامک ورود در حال حاضر ممکن نیست؛ لطفاً کمی بعد دوباره تلاش کنید.")
return render(request, "accounts/customer_login.html", {"form": form}) return render(request, "accounts/customer_login.html", {"form": form, "block_reason": block_reason})
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def verify_otp(request): def verify_otp(request):
if response:=_authenticated_home(request): return response if response:=_authenticated_home(request): return response
challenge = get_object_or_404(OTPChallenge, pk=request.session.get("otp_challenge_id")) challenge = get_object_or_404(OTPChallenge, pk=request.session.get("otp_challenge_id"))
form = OTPForm(request.POST or None) form = OTPForm(request.POST or None)
block_reason = blocked_login_reason(challenge.mobile)
if block_reason:
request.session.pop("otp_challenge_id", None)
request.session[BLOCK_REASON_KEY] = block_reason
return redirect("accounts:login")
if request.method == "POST" and form.is_valid(): if request.method == "POST" and form.is_valid():
if challenge.verify(form.cleaned_data["code"]): if challenge.verify(form.cleaned_data["code"]):
user, created = User.objects.get_or_create(mobile=challenge.mobile, defaults={"username": f"customer_{challenge.mobile[3:]}", "account_type": User.AccountType.CUSTOMER}) user, created = User.objects.get_or_create(mobile=challenge.mobile, defaults={"username": f"customer_{challenge.mobile[3:]}", "account_type": User.AccountType.CUSTOMER})
@@ -60,20 +87,25 @@ def verify_otp(request):
if not user.is_active: form.add_error(None, "حساب شما غیرفعال است.") if not user.is_active: form.add_error(None, "حساب شما غیرفعال است.")
else: else:
login(request, user, backend="django.contrib.auth.backends.ModelBackend") login(request, user, backend="django.contrib.auth.backends.ModelBackend")
return redirect(request.session.pop("next", "tickets:list")) return redirect(_login_destination(request))
else: form.add_error("code", "کد نامعتبر یا منقضی است.") else: form.add_error("code", "کد نامعتبر یا منقضی است.")
return render(request, "accounts/otp.html", {"form": form, "mobile": challenge.mobile}) return render(request, "accounts/otp.html", {"form": form, "mobile": challenge.mobile})
@require_http_methods(["GET","POST"]) @require_http_methods(["GET","POST"])
def customer_password_login(request): def customer_password_login(request):
_remember_destination(request)
if response:=_authenticated_home(request): return response if response:=_authenticated_home(request): return response
form=CustomerPasswordLoginForm(request.POST or None) form=CustomerPasswordLoginForm(request.POST or None)
block_reason = request.session.pop(BLOCK_REASON_KEY, "")
if request.method=="POST" and form.is_valid(): if request.method=="POST" and form.is_valid():
block_reason = blocked_login_reason(form.cleaned_data["mobile"])
if block_reason:
return render(request, "accounts/customer_password_login.html", {"form": form, "block_reason": block_reason})
user=form.authenticate(request) user=form.authenticate(request)
if user and user.is_active: if user and user.is_active:
login(request,user); return redirect(request.session.pop("next","tickets:list")) login(request,user); return redirect(_login_destination(request))
form.add_error(None,"شماره موبایل یا رمز عبور صحیح نیست.") form.add_error(None,"شماره موبایل یا رمز عبور صحیح نیست.")
return render(request,"accounts/customer_password_login.html",{"form":form}) return render(request,"accounts/customer_password_login.html",{"form":form,"block_reason":block_reason})
@login_required @login_required
@require_http_methods(["GET","POST"]) @require_http_methods(["GET","POST"])
@@ -87,14 +119,28 @@ def customer_profile(request):
messages.success(request,"پروفایل شما ذخیره شد."); return redirect("accounts:profile") messages.success(request,"پروفایل شما ذخیره شد."); return redirect("accounts:profile")
return render(request,"accounts/profile.html",{"form":form}) return render(request,"accounts/profile.html",{"form":form})
def _staff_login_destination(request):
target = request.GET.get("next") or request.POST.get("next") or request.session.get("staff_login_next", "")
if target.startswith(("/vt/", "/staff/tickets/")) and url_has_allowed_host_and_scheme(
target, allowed_hosts={request.get_host()}, require_https=request.is_secure(),
):
request.session["staff_login_next"] = target
return target
request.session.pop("staff_login_next", None)
return reverse("staff_tickets:list")
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def staff_login(request): def staff_login(request):
destination = _staff_login_destination(request)
if response:=_authenticated_home(request): return response if response:=_authenticated_home(request): return response
form = StaffLoginForm(request.POST or None) form = StaffLoginForm(request.POST or None)
if request.method == "POST" and form.is_valid(): if request.method == "POST" and form.is_valid():
user = form.authenticate(request) user = form.authenticate(request)
if user and user.is_active and user.account_type == User.AccountType.STAFF: if user and user.is_active and user.account_type == User.AccountType.STAFF:
login(request, user); return redirect("staff_tickets:list") login(request, user)
request.session.pop("staff_login_next", None)
return redirect(destination)
form.add_error(None, "اطلاعات ورود صحیح نیست.") form.add_error(None, "اطلاعات ورود صحیح نیست.")
return render(request, "accounts/staff_login.html", {"form": form}) return render(request, "accounts/staff_login.html", {"form": form})
@@ -106,17 +152,30 @@ def logout_view(request):
response["Cache-Control"]="no-store" response["Cache-Control"]="no-store"
return response return response
@never_cache
@require_http_methods(["GET", "POST"])
def consume_magic_link(request, token): def consume_magic_link(request, token):
link = TicketMagicLink.objects.select_related("customer", "ticket").filter(token_hash=TicketMagicLink.digest(token)).first() link = TicketMagicLink.objects.select_related("customer", "ticket").filter(token_hash=TicketMagicLink.digest(token)).first()
if not link or link.revoked_at or link.expires_at <= timezone.now(): if not link or link.revoked_at or link.expires_at <= timezone.now() or not link.customer.is_active:
request.session["next"] = f"/tickets/{link.ticket.public_id}/" if link else "/tickets/" request.session["next"] = f"/tickets/{link.ticket.public_id}/" if link else "/tickets/"
messages.error(request, "این لینک منقضی شده یا اشتباه است. لطفاً با شماره موبایل خود وارد شوید تا بتوانید پیام‌ها را ببینید.") messages.error(request, "این لینک منقضی شده یا اشتباه است. لطفاً با شماره موبایل خود وارد شوید تا بتوانید پیام‌ها را ببینید.")
return redirect("accounts:login") response = redirect("accounts:login")
if request.user.is_authenticated: response["Referrer-Policy"] = "no-referrer"
if request.user.pk != link.customer_id: return response
from django.core.exceptions import PermissionDenied if request.user.is_authenticated and request.user.pk != link.customer_id:
raise PermissionDenied confirmed = (
else: request.method == "POST"
and request.POST.get("action") == "logout_and_open"
and request.POST.get("account_id") == str(request.user.pk)
)
if not confirmed:
response = render(request, "accounts/magic_link_confirmation.html")
# Same-origin form submissions need an Origin for Django CSRF checks.
response["Referrer-Policy"] = "same-origin"
return response
# Logout flushes the old session; grant access only in the new one.
logout(request)
if not request.user.is_authenticated:
grant_ticket_magic_access(request, link) grant_ticket_magic_access(request, link)
ip_address = request.META.get("REMOTE_ADDR") ip_address = request.META.get("REMOTE_ADDR")
user_agent = request.META.get("HTTP_USER_AGENT", "")[:500] user_agent = request.META.get("HTTP_USER_AGENT", "")[:500]
+57 -2
View File
@@ -1,3 +1,58 @@
from django import forms
from django.contrib import admin from django.contrib import admin
from .models import Attachment,AttachmentPolicy
admin.site.register([Attachment,AttachmentPolicy]) from .models import Attachment, AttachmentPolicy
class AttachmentPolicyAdminForm(forms.ModelForm):
class Meta:
model = AttachmentPolicy
fields = "__all__"
labels = {
"attachment_type": "نوع پیوست (Attachment type)",
"is_enabled": "فعال است",
"max_file_size": "حداکثر حجم هر فایل (بایت)",
"max_files_per_message": "حداکثر تعداد فایل از این نوع در هر پیام",
"allowed_extensions": "پسوندهای مجاز (Allowed extensions)",
"allowed_mime_types": "نوع محتوای مجاز (Allowed mime types)",
"sort_order": "ترتیب نمایش",
}
help_texts = {
"attachment_type": (
"شناسه نوع پیوست را با حروف بزرگ وارد کنید: IMAGE، VIDEO، VOICE، PDF، "
"WORD، EXCEL، ZIP، RAR یا OTHER. هر نوع فقط یک سیاست دارد."
),
"is_enabled": "با غیرفعال کردن، آپلود جدید این نوع فایل برای مشتری و کارکنان رد می‌شود.",
"max_file_size": (
"واحد بایت است و این سقف برای هر فایل جداگانه اعمال می‌شود، نه مجموع پیوست‌ها. "
"برای تبدیل حجم بر حسب مِبی‌بایت (MiB) به بایت، عدد را در ۱۰۲۴ × ۱۰۲۴ ضرب کنید؛ "
"مثلاً ۳۰ × ۱۰۲۴ × ۱۰۲۴ = 31457280 بایت. فقط عدد نهایی را وارد کنید."
),
"max_files_per_message": (
"سقف تعداد پیوست‌های همین نوع در یک پیام است؛ "
"مثلاً مقدار ۱۰ برای PDF یعنی حداکثر ۱۰ فایل PDF در هر پیام."
),
"allowed_extensions": (
'فهرست JSON با نقل‌قول دوتایی، نقطه و حروف کوچک وارد کنید؛ '
'برای PDF: [".pdf"] و برای تصویر، مثلاً: [".jpg", ".jpeg", ".png"]. '
"این فیلد پسوند نام فایل را کنترل می‌کند. فرم ادمین فهرست خالی را نمی‌پذیرد."
),
"allowed_mime_types": (
'فهرست JSON از نوع محتوای واقعی فایل وارد کنید؛ برای PDF: ["application/pdf"] '
'و برای تصویر، مثلاً: ["image/jpeg", "image/png"]. '
"نوع محتوا از خود فایل تشخیص داده می‌شود و باید دقیقاً در این فهرست باشد؛ "
"مقادیر کلی مثل image/* پشتیبانی نمی‌شوند. هر دو فهرست پسوند و محتوا باید "
"با فایل سازگار باشند. این سیاست برای مشتری و کارکنان مشترک است؛ "
"فرم ادمین فهرست خالی را نمی‌پذیرد."
),
"sort_order": "عدد صحیح نامنفی برای ثبت ترتیب سیاست؛ مثلاً ۰، ۱ یا ۲.",
}
@admin.register(AttachmentPolicy)
class AttachmentPolicyAdmin(admin.ModelAdmin):
form = AttachmentPolicyAdminForm
from apps.tickets.response_admin import RelatedResponseContentAdmin
admin.site.register(Attachment, RelatedResponseContentAdmin)
+13
View File
@@ -19,3 +19,16 @@ def signed_media_redirect(relative_path, *, download=False, lifetime=300):
if download: if download:
query["download"] = "1" query["download"] = "1"
return HttpResponseRedirect(f"{uri}?{urlencode(query)}") return HttpResponseRedirect(f"{uri}?{urlencode(query)}")
def attachment_response(attachment, *, download=False):
"""Deliver authorized bytes; confidential responses never use bearer URLs."""
import mimetypes
from django.http import FileResponse
if settings.USE_NGINX_MEDIA and not attachment.relative_path.startswith("dispatch-responses/"):
return signed_media_redirect(attachment.relative_path, download=download)
content_type = attachment.mime_type or mimetypes.guess_type(attachment.original_name)[0] or "application/octet-stream"
response = FileResponse(attachment.file.open("rb"), content_type=content_type,
as_attachment=download, filename=attachment.original_name)
response["X-Content-Type-Options"] = "nosniff"
return response
+8
View File
@@ -5,9 +5,17 @@ from django.db import models
from apps.core.models import PublicIdModel from apps.core.models import PublicIdModel
def attachment_path(instance,filename): def attachment_path(instance,filename):
ext=Path(filename).suffix.lower(); t=instance.message.ticket; m=instance.message ext=Path(filename).suffix.lower(); t=instance.message.ticket; m=instance.message
from apps.tickets.dispatch_responses import response_control
control = response_control(t.group_dispatch) if t.group_dispatch_id else None
if m.author_id == t.customer_id and control and control.mode == "SEALED":
return f"dispatch-responses/{t.public_id}/{m.public_id}/{uuid.uuid4().hex}{ext}"
return f"tickets/{t.created_at:%Y/%m}/{t.public_id}/{m.public_id}/{uuid.uuid4().hex}{ext}" return f"tickets/{t.created_at:%Y/%m}/{t.public_id}/{m.public_id}/{uuid.uuid4().hex}{ext}"
class AttachmentPolicy(models.Model): class AttachmentPolicy(models.Model):
attachment_type=models.CharField(max_length=20,unique=True); is_enabled=models.BooleanField(default=True); max_file_size=models.PositiveBigIntegerField(); max_files_per_message=models.PositiveSmallIntegerField(default=10); allowed_extensions=models.JSONField(default=list); allowed_mime_types=models.JSONField(default=list); sort_order=models.PositiveIntegerField(default=0); updated_at=models.DateTimeField(auto_now=True) attachment_type=models.CharField(max_length=20,unique=True); is_enabled=models.BooleanField(default=True); max_file_size=models.PositiveBigIntegerField(); max_files_per_message=models.PositiveSmallIntegerField(default=10); allowed_extensions=models.JSONField(default=list); allowed_mime_types=models.JSONField(default=list); sort_order=models.PositiveIntegerField(default=0); updated_at=models.DateTimeField(auto_now=True)
def __str__(self):
return self.attachment_type
class Attachment(PublicIdModel,models.Model): class Attachment(PublicIdModel,models.Model):
class Type(models.TextChoices): IMAGE="IMAGE","تصویر"; VIDEO="VIDEO","ویدئو"; VOICE="VOICE","صدا"; PDF="PDF","PDF"; WORD="WORD","Word"; EXCEL="EXCEL","Excel"; ZIP="ZIP","ZIP"; RAR="RAR","RAR"; OTHER="OTHER","سایر" class Type(models.TextChoices): IMAGE="IMAGE","تصویر"; VIDEO="VIDEO","ویدئو"; VOICE="VOICE","صدا"; PDF="PDF","PDF"; WORD="WORD","Word"; EXCEL="EXCEL","Excel"; ZIP="ZIP","ZIP"; RAR="RAR","RAR"; OTHER="OTHER","سایر"
message=models.ForeignKey("messaging.Message",on_delete=models.PROTECT,related_name="attachments"); file=models.FileField(upload_to=attachment_path,max_length=500); original_name=models.CharField(max_length=255); stored_name=models.CharField(max_length=255); relative_path=models.CharField(max_length=500); attachment_type=models.CharField(max_length=20,choices=Type.choices); mime_type=models.CharField(max_length=150); extension=models.CharField(max_length=20); file_size=models.PositiveBigIntegerField(); uploaded_by=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT); created_at=models.DateTimeField(auto_now_add=True) message=models.ForeignKey("messaging.Message",on_delete=models.PROTECT,related_name="attachments"); file=models.FileField(upload_to=attachment_path,max_length=500); original_name=models.CharField(max_length=255); stored_name=models.CharField(max_length=255); relative_path=models.CharField(max_length=500); attachment_type=models.CharField(max_length=20,choices=Type.choices); mime_type=models.CharField(max_length=150); extension=models.CharField(max_length=20); file_size=models.PositiveBigIntegerField(); uploaded_by=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT); created_at=models.DateTimeField(auto_now_add=True)
+9 -16
View File
@@ -1,30 +1,23 @@
import mimetypes
from django.conf import settings
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import FileResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from apps.tickets.policies import TicketPolicy from apps.tickets.policies import TicketPolicy
from apps.accounts.magic_access import get_ticket_magic_access from apps.accounts.magic_access import get_ticket_magic_access
from .delivery import signed_media_redirect from .delivery import attachment_response
from .models import Attachment from .models import Attachment
from django.views.decorators.cache import never_cache
def _authorized_attachment(request,public_id): def _authorized_attachment(request,public_id):
attachment=get_object_or_404(Attachment.objects.select_related("message__ticket"),public_id=public_id); ticket=attachment.message.ticket attachment=get_object_or_404(Attachment.objects.select_related("message__ticket"),public_id=public_id); ticket=attachment.message.ticket
magic_link = None if request.user.is_authenticated else get_ticket_magic_access(request,ticket) magic_link = None if request.user.is_authenticated else get_ticket_magic_access(request,ticket)
if not TicketPolicy.can_view(request.user,ticket) and not magic_link: raise PermissionDenied if not TicketPolicy.can_view(request.user,ticket) and not magic_link: raise PermissionDenied
from apps.tickets.dispatch_responses import can_read_response
actor = ticket.customer if magic_link else request.user
if not can_read_response(actor, attachment.message): raise PermissionDenied
if attachment.message.is_internal and (magic_link or getattr(request.user,"account_type",None)=="CUSTOMER"): raise PermissionDenied if attachment.message.is_internal and (magic_link or getattr(request.user,"account_type",None)=="CUSTOMER"): raise PermissionDenied
if attachment.message.deleted_at and not request.user.has_perm("messaging.view_deleted_message_content"): raise PermissionDenied if attachment.message.deleted_at and not request.user.has_perm("messaging.view_deleted_message_content"): raise PermissionDenied
return attachment return attachment
@never_cache
def view(request,public_id): def view(request,public_id):
attachment=_authorized_attachment(request,public_id) return attachment_response(_authorized_attachment(request, public_id))
if settings.USE_NGINX_MEDIA: @never_cache
return signed_media_redirect(attachment.relative_path)
content_type=attachment.mime_type or mimetypes.guess_type(attachment.original_name)[0] or "application/octet-stream"
response=FileResponse(attachment.file.open("rb"),content_type=content_type)
response["Content-Disposition"]=f'inline; filename="{attachment.original_name}"'
response["X-Content-Type-Options"]="nosniff"
return response
def download(request,public_id): def download(request,public_id):
attachment=_authorized_attachment(request,public_id) return attachment_response(_authorized_attachment(request, public_id), download=True)
if settings.USE_NGINX_MEDIA:
return signed_media_redirect(attachment.relative_path, download=True)
return FileResponse(attachment.file.open("rb"),as_attachment=True,filename=attachment.original_name)
+32 -1
View File
@@ -1,6 +1,7 @@
from django.contrib import admin from django.contrib import admin
from django import forms
from .models import PageTitle, ProductionSeedState, SiteBranding from .models import PageTitle, ProductionSeedState, SiteBranding, VoipConfiguration
@admin.register(SiteBranding) @admin.register(SiteBranding)
@@ -29,6 +30,36 @@ class SiteBrandingAdmin(admin.ModelAdmin):
return False return False
@admin.register(VoipConfiguration)
class VoipConfigurationAdmin(admin.ModelAdmin):
fields = ("voip_call_url", "announcement_url", "announcement_id", "announcement_api_key", "clear_announcement_api_key", "announcement_delay_minutes", "updated_at")
readonly_fields = ("updated_at",)
class ConfigurationForm(forms.ModelForm):
announcement_api_key = forms.CharField(
required=False, widget=forms.PasswordInput,
help_text="برای حفظ کلید فعلی خالی بگذارید؛ برای تغییر، کلید جدید را وارد کنید.",
)
clear_announcement_api_key = forms.BooleanField(required=False, label="حذف کلید فعلی")
class Meta:
model = VoipConfiguration
fields = "__all__"
def clean_announcement_api_key(self):
if self.data.get("clear_announcement_api_key"):
return ""
return self.cleaned_data["announcement_api_key"] or (self.instance.announcement_api_key if self.instance.pk else "")
form = ConfigurationForm
def has_add_permission(self, request):
return not VoipConfiguration.objects.exists()
def has_delete_permission(self, request, obj=None):
return False
@admin.register(PageTitle) @admin.register(PageTitle)
class PageTitleAdmin(admin.ModelAdmin): class PageTitleAdmin(admin.ModelAdmin):
list_display = ("route_name", "title", "is_active", "updated_at") list_display = ("route_name", "title", "is_active", "updated_at")
+5 -3
View File
@@ -56,13 +56,15 @@ def staff_operations_access(request):
if not getattr(user, "is_authenticated", False) or getattr(user, "account_type", "") != "STAFF": if not getattr(user, "is_authenticated", False) or getattr(user, "account_type", "") != "STAFF":
return {} return {}
from apps.tickets.policies import TicketPolicy from apps.tickets.policies import TicketPolicy
from apps.tickets.staff_conversation import unread_shares
memberships = list(user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit")) memberships = list(user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit"))
def allowed(permission): def allowed(permission):
return user.is_superuser or user.has_perm(permission) or any(TicketPolicy.has_unit_permission(user, item.unit, permission) for item in memberships) return user.is_superuser or user.has_perm(permission) or any(TicketPolicy.has_unit_permission(user, item.unit, permission) for item in memberships)
return {"staff_operations_access": { return {"staff_unread_shares": unread_shares(user), "staff_operations_access": {
"create_ticket": user.has_perm("tickets.add_ticket") or allowed("tickets.add_ticket"), "create_ticket": user.has_perm("tickets.add_ticket") or allowed("tickets.add_ticket"),
"contacts": allowed("customers.view_customerprofile"), "contacts": True,
"dispatches": allowed("tickets.create_bulk_dispatch") or allowed("tickets.create_inquiry") or allowed("tickets.dispatch_group_tickets"), "contact_groups": allowed("customers.view_contactgroup") or allowed("customers.add_contactgroup"),
"dispatches": any(allowed(permission) for permission in ("tickets.create_bulk_dispatch", "tickets.create_inquiry", "tickets.dispatch_group_tickets", "tickets.view_all_bulk_dispatches", "tickets.view_all_inquiries")),
"templates": allowed("notifications.view_smstemplate") or allowed("notifications.add_smstemplate"), "templates": allowed("notifications.view_smstemplate") or allowed("notifications.add_smstemplate"),
"audit": user.has_perm("accounts.view_all_magic_links") or allowed("accounts.view_ticketmagiclink"), "audit": user.has_perm("accounts.view_all_magic_links") or allowed("accounts.view_ticketmagiclink"),
}} }}
@@ -16,6 +16,14 @@ from apps.core.production_seed import sha256_file
from apps.tickets.models import Ticket from apps.tickets.models import Ticket
def _load_seed_data(bundle_dir):
# Only the pristine-target guard in handle authorizes this replacement.
call_command("flush", interactive=False, verbosity=0)
call_command("loaddata", str(bundle_dir / "data.json.gz"), verbosity=1)
from apps.notifications.dispatch_opening import ensure_opening_template
ensure_opening_template()
class Command(BaseCommand): class Command(BaseCommand):
help = "Import a verified production seed bundle into a pristine PostgreSQL database." help = "Import a verified production seed bundle into a pristine PostgreSQL database."
@@ -80,10 +88,7 @@ class Command(BaseCommand):
copied_files = [] copied_files = []
try: try:
with transaction.atomic(): with transaction.atomic():
# Migrations contain defaults. Flush those rows before loading _load_seed_data(bundle_dir)
# the authoritative SQLite snapshot; schema/migration history remains.
call_command("flush", interactive=False, verbosity=0)
call_command("loaddata", str(bundle_dir / "data.json.gz"), verbosity=1)
# Old SQLite snapshots may predate unit-scoped queues. A # Old SQLite snapshots may predate unit-scoped queues. A
# category already belongs to exactly one unit, so it is a # category already belongs to exactly one unit, so it is a
# deterministic and safe source for repairing those rows. # deterministic and safe source for repairing those rows.
@@ -0,0 +1,17 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("core", "0006_productionseedstate")]
operations = [
migrations.CreateModel(
name="VoipConfiguration",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("voip_call_url", models.URLField(blank=True, help_text="نشانی کامل endpoint تماس، مانند http://127.0.0.1:8000/v1/click2call", verbose_name="VOIP_CALL_URL")),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={"verbose_name": "تنظیمات تماس VOIP", "verbose_name_plural": "تنظیمات تماس VOIP"},
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.1.1 on 2026-09-28 15:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0007_voipconfiguration'),
]
operations = [
migrations.AddField(
model_name='voipconfiguration',
name='announcement_api_key',
field=models.CharField(blank=True, max_length=500, verbose_name='ANNOUNCEMENT_API_KEY'),
),
migrations.AddField(
model_name='voipconfiguration',
name='announcement_delay_minutes',
field=models.PositiveIntegerField(default=90, verbose_name='تأخیر تماس اطلاع\u200cرسانی (دقیقه)'),
),
migrations.AddField(
model_name='voipconfiguration',
name='announcement_id',
field=models.CharField(blank=True, max_length=200, verbose_name='ANNOUNCEMENT_ID'),
),
migrations.AddField(
model_name='voipconfiguration',
name='announcement_url',
field=models.URLField(blank=True, verbose_name='ANNOUNCEMENT_URL'),
),
]
+36 -9
View File
@@ -31,7 +31,20 @@ class PublicIdModel(models.Model):
abstract = True abstract = True
class SiteBranding(models.Model): class SingletonConfiguration(models.Model):
class Meta:
abstract = True
def save(self, *args, **kwargs):
self.pk = 1
return super().save(*args, **kwargs)
@classmethod
def load(cls):
return cls.objects.filter(pk=1).first() or cls()
class SiteBranding(SingletonConfiguration):
"""Database-backed copy shared by customer, authentication and staff pages.""" """Database-backed copy shared by customer, authentication and staff pages."""
site_title = models.CharField(max_length=160, default="گفت‌وگو با واحد فروش") site_title = models.CharField(max_length=160, default="گفت‌وگو با واحد فروش")
header_title = models.CharField(max_length=160, default="گفت‌وگو با واحد فروش") header_title = models.CharField(max_length=160, default="گفت‌وگو با واحد فروش")
@@ -99,18 +112,32 @@ class SiteBranding(models.Model):
verbose_name = "تنظیمات عنوان و برند" verbose_name = "تنظیمات عنوان و برند"
verbose_name_plural = "تنظیمات عنوان و برند" verbose_name_plural = "تنظیمات عنوان و برند"
def save(self, *args, **kwargs):
self.pk = 1
return super().save(*args, **kwargs)
@classmethod
def load(cls):
return cls.objects.filter(pk=1).first() or cls()
def __str__(self): def __str__(self):
return self.site_title return self.site_title
class VoipConfiguration(SingletonConfiguration):
"""Singleton configuration for the backend click-to-call service."""
voip_call_url = models.URLField(
"VOIP_CALL_URL",
blank=True,
help_text="نشانی کامل endpoint تماس، مانند http://127.0.0.1:8000/v1/click2call",
)
announcement_url = models.URLField("ANNOUNCEMENT_URL", blank=True)
announcement_id = models.CharField("ANNOUNCEMENT_ID", max_length=200, blank=True)
announcement_api_key = models.CharField("ANNOUNCEMENT_API_KEY", max_length=500, blank=True)
announcement_delay_minutes = models.PositiveIntegerField("تأخیر تماس اطلاع‌رسانی (دقیقه)", default=90)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "تنظیمات تماس VOIP"
verbose_name_plural = "تنظیمات تماس VOIP"
def __str__(self):
return "تنظیمات تماس VOIP"
class PageTitle(models.Model): class PageTitle(models.Model):
"""Editable browser title for a named Django route.""" """Editable browser title for a named Django route."""
route_name = models.CharField(max_length=160, unique=True, help_text="مانند accounts:login") route_name = models.CharField(max_length=160, unique=True, help_text="مانند accounts:login")
+1
View File
@@ -16,6 +16,7 @@ SEED_MODEL_LABELS = (
"tickets.SLAConfig", "tickets.SLAConfig",
"tickets.TicketTag", "tickets.TicketTag",
"tickets.GroupDispatch", "tickets.GroupDispatch",
"tickets.DispatchResponseControl",
"tickets.Ticket", "tickets.Ticket",
"tickets.TicketAccessGrant", "tickets.TicketAccessGrant",
"tickets.TicketEvent", "tickets.TicketEvent",
+12
View File
@@ -0,0 +1,12 @@
from django.utils import timezone
def grant_scoped_session_access(request, grant_key, grant_id, expires_at, *, preserve_expiry=False):
"""Rotate anonymous session keys; resource grants still validate their own expiry."""
session = request.session
session.cycle_key()
session[grant_key] = grant_id
seconds = max(1, int((expires_at - timezone.now()).total_seconds()))
if preserve_expiry:
seconds = max(session.get_expiry_age(), seconds)
session.set_expiry(seconds)
+8
View File
@@ -46,6 +46,14 @@ def jalali_date(value):
return _persian_digits(converted.strftime("%d %B %Y")) if converted else "" return _persian_digits(converted.strftime("%d %B %Y")) if converted else ""
@register.filter
def jalali_datetime_precise(value):
converted = _jalali(value)
if not isinstance(converted, jdatetime.datetime):
return ""
return _persian_digits(converted.strftime("%d %B %Y، ساعت %H:%M:%S"))
@register.filter @register.filter
def persian_time(value): def persian_time(value):
"""Display local Iran time using Persian digits.""" """Display local Iran time using Persian digits."""
+19 -3
View File
@@ -1,5 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import ContactGroup, CustomerProfile from .models import ContactEditLog, ContactGroup, CustomerProfile
@admin.register(ContactGroup) @admin.register(ContactGroup)
@@ -11,7 +11,23 @@ class ContactGroupAdmin(admin.ModelAdmin):
@admin.register(CustomerProfile) @admin.register(CustomerProfile)
class CustomerProfileAdmin(admin.ModelAdmin): class CustomerProfileAdmin(admin.ModelAdmin):
list_display = ("user", "stakeholder_type", "organization_name", "first_name", "last_name") list_display = ("user", "stakeholder_type", "organization_name", "first_name", "last_name", "login_blocked")
list_filter = ("stakeholder_type", "groups") list_filter = ("stakeholder_type", "groups", "login_blocked")
search_fields = ("user__mobile", "organization_name", "first_name", "last_name") search_fields = ("user__mobile", "organization_name", "first_name", "last_name")
filter_horizontal = ("groups",) filter_horizontal = ("groups",)
@admin.register(ContactEditLog)
class ContactEditLogAdmin(admin.ModelAdmin):
list_display = ("contact_public_id", "actor_name", "created_at")
search_fields = ("actor_name", "contact_public_id")
readonly_fields = ("contact", "contact_public_id", "actor", "actor_name", "changes", "created_at")
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
@@ -0,0 +1,33 @@
# Generated by Django 5.1.1 on 2026-09-29 06:14
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('customers', '0004_contactgroup_unit_customerprofile_groups_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ContactEditLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('contact_public_id', models.UUIDField(db_index=True)),
('actor_name', models.CharField(max_length=150)),
('changes', models.JSONField()),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('actor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contact_edits', to=settings.AUTH_USER_MODEL)),
('contact', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='edit_logs', to='customers.customerprofile')),
],
options={
'verbose_name': 'سابقه ویرایش مخاطب',
'verbose_name_plural': 'سوابق ویرایش مخاطبان',
'ordering': ['-created_at', '-pk'],
},
),
]
@@ -0,0 +1,25 @@
from django.db import migrations
def backfill_customer_profiles(apps, schema_editor):
User = apps.get_model("accounts", "User")
CustomerProfile = apps.get_model("customers", "CustomerProfile")
database = schema_editor.connection.alias
missing_users = (
User.objects.using(database)
.filter(account_type="CUSTOMER", customer_profile__isnull=True)
.values("pk", "first_name", "last_name")
)
batch = []
for user in missing_users.iterator(chunk_size=500):
batch.append(CustomerProfile(user_id=user["pk"], first_name=user["first_name"][:100], last_name=user["last_name"][:100]))
if len(batch) == 500:
CustomerProfile.objects.using(database).bulk_create(batch, ignore_conflicts=True, batch_size=500)
batch.clear()
if batch:
CustomerProfile.objects.using(database).bulk_create(batch, ignore_conflicts=True, batch_size=500)
class Migration(migrations.Migration):
dependencies = [("customers", "0005_contacteditlog")]
operations = [migrations.RunPython(backfill_customer_profiles, migrations.RunPython.noop)]
@@ -0,0 +1,28 @@
# Generated by Django 5.1.1 on 2026-10-14 08:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('customers', '0006_backfill_customer_profiles'),
]
operations = [
migrations.AddField(
model_name='customerprofile',
name='login_access_version',
field=models.PositiveIntegerField(default=0, editable=False),
),
migrations.AddField(
model_name='customerprofile',
name='login_block_reason',
field=models.TextField(blank=True, help_text='این متن به مشتری یا مخاطب نمایش داده می\u200cشود. ورود کارکنان و کارشناسان تحت تأثیر نیست.', verbose_name='دلیل مسدودی ورود'),
),
migrations.AddField(
model_name='customerprofile',
name='login_blocked',
field=models.BooleanField(default=False, verbose_name='مسدود کردن ورود مشتری / مخاطب'),
),
]
+46
View File
@@ -1,5 +1,7 @@
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.db import transaction
from django.core.exceptions import ValidationError
from apps.core.models import PersianTextNormalizationMixin, TimeStampedModel from apps.core.models import PersianTextNormalizationMixin, TimeStampedModel
@@ -51,4 +53,48 @@ class CustomerProfile(PersianTextNormalizationMixin, TimeStampedModel):
groups = models.ManyToManyField(ContactGroup, blank=True, related_name="contacts") groups = models.ManyToManyField(ContactGroup, blank=True, related_name="contacts")
province = models.CharField(max_length=100, blank=True) province = models.CharField(max_length=100, blank=True)
city = models.CharField(max_length=100, blank=True) city = models.CharField(max_length=100, blank=True)
login_blocked = models.BooleanField("مسدود کردن ورود مشتری / مخاطب", default=False)
login_block_reason = models.TextField(
"دلیل مسدودی ورود", blank=True,
help_text="این متن به مشتری یا مخاطب نمایش داده می‌شود. ورود کارکنان و کارشناسان تحت تأثیر نیست.",
)
login_access_version = models.PositiveIntegerField(default=0, editable=False)
def clean(self):
super().clean()
if self.login_blocked and not self.login_block_reason.strip():
raise ValidationError({"login_block_reason": "برای مسدود کردن ورود، دلیل مسدودی را بنویسید."})
def save(self, *args, **kwargs):
# Keep old sessions revoked even if the administrator later unblocks access.
with transaction.atomic():
previous = type(self).objects.select_for_update().filter(pk=self.pk).first()
if previous is None and self.login_blocked:
self.login_access_version = 1
if previous is not None:
self.login_access_version = previous.login_access_version
fields = kwargs.get("update_fields")
changes_access = fields is None or "login_blocked" in fields
if changes_access and self.login_blocked and not previous.login_blocked:
self.login_access_version += 1
if fields is not None:
kwargs["update_fields"] = set(fields) | {"login_access_version"}
super().save(*args, **kwargs)
def __str__(self): return (f"{self.first_name} {self.last_name}".strip() or self.user.mobile or self.user.username) def __str__(self): return (f"{self.first_name} {self.last_name}".strip() or self.user.mobile or self.user.username)
class ContactEditLog(models.Model):
"""Persistent record of changes made through the staff contact editor."""
contact = models.ForeignKey(CustomerProfile, on_delete=models.SET_NULL, null=True, related_name="edit_logs")
contact_public_id = models.UUIDField(db_index=True)
actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="contact_edits")
actor_name = models.CharField(max_length=150)
changes = models.JSONField()
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
ordering = ["-created_at", "-pk"]
verbose_name = "سابقه ویرایش مخاطب"
verbose_name_plural = "سوابق ویرایش مخاطبان"
+26
View File
@@ -0,0 +1,26 @@
"""Outbound prerecorded announcement calls."""
import requests
from django.conf import settings
class AnnouncementRejected(ValueError):
"""The provider explicitly refused the requested announcement."""
def place_announcement_call(configuration, *, mobile, idempotency_key):
headers = {"Idempotency-Key": idempotency_key}
if configuration.announcement_api_key:
headers["X-API-Key"] = configuration.announcement_api_key
response = requests.post(
configuration.announcement_url,
json={"customer_number": mobile, "announcement_id": configuration.announcement_id},
headers=headers,
timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS,
)
response.raise_for_status()
if response.content:
payload = response.json()
if not isinstance(payload, dict):
raise ValueError("Invalid announcement provider response")
if payload.get("success") is False:
raise AnnouncementRejected("Announcement provider rejected the request")
+20
View File
@@ -15,6 +15,26 @@ class ConsoleBaleProvider:
return NotificationResult(True, "console") return NotificationResult(True, "console")
class BaleBotProvider: 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): def send_message(self, chat_id, text):
if not chat_id: return NotificationResult(False, error="Bale chat id is not configured") 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 = 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)
+57
View File
@@ -0,0 +1,57 @@
import logging
from dataclasses import dataclass
from uuid import uuid4
import requests
from django.conf import settings
from apps.core.models import VoipConfiguration
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class VoipCallResult:
success: bool
error: str = ""
def place_voip_call(*, agent, customer, method):
configuration = VoipConfiguration.load()
if not configuration.voip_call_url:
return VoipCallResult(False, "VOIP_CALL_URL تنظیم نشده است.")
payload = {
"method": method,
"customer_number": customer.local_mobile,
"metadata": {
"agent_id": str(agent.public_id),
"customer_id": str(customer.public_id),
},
}
if method == "ip_phone" and agent.voip_extension:
payload["agent_extension"] = agent.voip_extension
key_prefix = "internal"
elif method == "mobile" and agent.voip_local_mobile:
payload["agent_mobile"] = agent.voip_local_mobile
key_prefix = "mobile"
else:
return VoipCallResult(False, "روش تماس انتخاب‌شده در دسترس نیست.")
try:
response = requests.post(
configuration.voip_call_url,
json=payload,
headers={"Idempotency-Key": f"{key_prefix}-{uuid4().hex}"},
timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS,
)
response.raise_for_status()
if response.content:
body = response.json()
if not isinstance(body, dict) or body.get("success") is False:
return VoipCallResult(False, "سرویس تماس درخواست را نپذیرفت.")
return VoipCallResult(True)
except (requests.RequestException, ValueError):
logger.exception("VOIP click-to-call failed")
return VoipCallResult(False, "خطا در ارتباط با سرویس تماس.")
+3 -1
View File
@@ -1,3 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import Message,MessageMention,MessageRevision from .models import Message,MessageMention,MessageRevision
admin.site.register([Message,MessageMention,MessageRevision]) from apps.tickets.response_admin import ResponseContentAdmin, RelatedResponseContentAdmin
admin.site.register(Message, ResponseContentAdmin)
admin.site.register([MessageMention, MessageRevision], RelatedResponseContentAdmin)
+29
View File
@@ -0,0 +1,29 @@
"""Bounded parsing for explicit message-read acknowledgements."""
import json
import uuid
from django.core.exceptions import ValidationError
def message_read_ids(request):
if request.content_type == 'application/json':
try:
payload = json.loads(request.body or b'{}')
except (TypeError, ValueError):
raise ValidationError('invalid_json')
if not isinstance(payload, dict):
raise ValidationError('invalid_json')
values = payload.get('message_ids', [])
else:
values = request.POST.getlist('message_ids')
if not isinstance(values, list) or len(values) > 100:
raise ValidationError('invalid_message_ids')
identifiers = set()
for value in values:
if not isinstance(value, str) or len(value) > 45:
continue
try:
identifiers.add(uuid.UUID(value))
except ValueError:
continue
return identifiers
+4
View File
@@ -6,6 +6,8 @@ from apps.tickets.policies import TicketPolicy
from .models import Message,MessageRevision from .models import Message,MessageRevision
@transaction.atomic @transaction.atomic
def edit_message(message_id,user,body): def edit_message(message_id,user,body):
from apps.tickets.dispatch_responses import lock_dispatch_for_ticket
lock_dispatch_for_ticket(Message.objects.values_list("ticket_id", flat=True).get(pk=message_id))
msg=Message.objects.select_for_update().select_related("ticket").get(pk=message_id) msg=Message.objects.select_for_update().select_related("ticket").get(pk=message_id)
if msg.author_id!=user.id or not TicketPolicy.can_view(user,msg.ticket): raise PermissionDenied if msg.author_id!=user.id or not TicketPolicy.can_view(user,msg.ticket): raise PermissionDenied
if not (user.account_type=="CUSTOMER" or user.has_perm("messaging.edit_own_message")): raise PermissionDenied if not (user.account_type=="CUSTOMER" or user.has_perm("messaging.edit_own_message")): raise PermissionDenied
@@ -15,6 +17,8 @@ def edit_message(message_id,user,body):
MessageRevision.objects.create(message=msg,body=msg.body,edited_by=user); msg.body=body; msg.edited_at=timezone.now(); msg.save(update_fields=["body","edited_at","updated_at"]); return msg MessageRevision.objects.create(message=msg,body=msg.body,edited_by=user); msg.body=body; msg.edited_at=timezone.now(); msg.save(update_fields=["body","edited_at","updated_at"]); return msg
@transaction.atomic @transaction.atomic
def soft_delete_message(message_id,user): def soft_delete_message(message_id,user):
from apps.tickets.dispatch_responses import lock_dispatch_for_ticket
lock_dispatch_for_ticket(Message.objects.values_list("ticket_id", flat=True).get(pk=message_id))
msg=Message.objects.select_for_update().select_related("ticket").get(pk=message_id) msg=Message.objects.select_for_update().select_related("ticket").get(pk=message_id)
may_delete=user.account_type=="CUSTOMER" or user.has_perm("messaging.delete_own_message") may_delete=user.account_type=="CUSTOMER" or user.has_perm("messaging.delete_own_message")
if msg.author_id!=user.id or not may_delete or not TicketPolicy.can_view(user,msg.ticket): raise PermissionDenied if msg.author_id!=user.id or not may_delete or not TicketPolicy.can_view(user,msg.ticket): raise PermissionDenied
+2
View File
@@ -23,6 +23,8 @@ def delete(request,public_id):
msg=get_object_or_404(Message,public_id=public_id); soft_delete_message(msg.pk,request.user); return redirect(_safe_next(request,"/staff/")) msg=get_object_or_404(Message,public_id=public_id); soft_delete_message(msg.pk,request.user); return redirect(_safe_next(request,"/staff/"))
@login_required @login_required
def revisions(request,public_id): def revisions(request,public_id):
from apps.tickets.dispatch_responses import can_read_response
msg=get_object_or_404(Message,public_id=public_id) msg=get_object_or_404(Message,public_id=public_id)
if not can_read_response(request.user, msg): raise PermissionDenied
if not request.user.has_perm("messaging.view_message_revisions") or not TicketPolicy.can_view(request.user,msg.ticket): raise PermissionDenied if not request.user.has_perm("messaging.view_message_revisions") or not TicketPolicy.can_view(request.user,msg.ticket): raise PermissionDenied
return render(request,"messaging/revisions.html",{"message":msg,"revisions":msg.revisions.select_related("edited_by")}) return render(request,"messaging/revisions.html",{"message":msg,"revisions":msg.revisions.select_related("edited_by")})
+18 -3
View File
@@ -1,12 +1,27 @@
from django.contrib import admin from django.contrib import admin
from .models import NotificationJob,NotificationPolicy,SMSTemplate from .models import AnnouncementJob,NotificationJob,NotificationPolicy,SMSTemplate
@admin.register(NotificationJob) @admin.register(NotificationJob)
class NotificationJobAdmin(admin.ModelAdmin): list_display=("channel","event_type","status","attempts","provider_status_code","delivery_status","provider_message_id","created_at"); list_filter=("channel","status","delivery_status"); readonly_fields=("provider_message_id","provider_status_code","delivery_status_code","delivery_description","delivery_checked_at","dedupe_key","created_at","sent_at") class NotificationJobAdmin(admin.ModelAdmin): list_display=("channel","event_type","status","attempts","provider_status_code","delivery_status","delivery_retry_source","provider_message_id","created_at"); list_filter=("channel","status","delivery_status","delivery_is_final"); readonly_fields=("provider_message_id","provider_status_code","delivery_status_code","delivery_description","delivery_checked_at","delivery_is_final","delivery_retry_source","dedupe_key","created_at","sent_at")
@admin.register(AnnouncementJob)
class AnnouncementJobAdmin(admin.ModelAdmin):
list_display = ("source", "ticket", "message", "requested_by", "status", "attempts", "next_attempt_at", "sent_at")
list_filter = ("source", "status")
search_fields = ("ticket__ticket_number", "message__ticket__ticket_number", "message__ticket__customer__mobile")
readonly_fields = ("source", "ticket", "message", "requested_by", "status", "attempts", "next_attempt_at", "idempotency_key", "last_error", "created_at", "sent_at")
def has_add_permission(self, request):
return False
@admin.register(SMSTemplate) @admin.register(SMSTemplate)
class SMSTemplateAdmin(admin.ModelAdmin): class SMSTemplateAdmin(admin.ModelAdmin):
list_display=("event_type","unit","title","is_active","updated_at") list_display=("event_type","unit","title","is_active","updated_at")
list_filter=("unit","is_active","event_type") list_filter=("unit","is_active","event_type")
readonly_fields=("allowed_tags",) readonly_fields=("allowed_tags_display",)
exclude=("allowed_tags",)
@admin.display(description='Allowed tags / تگ‌های مجاز')
def allowed_tags_display(self, obj):
return obj.supported_tags
@admin.register(NotificationPolicy) @admin.register(NotificationPolicy)
class NotificationPolicyAdmin(admin.ModelAdmin): class NotificationPolicyAdmin(admin.ModelAdmin):
list_display=("key","quiet_period_seconds","is_active","updated_at") list_display=("key","quiet_period_seconds","is_active","updated_at")
+112
View File
@@ -0,0 +1,112 @@
"""Process due announcement calls from the notification worker."""
import logging
from datetime import timedelta
import requests
from django.db import transaction
from django.db.models import Case, IntegerField, Value, When
from django.utils import timezone
from apps.core.models import VoipConfiguration
from apps.integrations.announcements import AnnouncementRejected, place_announcement_call
from apps.messaging.models import MessageReadReceipt
from .models import AnnouncementJob
logger = logging.getLogger(__name__)
MAX_ATTEMPTS = 3
def _failure_code(exc):
if isinstance(exc, requests.Timeout):
return "service_timeout"
if isinstance(exc, requests.ConnectionError):
return "service_unreachable"
if isinstance(exc, requests.HTTPError):
return "service_http_error"
if isinstance(exc, AnnouncementRejected):
return "service_rejected"
if isinstance(exc, ValueError):
return "invalid_service_response"
return "unexpected_error"
def _cancellation_reason(job, configuration):
if job.source == AnnouncementJob.Source.MANUAL:
if not job.ticket_id:
return "message_unavailable"
customer = job.ticket.customer
else:
if job.message is None:
return "message_unavailable"
customer = job.message.ticket.customer
reason = _automatic_cancellation_reason(job.message, customer)
if reason:
return reason
if not customer.is_active or not customer.local_mobile:
return "customer_mobile_unavailable"
if not configuration.announcement_url or not configuration.announcement_id:
return "announcement_not_configured"
return ""
def _automatic_cancellation_reason(message, customer):
if message.is_internal or message.deleted_at or message.author.account_type != "STAFF":
return "message_unavailable"
if MessageReadReceipt.objects.filter(message=message, user=customer).exists():
return "customer_read"
if message.ticket.ticket_messages.filter(author=customer, created_at__gt=message.created_at, is_internal=False).exists():
return "customer_replied"
return ""
def _deliver(job):
try:
configuration = VoipConfiguration.load()
reason = _cancellation_reason(job, configuration)
if reason:
job.status = AnnouncementJob.Status.CANCELLED
job.last_error = reason
return
place_announcement_call(
configuration,
mobile=(job.ticket.customer if job.source == AnnouncementJob.Source.MANUAL else job.message.ticket.customer).local_mobile,
idempotency_key=job.idempotency_key,
)
except Exception as exc:
job.attempts += 1
job.last_error = _failure_code(exc)
job.status = AnnouncementJob.Status.FAILED if job.attempts >= MAX_ATTEMPTS else AnnouncementJob.Status.PENDING
job.next_attempt_at = timezone.now() + timedelta(minutes=3)
logger.exception("Announcement call failed for job %s", job.pk)
else:
job.attempts += 1
job.status = AnnouncementJob.Status.SENT
job.sent_at = timezone.now()
job.last_error = ""
def process_due_announcements(*, job_id=None):
now = timezone.now()
# A crashed worker can safely retry with the same provider idempotency key.
AnnouncementJob.objects.filter(
status=AnnouncementJob.Status.PROCESSING,
next_attempt_at__lt=now - timedelta(minutes=10),
).update(status=AnnouncementJob.Status.PENDING, next_attempt_at=now)
with transaction.atomic():
queryset = (
# Nullable message/ticket joins must not be included in FOR UPDATE on PostgreSQL.
AnnouncementJob.objects.select_for_update(skip_locked=True, of=("self",))
.filter(status=AnnouncementJob.Status.PENDING, next_attempt_at__lte=now)
.select_related("message__ticket__customer", "ticket__customer")
.annotate(priority=Case(When(source=AnnouncementJob.Source.MANUAL, then=Value(0)), default=Value(1), output_field=IntegerField()))
)
if job_id is not None:
queryset = queryset.filter(pk=job_id)
jobs = list(queryset.order_by("priority", "next_attempt_at", "pk")[:20])
AnnouncementJob.objects.filter(pk__in=[job.pk for job in jobs]).update(
status=AnnouncementJob.Status.PROCESSING, next_attempt_at=now,
)
for job in jobs:
_deliver(job)
job.save(update_fields=["status", "attempts", "next_attempt_at", "sent_at", "last_error"])
+42
View File
@@ -0,0 +1,42 @@
"""Isolated worker handler; existing events keep their delivery flow."""
from django.core.exceptions import ValidationError
from apps.tickets.models import DispatchResponseControl
DEFAULT_OPENING_SMS = (
"لوله گستر گلپایگان با سلام؛ پاسخ استعلام شما بازگشایی شد. جهت صیانت از رقابت عادلانه، تمامی پیشنهادات تا این لحظه کاملاً محرمانه بوده و هیچ‌کس دسترسی به مشاهده آن‌ها نداشته است. اکنون تمامی پاسخ‌ها به‌صورت هم‌زمان بازگشایی شدند تا سلامت فرآیند تضمین گردد.\n"
"از همراهی و اعتماد شما سپاسگزاریم. واحد فناوری اطلاعات لوله گستر گلپایگان"
)
def ensure_opening_template():
"""Legacy seed bundles may predate this event; never replace an existing body."""
from .models import SMSTemplate
return SMSTemplate.objects.get_or_create(unit=None, event_type="DISPATCH_RESPONSES_OPENED", defaults={
"title": "بازگشایی پاسخ‌های ارسال گروهی", "body": DEFAULT_OPENING_SMS, "allowed_tags": [], "is_active": True,
})[0]
def send_opening_sms(job, provider_factory):
control = DispatchResponseControl.objects.get(dispatch_id=job.payload.get("dispatch_id"))
permitted = (control.mode == "SEALED" and control.notify_sms
and control.recipient_count_snapshot <= 15 and control.opened_at
and job.ticket_id and job.ticket.group_dispatch_id == control.dispatch_id
and job.customer_id == job.ticket.customer_id
and str(job.customer.public_id) in control.respondents_snapshot)
if not permitted or job.payload.get("body") != control.sms_body_snapshot:
raise ValidationError("Opening notification does not match its authorized snapshot.")
return provider_factory().send_message(job.customer.mobile, control.sms_body_snapshot)
def send_dispatch_reply_bale(job, text, provider_factory):
"""Dispatch metadata goes to its creator's private destination, never a unit room."""
from apps.tickets.policies import DispatchPolicy
dispatch = job.ticket.group_dispatch
owner = dispatch.created_by
profile = getattr(owner, "staff_profile", None)
if not owner.is_active or not profile or not profile.is_active or not profile.bale_notifications_enabled:
return None
if not profile.bale_chat_id.strip() or not DispatchPolicy.can_view(owner, dispatch):
return None
return provider_factory().send_message(profile.bale_chat_id.strip(), text)
@@ -14,11 +14,19 @@ from apps.integrations.bale.providers import get_bale_provider
from apps.integrations.sms.providers import get_sms_provider from apps.integrations.sms.providers import get_sms_provider
from apps.messaging.models import MessageReadReceipt from apps.messaging.models import MessageReadReceipt
from apps.notifications.models import NotificationJob,NotificationPolicy,SMSTemplate from apps.notifications.models import NotificationJob,NotificationPolicy,SMSTemplate
from apps.notifications.announcement_worker import process_due_announcements
from apps.notifications.services import customer_reply_bale_policy,staff_reply_policy from apps.notifications.services import customer_reply_bale_policy,staff_reply_policy
from apps.tickets.models import Ticket from apps.tickets.models import Ticket
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFINITIVE_DELIVERY_RETRY_EVENT = "STAFF_TICKET_CREATED"
SAFE_0098_DELIVERY_RETRY_RESULTS = {
(NotificationJob.DeliveryStatus.UNDELIVERED, "6"),
(NotificationJob.DeliveryStatus.EXPIRED, "7"),
(NotificationJob.DeliveryStatus.ERROR, "-31"),
}
def _magic_link(job, reason): def _magic_link(job, reason):
token,link = TicketMagicLink.issue_with_record(job.ticket, reason=reason, created_by=job.staff_user or job.ticket.owner or job.ticket.created_by) token,link = TicketMagicLink.issue_with_record(job.ticket, reason=reason, created_by=job.staff_user or job.ticket.owner or job.ticket.created_by)
return link, f"{settings.PUBLIC_BASE_URL}/t/{token}/" return link, f"{settings.PUBLIC_BASE_URL}/t/{token}/"
@@ -50,6 +58,9 @@ def _bale_ticket_text(ticket, message=None, *, is_reply=False, message_count=1,
message = message or ticket.ticket_messages.filter(is_internal=False).order_by("created_at", "id").first() message = message or ticket.ticket_messages.filter(is_internal=False).order_by("created_at", "id").first()
raw_excerpt = " ".join((message.body if message else "").split()) raw_excerpt = " ".join((message.body if message else "").split())
excerpt = f"{raw_excerpt[:277].rstrip()}…" if len(raw_excerpt)>280 else raw_excerpt or "بدون متن" excerpt = f"{raw_excerpt[:277].rstrip()}…" if len(raw_excerpt)>280 else raw_excerpt or "بدون متن"
from apps.tickets.dispatch_responses import response_control
if message and message.author_id == ticket.customer_id and ticket.group_dispatch_id and response_control(ticket.group_dispatch):
excerpt = "پاسخ ثبت شده است؛ محتوا فقط در پنل و با رعایت مجوز بازگشایی قابل مشاهده است."
local_created = timezone.localtime(message.created_at if message else ticket.created_at) local_created = timezone.localtime(message.created_at if message else ticket.created_at)
jalali = jdatetime.datetime.fromgregorian(datetime=local_created, locale=jdatetime.FA_LOCALE).strftime("%d %B، %H:%M") jalali = jdatetime.datetime.fromgregorian(datetime=local_created, locale=jdatetime.FA_LOCALE).strftime("%d %B، %H:%M")
lines=[ lines=[
@@ -64,13 +75,36 @@ def _bale_ticket_text(ticket, message=None, *, is_reply=False, message_count=1,
lines.extend([f"🕒 {jalali}", "", f"📝 {excerpt}", "", "🔗 مشاهده و پاسخ", f"{settings.PUBLIC_BASE_URL}/staff/tickets/{ticket.public_id}/"]) lines.extend([f"🕒 {jalali}", "", f"📝 {excerpt}", "", "🔗 مشاهده و پاسخ", f"{settings.PUBLIC_BASE_URL}/staff/tickets/{ticket.public_id}/"])
return "\n".join(lines) return "\n".join(lines)
def _prepare_sms(job):
if job.event_type == 'STAFF_REPLY':
return _prepare_staff_reply_sms(job)
if job.event_type == 'PAYMENT_NOTICE_CREATED' and NotificationJob.objects.filter(
pk=job.pk, payment_notification__notice__deleted_at__isnull=False).exists():
job.status = NotificationJob.Status.CANCELLED
job.last_error = 'اعلامیه واریز حذف شده است.'
job.save(update_fields=['status', 'last_error'])
return False
return True
def _send_sms(job): def _send_sms(job):
if job.event_type == "DISPATCH_RESPONSES_OPENED":
from apps.notifications.dispatch_opening import send_opening_sms
return send_opening_sms(job, get_sms_provider), None
if job.event_type == 'PAYMENT_RECEIPT_RECEIVED':
from apps.payments.receipt_alerts import send_receipt_alert
return send_receipt_alert(job, sms_provider_factory=get_sms_provider), None
if job.event_type in {"PAYMENT_NOTICE_CREATED", "PAYMENT_RECEIPT_APPROVED", "PAYMENT_RECEIPT_REJECTED"}:
from apps.payments.notifications import send_payment_sms
return send_payment_sms(job, provider_factory=get_sms_provider)
issued = None issued = None
if job.event_type == "UNIT_SUPERVISOR_NEW_TICKET": if job.event_type == "UNIT_SUPERVISOR_NEW_TICKET":
from apps.tickets.short_links import append_supervisor_ticket_link
recipient = job.payload.get("recipient_mobile", "") recipient = job.payload.get("recipient_mobile", "")
if not recipient: if not recipient:
raise ValueError("Unit supervisor mobile is not configured") raise ValueError("Unit supervisor mobile is not configured")
message = _render_sms(job.event_type, _sms_context(job), job.ticket.owning_unit) message = _render_sms(job.event_type, _sms_context(job), job.ticket.owning_unit)
message = append_supervisor_ticket_link(job.ticket, message)
return get_sms_provider().send_message(recipient, message), issued return get_sms_provider().send_message(recipient, message), issued
if job.event_type == "CUSTOMER_TICKET_CREATED": if job.event_type == "CUSTOMER_TICKET_CREATED":
issued, url = _magic_link(job, "customer_created_ticket") issued, url = _magic_link(job, "customer_created_ticket")
@@ -83,14 +117,28 @@ def _send_sms(job):
raise ValueError(f"Unsupported SMS event: {job.event_type}") raise ValueError(f"Unsupported SMS event: {job.event_type}")
return get_sms_provider().send_message(job.customer.mobile, message), issued return get_sms_provider().send_message(job.customer.mobile, message), issued
def _send_customer_reply_bale(job, unit_chat_id):
message = job.ticket.ticket_messages.filter(pk=job.payload.get("message_id"), is_internal=False).first()
if not message:
raise ValueError("Customer reply message no longer exists")
text = _bale_ticket_text(job.ticket, message, is_reply=True,
message_count=job.payload.get("message_count", 1), reopened=job.event_type == "CUSTOMER_TICKET_REOPENED")
from apps.tickets.dispatch_responses import response_control
if job.ticket.group_dispatch_id and response_control(job.ticket.group_dispatch):
from apps.notifications.dispatch_opening import send_dispatch_reply_bale
return send_dispatch_reply_bale(job, text, get_bale_provider)
return get_bale_provider().send_message(unit_chat_id, text) if unit_chat_id else None
def _send_bale(job): def _send_bale(job):
if job.event_type == 'PAYMENT_RECEIPT_RECEIVED':
from apps.payments.receipt_alerts import send_receipt_alert
return send_receipt_alert(job)
unit_chat_id = (getattr(job.ticket.owning_unit, "bale_chat_id", "") or "").strip() unit_chat_id = (getattr(job.ticket.owning_unit, "bale_chat_id", "") or "").strip()
if job.event_type == "NEW_CUSTOMER_TICKET": if job.event_type == "NEW_CUSTOMER_TICKET":
return get_bale_provider().send_message(unit_chat_id, _bale_ticket_text(job.ticket)) if unit_chat_id else None return get_bale_provider().send_message(unit_chat_id, _bale_ticket_text(job.ticket)) if unit_chat_id else None
if job.event_type in ["CUSTOMER_TICKET_REPLY","CUSTOMER_TICKET_REOPENED"]: if job.event_type in ["CUSTOMER_TICKET_REPLY","CUSTOMER_TICKET_REOPENED"]:
message = job.ticket.ticket_messages.filter(pk=job.payload.get("message_id"), is_internal=False).first() return _send_customer_reply_bale(job, unit_chat_id)
if not message: raise ValueError("Customer reply message no longer exists")
return get_bale_provider().send_message(unit_chat_id, _bale_ticket_text(job.ticket,message,is_reply=True,message_count=job.payload.get("message_count",1),reopened=job.event_type=="CUSTOMER_TICKET_REOPENED")) if unit_chat_id else None
if not job.staff_user or not hasattr(job.staff_user, "staff_profile"): raise ValueError("Bale recipient is not configured") if not job.staff_user or not hasattr(job.staff_user, "staff_profile"): raise ValueError("Bale recipient is not configured")
profile = job.staff_user.staff_profile profile = job.staff_user.staff_profile
if not profile.bale_notifications_enabled: return None if not profile.bale_notifications_enabled: return None
@@ -158,11 +206,54 @@ def _record_success(job, result):
job.last_error = "" job.last_error = ""
job.provider_message_id = getattr(result, "provider_id", "") if result is not None else "" job.provider_message_id = getattr(result, "provider_id", "") if result is not None else ""
job.provider_status_code = getattr(result, "provider_code", "") if result is not None else "" job.provider_status_code = getattr(result, "provider_code", "") if result is not None else ""
job.delivery_is_final = False
if job.channel == NotificationJob.Channel.SMS and job.provider_message_id: if job.channel == NotificationJob.Channel.SMS and job.provider_message_id:
job.delivery_status = NotificationJob.DeliveryStatus.PENDING job.delivery_status = NotificationJob.DeliveryStatus.PENDING
job.delivery_next_check_at = timezone.now() + timedelta(seconds=settings.SMS_DELIVERY_CHECK_DELAY_SECONDS) job.delivery_next_check_at = timezone.now() + timedelta(seconds=settings.SMS_DELIVERY_CHECK_DELAY_SECONDS)
def _delivery_retry_dedupe_key(job):
suffix = f":definitive-delivery-retry:{job.pk}"
return f"{job.dedupe_key[:200-len(suffix)]}{suffix}"
def _is_safe_0098_delivery_retry_result(job):
return (job.delivery_status, (job.delivery_status_code or "").strip()) in SAFE_0098_DELIVERY_RETRY_RESULTS
def _schedule_definitive_delivery_retry(job):
"""Schedule exactly one delayed resend for a definitive individual-ticket failure."""
if (
job.channel != NotificationJob.Channel.SMS
or job.event_type != DEFINITIVE_DELIVERY_RETRY_EVENT
or job.delivery_retry_source_id
or not job.ticket_id
or job.ticket.group_dispatch_id
or not job.delivery_is_final
or not _is_safe_0098_delivery_retry_result(job)
):
return None
sent_at = job.sent_at or job.created_at
due = max(
timezone.now(),
sent_at + timedelta(seconds=settings.SMS_DEFINITIVE_DELIVERY_RETRY_AFTER_SECONDS),
)
retry, _ = NotificationJob.objects.get_or_create(
delivery_retry_source=job,
defaults={
"channel": job.channel,
"event_type": job.event_type,
"customer_id": job.customer_id,
"staff_user_id": job.staff_user_id,
"ticket_id": job.ticket_id,
"payload": job.payload,
"next_attempt_at": due,
"dedupe_key": _delivery_retry_dedupe_key(job),
},
)
return retry
def _process_delivery_checks(options): def _process_delivery_checks(options):
if options.get("channel") not in (None, NotificationJob.Channel.SMS): if options.get("channel") not in (None, NotificationJob.Channel.SMS):
return return
@@ -200,18 +291,30 @@ def _process_delivery_checks(options):
NotificationJob.objects.filter(pk__in=[job.pk for job in jobs]).update( NotificationJob.objects.filter(pk__in=[job.pk for job in jobs]).update(
delivery_next_check_at=now + timedelta(minutes=10), delivery_next_check_at=now + timedelta(minutes=10),
) )
provider = get_sms_provider() if not jobs:
return
try:
provider = get_sms_provider()
except Exception:
logger.exception("SMS delivery provider initialization failed")
return
for job in jobs: for job in jobs:
result = provider.get_delivery_status(job.provider_message_id) try:
result = provider.get_delivery_status(job.provider_message_id)
except Exception:
logger.exception("SMS delivery check failed for job %s", job.pk)
continue
if result is None: if result is None:
job.delivery_status = "" job.delivery_status = ""
job.delivery_next_check_at = None job.delivery_next_check_at = None
job.delivery_description = "این ارائه‌دهنده از استعلام دلیوری پشتیبانی نمی‌کند." job.delivery_description = "این ارائه‌دهنده از استعلام دلیوری پشتیبانی نمی‌کند."
job.delivery_is_final = False
else: else:
job.delivery_check_attempts += 1 job.delivery_check_attempts += 1
job.delivery_checked_at = timezone.now() job.delivery_checked_at = timezone.now()
job.delivery_status_code = result.provider_code[:50] job.delivery_status_code = result.provider_code[:50]
job.delivery_description = result.description[:500] job.delivery_description = result.description[:500]
job.delivery_is_final = result.is_final
if result.is_final: if result.is_final:
job.delivery_status = result.status job.delivery_status = result.status
job.delivery_next_check_at = None job.delivery_next_check_at = None
@@ -221,11 +324,20 @@ def _process_delivery_checks(options):
else: else:
job.delivery_status = result.status if result.status in NotificationJob.DeliveryStatus.values else NotificationJob.DeliveryStatus.ERROR job.delivery_status = result.status if result.status in NotificationJob.DeliveryStatus.values else NotificationJob.DeliveryStatus.ERROR
job.delivery_next_check_at = None job.delivery_next_check_at = None
# 0098SMS code 6 remains provisional during polling. Once the
# complete monitoring window is exhausted it is safe to treat
# this exact result as terminal for the single delayed resend.
job.delivery_is_final = (
job.delivery_status == NotificationJob.DeliveryStatus.UNDELIVERED
and job.delivery_status_code == "6"
)
job.delivery_description = f"{result.description} نتیجه پس از {job.delivery_check_attempts} نوبت استعلام در بازه پایش نهایی شد."[:500] job.delivery_description = f"{result.description} نتیجه پس از {job.delivery_check_attempts} نوبت استعلام در بازه پایش نهایی شد."[:500]
job.save(update_fields=[ job.save(update_fields=[
"delivery_status", "delivery_status_code", "delivery_description", "delivery_status", "delivery_status_code", "delivery_description",
"delivery_checked_at", "delivery_check_attempts", "delivery_next_check_at", "delivery_checked_at", "delivery_check_attempts", "delivery_next_check_at",
"delivery_is_final",
]) ])
_schedule_definitive_delivery_retry(job)
if job.ticket_id and job.ticket.group_dispatch_id: if job.ticket_id and job.ticket.group_dispatch_id:
from apps.tickets.services import refresh_group_dispatch_status from apps.tickets.services import refresh_group_dispatch_status
refresh_group_dispatch_status(job.ticket.group_dispatch_id) refresh_group_dispatch_status(job.ticket.group_dispatch_id)
@@ -239,6 +351,8 @@ class Command(BaseCommand):
parser.add_argument("--channel", choices=NotificationJob.Channel.values, help="Process only one notification channel") parser.add_argument("--channel", choices=NotificationJob.Channel.values, help="Process only one notification channel")
def handle(self, *args, **options): def handle(self, *args, **options):
while True: while True:
if not options.get("job_id") and not options.get("channel") and not options.get("min_id"):
process_due_announcements()
stale = timezone.now() - timedelta(minutes=10) stale = timezone.now() - timedelta(minutes=10)
NotificationJob.objects.filter(status="PROCESSING", next_attempt_at__lt=stale, channel=NotificationJob.Channel.SMS).update(status=NotificationJob.Status.RETRYING, next_attempt_at=timezone.now(), last_error="بازیابی تلاش ارسال متوقف‌شده") NotificationJob.objects.filter(status="PROCESSING", next_attempt_at__lt=stale, channel=NotificationJob.Channel.SMS).update(status=NotificationJob.Status.RETRYING, next_attempt_at=timezone.now(), last_error="بازیابی تلاش ارسال متوقف‌شده")
NotificationJob.objects.filter(status="PROCESSING", next_attempt_at__lt=stale, channel=NotificationJob.Channel.BALE).update(status=NotificationJob.Status.FAILED, next_attempt_at=timezone.now(), last_error="Recovered stale processing job") NotificationJob.objects.filter(status="PROCESSING", next_attempt_at__lt=stale, channel=NotificationJob.Channel.BALE).update(status=NotificationJob.Status.FAILED, next_attempt_at=timezone.now(), last_error="Recovered stale processing job")
@@ -260,7 +374,11 @@ class Command(BaseCommand):
for job in jobs: for job in jobs:
issued_link = None issued_link = None
try: try:
if job.channel == NotificationJob.Channel.SMS and job.event_type == "STAFF_REPLY" and not _prepare_staff_reply_sms(job): if job.event_type == 'PAYMENT_RECEIPT_RECEIVED':
from apps.payments.receipt_alerts import prepare_receipt_alert
if not prepare_receipt_alert(job):
continue
if job.channel == NotificationJob.Channel.SMS and not _prepare_sms(job):
continue continue
if job.channel == NotificationJob.Channel.BALE and job.event_type == "CUSTOMER_TICKET_REPLY" and not _prepare_customer_reply_bale(job): if job.channel == NotificationJob.Channel.BALE and job.event_type == "CUSTOMER_TICKET_REPLY" and not _prepare_customer_reply_bale(job):
continue continue
@@ -282,6 +400,8 @@ class Command(BaseCommand):
if job.ticket_id and job.ticket.group_dispatch_id: if job.ticket_id and job.ticket.group_dispatch_id:
from apps.tickets.services import refresh_group_dispatch_status from apps.tickets.services import refresh_group_dispatch_status
refresh_group_dispatch_status(job.ticket.group_dispatch_id) refresh_group_dispatch_status(job.ticket.group_dispatch_id)
if not options.get("job_id") and not options.get("channel") and not options.get("min_id"):
process_due_announcements()
_process_delivery_checks(options) _process_delivery_checks(options)
if options["once"] or options.get("job_id"): break if options["once"] or options.get("job_id"): break
time.sleep(5) time.sleep(5)
@@ -0,0 +1,72 @@
from datetime import timedelta
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
from django.utils import timezone
def _retry_dedupe_key(job):
suffix = f":definitive-delivery-retry:{job.pk}"
return f"{job.dedupe_key[:200-len(suffix)]}{suffix}"
def queue_historical_definitive_delivery_retries(apps, schema_editor):
"""Queue each old, definitively expired individual staff-created SMS once."""
NotificationJob = apps.get_model("notifications", "NotificationJob")
delay = getattr(settings, "SMS_DEFINITIVE_DELIVERY_RETRY_AFTER_SECONDS", 2700)
stagger = getattr(settings, "SMS_HISTORICAL_DELIVERY_RETRY_STAGGER_SECONDS", 30)
now = timezone.now()
sources = NotificationJob.objects.filter(
channel="SMS",
event_type="STAFF_TICKET_CREATED",
status="SENT",
ticket__group_dispatch__isnull=True,
delivery_status="EXPIRED",
delivery_next_check_at__isnull=True,
delivery_retry_source__isnull=True,
).order_by("id")
queued = 0
for source in sources.iterator():
if NotificationJob.objects.filter(delivery_retry_source_id=source.pk).exists():
continue
sent_at = source.sent_at or source.created_at
due = max(sent_at + timedelta(seconds=delay), now + timedelta(seconds=(queued + 1) * stagger))
NotificationJob.objects.create(
channel=source.channel,
event_type=source.event_type,
customer_id=source.customer_id,
staff_user_id=source.staff_user_id,
ticket_id=source.ticket_id,
payload=source.payload,
status="PENDING",
attempts=0,
next_attempt_at=due,
dedupe_key=_retry_dedupe_key(source),
delivery_retry_source_id=source.pk,
)
queued += 1
class Migration(migrations.Migration):
dependencies = [("notifications", "0012_supervisor_sms_template")]
operations = [
migrations.AddField(
model_name="notificationjob",
name="delivery_is_final",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="notificationjob",
name="delivery_retry_source",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="delivery_retry_job",
to="notifications.notificationjob",
),
),
migrations.RunPython(queue_historical_definitive_delivery_retries, migrations.RunPython.noop),
]
@@ -0,0 +1,78 @@
from datetime import timedelta
from django.conf import settings
from django.db import migrations
from django.db.models import Q
from django.utils import timezone
SAFE_0098_RESULTS = (
("UNDELIVERED", "6"),
("EXPIRED", "7"),
("ERROR", "-31"),
)
def _retry_dedupe_key(job):
suffix = f":definitive-delivery-retry:{job.pk}"
return f"{job.dedupe_key[:200-len(suffix)]}{suffix}"
def queue_historical_safe_0098_delivery_retries(apps, schema_editor):
"""Queue one delayed resend for old individual staff-ticket SMS jobs."""
NotificationJob = apps.get_model("notifications", "NotificationJob")
delay = getattr(settings, "SMS_DEFINITIVE_DELIVERY_RETRY_AFTER_SECONDS", 2700)
stagger = getattr(settings, "SMS_HISTORICAL_DELIVERY_RETRY_STAGGER_SECONDS", 30)
max_checks = getattr(settings, "SMS_DELIVERY_MAX_CHECKS", 15)
now = timezone.now()
monitor_cutoff = now - timedelta(seconds=delay)
safe_result = (
Q(delivery_status="EXPIRED", delivery_status_code="7")
| Q(delivery_status="ERROR", delivery_status_code="-31")
| Q(delivery_status="UNDELIVERED", delivery_status_code="6", sent_at__lte=monitor_cutoff)
| Q(delivery_status="UNDELIVERED", delivery_status_code="6", delivery_check_attempts__gte=max_checks)
)
sources = NotificationJob.objects.filter(
safe_result,
channel="SMS",
event_type="STAFF_TICKET_CREATED",
status="SENT",
ticket__group_dispatch__isnull=True,
delivery_next_check_at__isnull=True,
delivery_retry_source__isnull=True,
).order_by("id")
queued = 0
for source in sources.iterator():
if NotificationJob.objects.filter(delivery_retry_source_id=source.pk).exists():
continue
sent_at = source.sent_at or source.created_at
due = max(
sent_at + timedelta(seconds=delay),
now + timedelta(seconds=(queued + 1) * stagger),
)
NotificationJob.objects.create(
channel=source.channel,
event_type=source.event_type,
customer_id=source.customer_id,
staff_user_id=source.staff_user_id,
ticket_id=source.ticket_id,
payload=source.payload,
status="PENDING",
attempts=0,
next_attempt_at=due,
dedupe_key=_retry_dedupe_key(source),
delivery_retry_source_id=source.pk,
)
NotificationJob.objects.filter(pk=source.pk).update(delivery_is_final=True)
queued += 1
class Migration(migrations.Migration):
dependencies = [("notifications", "0013_notificationjob_definitive_delivery_retry")]
operations = [
migrations.RunPython(
queue_historical_safe_0098_delivery_retries,
migrations.RunPython.noop,
),
]
@@ -0,0 +1,32 @@
# Generated by Django 5.1.1 on 2026-09-28 15:31
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('messaging', '0003_messagereadreceipt'),
('notifications', '0014_backfill_safe_0098_delivery_retries'),
]
operations = [
migrations.CreateModel(
name='AnnouncementJob',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('PENDING', 'در صف'), ('PROCESSING', 'در حال تماس'), ('SENT', 'ارسال\u200cشده'), ('FAILED', 'ناموفق'), ('CANCELLED', 'لغوشده')], db_index=True, default='PENDING', max_length=15)),
('attempts', models.PositiveSmallIntegerField(default=0)),
('next_attempt_at', models.DateTimeField(db_index=True)),
('idempotency_key', models.CharField(max_length=100, unique=True)),
('last_error', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('sent_at', models.DateTimeField(blank=True, null=True)),
('message', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='announcement_job', to='messaging.message')),
],
options={
'indexes': [models.Index(fields=['status', 'next_attempt_at'], name='announce_due_idx')],
},
),
]
@@ -0,0 +1,38 @@
# Generated by Django 5.1.1 on 2026-09-28 16:45
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('messaging', '0003_messagereadreceipt'),
('notifications', '0015_announcementjob'),
('tickets', '0016_scope_legacy_public_tickets_to_units'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='announcementjob',
name='requested_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_announcement_jobs', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='announcementjob',
name='source',
field=models.CharField(choices=[('AUTOMATIC', 'خودکار'), ('MANUAL', 'فوری توسط کارشناس')], default='AUTOMATIC', max_length=12),
),
migrations.AddField(
model_name='announcementjob',
name='ticket',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='manual_announcement_jobs', to='tickets.ticket'),
),
migrations.AlterField(
model_name='announcementjob',
name='message',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='announcement_job', to='messaging.message'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2026-10-05 10:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('notifications', '0016_announcementjob_requested_by_announcementjob_source_and_more'),
]
operations = [
migrations.AlterField(
model_name='smstemplate',
name='event_type',
field=models.CharField(choices=[('CUSTOMER_TICKET_CREATED', 'ثبت تیکت توسط مشتری'), ('STAFF_TICKET_CREATED', 'ثبت تیکت توسط کارشناس'), ('STAFF_REPLY', 'پاسخ کارشناس'), ('PAYMENT_NOTICE_CREATED', 'صدور اعلامیه واریز'), ('PAYMENT_RECEIPT_APPROVED', 'تأیید نهایی فیش واریز'), ('UNIT_SUPERVISOR_NEW_TICKET', 'اطلاع تیکت جدید به سرپرستان واحد')], max_length=50),
),
]
@@ -0,0 +1,31 @@
# Generated by Django 5.1.1 on 2026-10-06 05:38
from django.db import migrations, models
def align_template_tags(apps, schema_editor):
template = apps.get_model('notifications', 'SMSTemplate')
ticket_tags = ['magic_link', 'first_name', 'last_name', 'recipient_name', 'unit_name', 'company_name',
'ticket_number', 'subject', 'province', 'city', 'expert_name', 'customer_mobile', 'category_name']
payment_tags = ['recipient_name', 'company_name', 'notice_number', 'receipt_number', 'amount_rial', 'invoice_number', 'payment_link']
for item in template.objects.all():
tags = payment_tags if item.event_type in {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED'} else ticket_tags
if item.event_type == 'UNIT_SUPERVISOR_NEW_TICKET':
tags = [tag for tag in tags if tag != 'magic_link']
template.objects.filter(pk=item.pk).update(allowed_tags=tags)
class Migration(migrations.Migration):
dependencies = [
('notifications', '0017_alter_smstemplate_event_type'),
]
operations = [
migrations.AlterField(
model_name='smstemplate',
name='body',
field=models.TextField(help_text='تگ\u200cها بر اساس رویداد مشخص\u200cاند: {magic_link} برای تیکت و {payment_link} برای پرداخت.'),
),
migrations.RunPython(align_template_tags, migrations.RunPython.noop),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2026-10-11 11:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('notifications', '0018_alter_smstemplate_body'),
]
operations = [
migrations.AlterField(
model_name='smstemplate',
name='event_type',
field=models.CharField(choices=[('CUSTOMER_TICKET_CREATED', 'ثبت تیکت توسط مشتری'), ('STAFF_TICKET_CREATED', 'ثبت تیکت توسط کارشناس'), ('STAFF_REPLY', 'پاسخ کارشناس'), ('PAYMENT_NOTICE_CREATED', 'صدور اعلامیه واریز'), ('PAYMENT_RECEIPT_APPROVED', 'تأیید نهایی فیش واریز'), ('PAYMENT_RECEIPT_REJECTED', 'رد فیش واریز'), ('UNIT_SUPERVISOR_NEW_TICKET', 'اطلاع تیکت جدید به سرپرستان واحد')], max_length=50),
),
]
@@ -0,0 +1,17 @@
from django.db import migrations
def seed_rejection_template(apps, schema_editor):
template = apps.get_model('notifications', 'SMSTemplate')
template.objects.using(schema_editor.connection.alias).get_or_create(
unit=None, event_type='PAYMENT_RECEIPT_REJECTED', defaults={
'title': 'رد فیش واریز', 'is_active': True,
'body': 'فیش واریزی {receipt_number} به مبلغ {amount_rial} ریال رد شد.\nدلیل کارشناس: {rejection_reason}\nاصلاح و ارسال دوباره فیش: {payment_link}\n{company_name}',
'allowed_tags': ['recipient_name', 'company_name', 'notice_number', 'receipt_number',
'amount_rial', 'invoice_number', 'payment_link', 'rejection_reason'],
})
class Migration(migrations.Migration):
dependencies = [('notifications', '0019_alter_smstemplate_event_type')]
operations = [migrations.RunPython(seed_rejection_template, migrations.RunPython.noop)]
@@ -0,0 +1,27 @@
# Generated by Django 5.1.1 on 2026-10-13 09:53
from django.db import migrations, models
def seed_opening_template(apps, schema_editor):
apps.get_model('notifications', 'SMSTemplate').objects.using(schema_editor.connection.alias).get_or_create(
unit=None, event_type='DISPATCH_RESPONSES_OPENED', defaults={
'title': 'بازگشایی پاسخ‌های ارسال گروهی', 'is_active': True, 'allowed_tags': [],
'body': 'لوله گستر گلپایگان با سلام؛ پاسخ استعلام شما بازگشایی شد. جهت صیانت از رقابت عادلانه، تمامی پیشنهادات تا این لحظه کاملاً محرمانه بوده و هیچ‌کس دسترسی به مشاهده آن‌ها نداشته است. اکنون تمامی پاسخ‌ها به‌صورت هم‌زمان بازگشایی شدند تا سلامت فرآیند تضمین گردد.\nاز همراهی و اعتماد شما سپاسگزاریم. واحد فناوری اطلاعات لوله گستر گلپایگان',
})
class Migration(migrations.Migration):
dependencies = [
('notifications', '0020_receipt_rejection_template'),
]
operations = [
migrations.SeparateDatabaseAndState(state_operations=[migrations.AlterField(
model_name='smstemplate',
name='event_type',
field=models.CharField(choices=[('DISPATCH_RESPONSES_OPENED', 'بازگشایی پاسخ\u200cهای ارسال گروهی'), ('CUSTOMER_TICKET_CREATED', 'ثبت تیکت توسط مشتری'), ('STAFF_TICKET_CREATED', 'ثبت تیکت توسط کارشناس'), ('STAFF_REPLY', 'پاسخ کارشناس'), ('PAYMENT_NOTICE_CREATED', 'صدور اعلامیه واریز'), ('PAYMENT_RECEIPT_APPROVED', 'تأیید نهایی فیش واریز'), ('PAYMENT_RECEIPT_REJECTED', 'رد فیش واریز'), ('UNIT_SUPERVISOR_NEW_TICKET', 'اطلاع تیکت جدید به سرپرستان واحد')], max_length=50),
)], database_operations=[]),
migrations.RunPython(seed_opening_template, migrations.RunPython.noop),
]
+88 -2
View File
@@ -10,23 +10,51 @@ def sms_template_tags():
class SMSTemplate(PersianTextNormalizationMixin, models.Model): class SMSTemplate(PersianTextNormalizationMixin, models.Model):
persian_text_fields={"title":False,"body":True} persian_text_fields={"title":False,"body":True}
class Event(models.TextChoices): class Event(models.TextChoices):
DISPATCH_RESPONSES_OPENED="DISPATCH_RESPONSES_OPENED","بازگشایی پاسخ‌های ارسال گروهی"
CUSTOMER_TICKET_CREATED="CUSTOMER_TICKET_CREATED","ثبت تیکت توسط مشتری" CUSTOMER_TICKET_CREATED="CUSTOMER_TICKET_CREATED","ثبت تیکت توسط مشتری"
STAFF_TICKET_CREATED="STAFF_TICKET_CREATED","ثبت تیکت توسط کارشناس" STAFF_TICKET_CREATED="STAFF_TICKET_CREATED","ثبت تیکت توسط کارشناس"
STAFF_REPLY="STAFF_REPLY","پاسخ کارشناس" STAFF_REPLY="STAFF_REPLY","پاسخ کارشناس"
PAYMENT_NOTICE_CREATED="PAYMENT_NOTICE_CREATED","صدور اعلامیه واریز"
PAYMENT_RECEIPT_APPROVED="PAYMENT_RECEIPT_APPROVED","تأیید نهایی فیش واریز"
PAYMENT_RECEIPT_REJECTED="PAYMENT_RECEIPT_REJECTED","رد فیش واریز"
UNIT_SUPERVISOR_NEW_TICKET="UNIT_SUPERVISOR_NEW_TICKET","اطلاع تیکت جدید به سرپرستان واحد" UNIT_SUPERVISOR_NEW_TICKET="UNIT_SUPERVISOR_NEW_TICKET","اطلاع تیکت جدید به سرپرستان واحد"
unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.CASCADE,null=True,blank=True,related_name="sms_templates",help_text="خالی یعنی الگوی عمومی و fallback.") unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.CASCADE,null=True,blank=True,related_name="sms_templates",help_text="خالی یعنی الگوی عمومی و fallback.")
event_type=models.CharField(max_length=50,choices=Event.choices) event_type=models.CharField(max_length=50,choices=Event.choices)
title=models.CharField(max_length=120) title=models.CharField(max_length=120)
body=models.TextField(help_text="تگ‌ها را مانند {magic_link} در متن قرار دهید.") body=models.TextField(help_text="تگ‌ها بر اساس رویداد مشخص‌اند: {magic_link} برای تیکت و {payment_link} برای پرداخت.")
allowed_tags=models.JSONField(default=sms_template_tags,blank=True) allowed_tags=models.JSONField(default=sms_template_tags,blank=True)
is_active=models.BooleanField(default=True) is_active=models.BooleanField(default=True)
updated_at=models.DateTimeField(auto_now=True) updated_at=models.DateTimeField(auto_now=True)
class Meta: class Meta:
constraints=[models.UniqueConstraint(fields=["unit","event_type"],name="unique_sms_template_per_unit"),models.UniqueConstraint(fields=["event_type"],condition=models.Q(unit__isnull=True),name="unique_global_sms_template_event")] constraints=[models.UniqueConstraint(fields=["unit","event_type"],name="unique_sms_template_per_unit"),models.UniqueConstraint(fields=["event_type"],condition=models.Q(unit__isnull=True),name="unique_global_sms_template_event")]
def __str__(self): return self.get_event_type_display() def __str__(self): return self.get_event_type_display()
@classmethod
def tags_for_event(cls, event_type):
if event_type == "DISPATCH_RESPONSES_OPENED":
return []
if event_type in {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED', 'PAYMENT_RECEIPT_REJECTED'}:
from apps.payments.notifications import TAGS
return list(TAGS)
tags = sms_template_tags()
return [tag for tag in tags if tag != 'magic_link'] if event_type == 'UNIT_SUPERVISOR_NEW_TICKET' else tags
@property
def supported_tags(self):
return ', '.join('{' + tag + '}' for tag in self.tags_for_event(self.event_type))
def save(self, *args, **kwargs):
self.allowed_tags = self.tags_for_event(self.event_type)
if kwargs.get('update_fields') is not None:
kwargs['update_fields'] = set(kwargs['update_fields']) | {'allowed_tags'}
return super().save(*args, **kwargs)
def clean(self): def clean(self):
self.allowed_tags = self.tags_for_event(self.event_type)
from string import Formatter from string import Formatter
tags={field for _,field,_,_ in Formatter().parse(self.body) if field} try:
tags={field for _,field,_,_ in Formatter().parse(self.body) if field}
except ValueError:
raise ValidationError({'body': 'ساختار تگ معتبر نیست؛ هر تگ باید بین { و } قرار بگیرد.'})
invalid=tags-set(self.allowed_tags) invalid=tags-set(self.allowed_tags)
if invalid: raise ValidationError({"body":f"تگ‌های غیرمجاز: {', '.join(sorted(invalid))}"}) if invalid: raise ValidationError({"body":f"تگ‌های غیرمجاز: {', '.join(sorted(invalid))}"})
@@ -63,5 +91,63 @@ class NotificationJob(models.Model):
delivery_checked_at=models.DateTimeField(null=True,blank=True) delivery_checked_at=models.DateTimeField(null=True,blank=True)
delivery_check_attempts=models.PositiveSmallIntegerField(default=0) delivery_check_attempts=models.PositiveSmallIntegerField(default=0)
delivery_next_check_at=models.DateTimeField(null=True,blank=True,db_index=True) delivery_next_check_at=models.DateTimeField(null=True,blank=True,db_index=True)
delivery_is_final=models.BooleanField(default=False)
delivery_retry_source=models.OneToOneField(
"self",on_delete=models.CASCADE,null=True,blank=True,
related_name="delivery_retry_job",
)
class Meta: class Meta:
indexes=[models.Index(fields=["status","next_attempt_at"],name="notif_due_idx"),models.Index(fields=["ticket","channel","status"],name="notif_ticket_chan_idx")] indexes=[models.Index(fields=["status","next_attempt_at"],name="notif_due_idx"),models.Index(fields=["ticket","channel","status"],name="notif_ticket_chan_idx")]
class AnnouncementJob(models.Model):
"""Durable automatic or staff-requested prerecorded call."""
class Source(models.TextChoices):
AUTOMATIC = "AUTOMATIC", "خودکار"
MANUAL = "MANUAL", "فوری توسط کارشناس"
class Status(models.TextChoices):
PENDING = "PENDING", "در صف"
PROCESSING = "PROCESSING", "در حال تماس"
SENT = "SENT", "ارسال‌شده"
FAILED = "FAILED", "ناموفق"
CANCELLED = "CANCELLED", "لغوشده"
source = models.CharField(max_length=12, choices=Source.choices, default=Source.AUTOMATIC)
message = models.OneToOneField("messaging.Message", on_delete=models.CASCADE, related_name="announcement_job", null=True, blank=True)
ticket = models.ForeignKey("tickets.Ticket", on_delete=models.CASCADE, related_name="manual_announcement_jobs", null=True, blank=True)
requested_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="requested_announcement_jobs")
status = models.CharField(max_length=15, choices=Status.choices, default=Status.PENDING, db_index=True)
attempts = models.PositiveSmallIntegerField(default=0)
next_attempt_at = models.DateTimeField(db_index=True)
idempotency_key = models.CharField(max_length=100, unique=True)
last_error = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
sent_at = models.DateTimeField(null=True, blank=True)
class Meta:
indexes = [models.Index(fields=["status", "next_attempt_at"], name="announce_due_idx")]
@property
def status_label(self):
if self.status == self.Status.SENT:
return "درخواست تماس پذیرفته شد"
if self.status == self.Status.PENDING and self.attempts:
return "در انتظار تلاش مجدد"
return self.get_status_display()
@property
def detail_label(self):
return {
"customer_read": "مشتری پیام را مشاهده کرد.",
"customer_replied": "مشتری پس از پیام پاسخ داد.",
"message_unavailable": "پیام برای اطلاع‌رسانی در دسترس نیست.",
"customer_mobile_unavailable": "شماره همراه مشتری در دسترس نیست.",
"announcement_not_configured": "تنظیمات تماس اطلاع‌رسانی کامل نیست.",
"service_timeout": "سرویس تماس در مهلت مقرر پاسخ نداد.",
"service_unreachable": "ارتباط با سرویس تماس برقرار نشد.",
"service_http_error": "سرویس تماس درخواست را با خطا پاسخ داد.",
"service_rejected": "سرویس تماس درخواست را نپذیرفت.",
"invalid_service_response": "پاسخ سرویس تماس معتبر نبود.",
"unexpected_error": "خطای غیرمنتظره در پردازش درخواست تماس رخ داد.",
}.get(self.last_error, "وضعیت درخواست تماس نیاز به بررسی دارد." if self.last_error else "")
+39 -3
View File
@@ -1,10 +1,46 @@
from datetime import timedelta from datetime import timedelta
from uuid import uuid4 from uuid import uuid4
from django.utils import timezone from django.utils import timezone
from .models import NotificationJob,NotificationPolicy from apps.core.models import VoipConfiguration
from .models import AnnouncementJob,NotificationJob,NotificationPolicy
def enqueue(channel,event_type,ticket,dedupe_key,payload=None,customer=None,staff_user=None): def enqueue(channel,event_type,ticket,dedupe_key,payload=None,customer=None,staff_user=None):
return NotificationJob.objects.get_or_create(dedupe_key=dedupe_key,defaults={"channel":channel,"event_type":event_type,"ticket":ticket,"payload":payload or {},"customer":customer,"staff_user":staff_user,"next_attempt_at":timezone.now()})[0] return NotificationJob.objects.get_or_create(dedupe_key=dedupe_key,defaults={"channel":channel,"event_type":event_type,"ticket":ticket,"payload":payload or {},"customer":customer,"staff_user":staff_user,"next_attempt_at":timezone.now()})[0]
def schedule_announcement(message):
"""The ticket transaction also commits this one-message reminder."""
delay = VoipConfiguration.load().announcement_delay_minutes
return AnnouncementJob.objects.get_or_create(
message=message,
defaults={
"next_attempt_at": message.created_at + timedelta(minutes=delay),
"idempotency_key": f"staff-message-{message.public_id.hex}",
},
)[0]
def schedule_immediate_announcement(ticket, requested_by):
"""Queue an urgent call, coalescing repeated clicks for one minute."""
from django.db import transaction
from apps.tickets.models import Ticket
with transaction.atomic():
Ticket.objects.select_for_update().get(pk=ticket.pk)
now = timezone.now()
recent = AnnouncementJob.objects.filter(
ticket=ticket,
source=AnnouncementJob.Source.MANUAL,
created_at__gte=now - timedelta(minutes=1),
).order_by("-created_at").first()
if recent:
return recent, False
return AnnouncementJob.objects.create(
source=AnnouncementJob.Source.MANUAL,
ticket=ticket,
requested_by=requested_by,
next_attempt_at=now,
idempotency_key=f"manual-ticket-{uuid4().hex}",
), True
def enqueue_unit_supervisor_ticket_sms(ticket): def enqueue_unit_supervisor_ticket_sms(ticket):
"""Queue one concise new-ticket SMS for each configured unit supervisor.""" """Queue one concise new-ticket SMS for each configured unit supervisor."""
unit = ticket.owning_unit unit = ticket.owning_unit
@@ -28,7 +64,7 @@ def customer_reply_bale_policy():
return NotificationPolicy.objects.get_or_create(key=NotificationPolicy.Key.CUSTOMER_REPLY_BALE,defaults={"quiet_period_seconds":600})[0] return NotificationPolicy.objects.get_or_create(key=NotificationPolicy.Key.CUSTOMER_REPLY_BALE,defaults={"quiet_period_seconds":600})[0]
def cancel_staff_reply_sms(ticket,reason): def cancel_staff_reply_sms(ticket,reason):
return NotificationJob.objects.filter(ticket=ticket,channel=NotificationJob.Channel.SMS,event_type="STAFF_REPLY",status__in=[NotificationJob.Status.PENDING,NotificationJob.Status.FAILED]).update(status=NotificationJob.Status.CANCELLED,last_error=reason) return NotificationJob.objects.filter(ticket=ticket,channel=NotificationJob.Channel.SMS,event_type="STAFF_REPLY",status__in=[NotificationJob.Status.PENDING,NotificationJob.Status.RETRYING,NotificationJob.Status.FAILED]).update(status=NotificationJob.Status.CANCELLED,last_error=reason)
def schedule_staff_reply_sms(ticket,message): def schedule_staff_reply_sms(ticket,message):
"""Debounce a chat burst into one SMS; caller already locks the ticket.""" """Debounce a chat burst into one SMS; caller already locks the ticket."""
@@ -46,7 +82,7 @@ def schedule_staff_reply_sms(ticket,message):
return NotificationJob.objects.create(channel=NotificationJob.Channel.SMS,event_type="STAFF_REPLY",ticket=ticket,customer=ticket.customer,payload=payload,next_attempt_at=due,dedupe_key=f"ticket:{ticket.pk}:staff-reply:{uuid4().hex}"),True return NotificationJob.objects.create(channel=NotificationJob.Channel.SMS,event_type="STAFF_REPLY",ticket=ticket,customer=ticket.customer,payload=payload,next_attempt_at=due,dedupe_key=f"ticket:{ticket.pk}:staff-reply:{uuid4().hex}"),True
def cancel_staff_reply_sms_if_read(ticket,customer): def cancel_staff_reply_sms_if_read(ticket,customer):
jobs=NotificationJob.objects.filter(ticket=ticket,customer=customer,channel=NotificationJob.Channel.SMS,event_type="STAFF_REPLY",status__in=[NotificationJob.Status.PENDING,NotificationJob.Status.FAILED]) jobs=NotificationJob.objects.filter(ticket=ticket,customer=customer,channel=NotificationJob.Channel.SMS,event_type="STAFF_REPLY",status__in=[NotificationJob.Status.PENDING,NotificationJob.Status.RETRYING,NotificationJob.Status.FAILED])
cancelled=0 cancelled=0
for job in jobs: for job in jobs:
message_id=job.payload.get("latest_message_id") message_id=job.payload.get("latest_message_id")
View File
Whitespace-only changes.
+157
View File
@@ -0,0 +1,157 @@
"""A payment link never authenticates a user or grants access to their whole account."""
import hashlib
import uuid
from datetime import timedelta
from django.conf import settings
from django.db import IntegrityError, transaction
from django.db.models import Q
from django.contrib.auth.views import redirect_to_login
from django.core.exceptions import PermissionDenied
from django.http import Http404
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.utils import timezone
from apps.core.session_access import grant_scoped_session_access
from apps.accounts.models import TicketMagicLink
from . import policies
from .models import PaymentAccessLink, PaymentNotice
SESSION_KEY = 'payment_access_link_id'
SHARE_SESSION_KEY = 'payment_page_share'
def token_digest(token):
return hashlib.sha256(token.encode()).hexdigest()
def issue_payment_link(*, notice=None, receipt=None):
target = receipt or notice
if target is None:
raise ValueError('Payment link requires a notice or receipt')
if receipt is not None:
notice = receipt.notice
for attempt in range(5):
token = TicketMagicLink.generate_secure_token(10)
try:
with transaction.atomic():
link = PaymentAccessLink.objects.create(customer=target.customer, notice=notice, receipt=receipt,
token_hash=token_digest(token),
expires_at=timezone.now() + timedelta(days=settings.PAYMENT_LINK_TTL_DAYS))
return link, settings.PUBLIC_BASE_URL.rstrip('/') + reverse('payment_short_link', args=[token])
except IntegrityError:
if attempt == 4:
raise RuntimeError('عدم موفقیت در تولید لینک یکتای پرداخت.')
def notice_share_url(request, notice):
"""Reuse a validated capability; only a logged-in owner can issue a new one."""
shared = request.session.get(SHARE_SESSION_KEY, {})
token = shared.get('token', '')
link = active_links().filter(pk=shared.get('link_id'), notice=notice,
token_hash=token_digest(token)).first() if token else None
grant = getattr(request, 'payment_access_link', None)
if grant and (link is None or link.pk != grant.pk):
raise PermissionDenied('برای کپی لینک، دوباره لینک پرداخت دریافتی را باز کنید.')
if link is None:
from .services import audit
link, url = issue_payment_link(notice=notice)
token = url.rstrip('/').rsplit('/', 1)[-1]
request.session[SHARE_SESSION_KEY] = {'link_id': link.pk, 'token': token}
audit(request.user, 'PAYMENT_LINK_CREATED', link)
return settings.PUBLIC_BASE_URL.rstrip('/') + reverse('payment_short_link', args=[token])
def active_links():
return PaymentAccessLink.objects.select_related('customer', 'notice', 'receipt').filter(
notice__deleted_at__isnull=True,
revoked_at__isnull=True, expires_at__gt=timezone.now(), customer__is_active=True,
customer__account_type='CUSTOMER')
def grant_payment_access(request, link):
# The session may already contain a ticket grant; each grant checks its own expiry.
grant_scoped_session_access(request, SESSION_KEY, link.pk, link.expires_at, preserve_expiry=True)
def get_payment_access(request):
link_id = request.session.get(SESSION_KEY)
if not isinstance(link_id, int):
return None
link = active_links().filter(pk=link_id).first()
if link is None:
request.session.pop(SESSION_KEY, None)
return link
def visible_payments(request, queryset):
link = getattr(request, 'payment_access_link', None)
if link is None:
return policies.visible(queryset, request.user)
owned = queryset.filter(customer_id=link.customer_id)
if queryset.model == PaymentNotice:
return owned.filter(pk=link.notice_id)
scope = Q(pk=link.receipt_id) | Q(access_links=link)
if link.notice_id:
scope |= Q(notice_id=link.notice_id)
return owned.filter(scope).distinct()
def customer_context(request, **kwargs):
return {'payment_link_access': getattr(request, 'payment_access_link', None), **kwargs}
def prepare_payment_request(request, *, staff=False, allow_payment_link=False):
if request.user.is_authenticated:
customer = request.user.is_active and request.user.account_type == 'CUSTOMER'
permitted = policies.is_staff(request.user) if staff else customer
if staff is None:
permitted = customer or policies.is_staff(request.user)
if not permitted:
raise PermissionDenied
request.payment_customer = request.user
return None
had_link = SESSION_KEY in request.session
link = get_payment_access(request) if allow_payment_link else None
if link:
request.payment_access_link = link
request.payment_customer = link.customer
return None
if had_link and allow_payment_link:
return unavailable_link(request)
return redirect_to_login(request.get_full_path(), '/staff/login/' if staff else '/login/')
def receipt_initial(request, customer):
link = getattr(request, 'payment_access_link', None)
identifier = request.GET.get('notice')
initial = {}
if link and not identifier:
if request.GET.get('mode') == 'independent' or not link.notice_id or link.notice.cancelled_at:
return {'receipt_mode': 'independent'}
identifier = str(link.notice.public_id)
if not identifier:
return initial
try:
identifier = uuid.UUID(identifier)
except ValueError:
raise Http404
notices = visible_payments(request, PaymentNotice.objects.all()) if link else PaymentNotice.objects.filter(customer=customer)
notice = get_object_or_404(notices, public_id=identifier, cancelled_at__isnull=True)
return {'notice_id': notice.pk, 'destination_account': notice.account_id, 'deposit_id': notice.deposit_id}
def validate_link_submission(link, customer, data):
if not active_links().select_related(None).select_for_update(of=('self',)).filter(pk=link.pk, customer=customer).first():
raise PermissionDenied
notice = data.get('notice_id')
if notice is not None and notice.pk != link.notice_id:
raise PermissionDenied
def unavailable_link(request):
response = render(request, 'payments/link_unavailable.html', {'financial_page': True}, status=410)
response['Referrer-Policy'] = 'no-referrer'
response['X-Robots-Tag'] = 'noindex, nofollow'
return response
+216
View File
@@ -0,0 +1,216 @@
from django.contrib import admin
from django.core.exceptions import PermissionDenied
from django.db import connection
from . import models, policies, services
@admin.register(models.PaymentAccessLink)
class PaymentAccessLinkAdmin(admin.ModelAdmin):
list_display = ('customer', 'notice', 'receipt', 'created_at', 'expires_at', 'revoked_at', 'view_count')
list_filter = ('revoked_at', 'expires_at')
exclude = ('token_hash',)
readonly_fields = ('customer', 'notice', 'receipt', 'created_at', 'expires_at', 'revoked_at', 'view_count', 'last_used_at', 'submitted_receipts')
actions = ['revoke_links']
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.action(description='ابطال لینک‌های انتخاب‌شده')
def revoke_links(self, request, queryset):
from django.utils import timezone
for link in queryset.filter(revoked_at__isnull=True):
link.revoked_at = timezone.now()
link.save(update_fields=['revoked_at'])
services.audit(request.user, 'PAYMENT_LINK_REVOKED', link)
from .forms import BankAccountForm
class AuditedConfigurationAdmin(admin.ModelAdmin):
permission = 'manage_payment_approval_policy'
singleton_configuration = False
def has_view_permission(self, request, obj=None):
return policies.has_permission(request.user,self.permission)
def has_add_permission(self, request):
return self.has_view_permission(request) and not (self.singleton_configuration and self.model.objects.exists())
def has_change_permission(self, request, obj=None):
return self.has_view_permission(request,obj)
def has_delete_permission(self, request, obj=None):
return False
def save_model(self, request, obj, form, change):
if not self.has_change_permission(request,obj):
raise PermissionDenied
current = self.model.objects.select_for_update().get(pk=obj.pk) if change else None
before = services.configuration_snapshot(current) if current else {}
super().save_model(request,obj,form,change)
services.audit(request.user,'CONFIG_SAVED',obj,{'fields': form.changed_data,
'before':before,'after':services.configuration_snapshot(obj)})
def save_related(self, request, form, formsets, change):
super().save_related(request,form,formsets,change)
if isinstance(form.instance,models.PaymentApprovalPolicy):
services.audit(request.user,'POLICY_ASSIGNMENTS_CHANGED',form.instance,{'reviewers':list(form.instance.reviewers.values_list('pk',flat=True)), 'seniors':list(form.instance.seniors.values_list('pk',flat=True))})
@admin.register(models.PaymentApprovalPolicy)
class ApprovalPolicyAdmin(AuditedConfigurationAdmin):
list_display = ['name','unit','is_active']
filter_horizontal = ['reviewers','seniors']
def formfield_for_manytomany(self, db_field, request, **kwargs):
from apps.accounts.models import User
if db_field.name in ['reviewers','seniors']:
kwargs['queryset'] = User.objects.filter(account_type='STAFF',is_active=True).exclude(staff_profile__is_active=False)
return super().formfield_for_manytomany(db_field,request,**kwargs)
@admin.register(models.PaymentAccessConfiguration)
class AccessAdmin(AuditedConfigurationAdmin):
permission = 'manage_payment_access'
singleton_configuration = True
@admin.register(models.CompanyBankAccount)
class AccountAdmin(AuditedConfigurationAdmin):
form = BankAccountForm
list_display = ['name','bank','requires_deposit_id','is_active','available_to_staff','available_to_customers']
permission = 'manage_company_bankaccounts'
def has_view_permission(self, request, obj=None):
return policies.can_manage_banks(request.user)
def get_queryset(self, request):
queryset = super().get_queryset(request)
# Django wraps POST change forms in atomic(): lock before token validation.
if request.method == 'POST' and connection.in_atomic_block:
return queryset.select_for_update(of=('self',))
return queryset
@admin.register(models.BankInstitution)
class BankAdmin(AccountAdmin):
form = admin.ModelAdmin.form
list_display = ['name','code','is_active','historical']
class EvidenceAdmin(admin.ModelAdmin):
def has_view_permission(self, request, obj=None):
if self.model in [models.PaymentNotice, models.PaymentReceipt]:
return super().has_view_permission(request, obj)
return policies.has_permission(request.user, 'view_payment_audit')
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
def get_queryset(self, request):
queryset = super().get_queryset(request)
if queryset.model in [models.PaymentNotice,models.PaymentReceipt]:
return policies.visible(queryset,request.user)
return queryset if policies.has_permission(request.user,'view_payment_audit') else queryset.none()
for model in [models.PaymentNotice,models.PaymentReceipt,models.PaymentReceiptRevision,models.ReceiptApprovalCycle,
models.ReceiptApprovalAssignment,models.ReceiptDecision,models.PaymentAuditEvent,
models.PaymentNotification,models.PaymentNotificationAttempt]:
admin.site.register(model,EvidenceAdmin)
class ReceiptAlertRecipientInline(admin.TabularInline):
model = models.PaymentReceiptAlertRecipient
extra = 1
can_delete = False
def has_view_permission(self, request, obj=None):
return policies.has_permission(request.user, 'manage_receipt_alerts')
has_add_permission = has_view_permission
has_change_permission = has_view_permission
@admin.register(models.PaymentReceiptAlertConfiguration)
class ReceiptAlertConfigurationAdmin(AuditedConfigurationAdmin):
from .forms import ReceiptAlertConfigurationForm
form = ReceiptAlertConfigurationForm
permission = 'manage_receipt_alerts'
singleton_configuration = True
list_display = ('__str__', 'bale_enabled', 'sms_enabled', 'notify_revisions', 'updated_at')
readonly_fields = ('updated_at',)
inlines = [ReceiptAlertRecipientInline]
fieldsets = (
('بله — تصویر فیش', {'fields': ('bale_enabled', 'bale_token', 'clear_bale_token', 'bale_chat_id', 'bale_caption')}),
('پیامک مدیران', {'fields': ('sms_enabled', 'sms_template')}),
('فیش اصلاح‌شده', {'fields': ('notify_revisions', 'updated_at')}),
)
def save_related(self, request, form, formsets, change):
super().save_related(request, form, formsets, change)
services.audit(request.user, 'RECEIPT_ALERT_RECIPIENTS_CHANGED', form.instance,
{'recipients': list(form.instance.recipients.values('id', 'name', 'mobile', 'is_active'))})
@admin.register(models.PaymentReceiptAlertRecipient)
class ReceiptAlertRecipientAdmin(AuditedConfigurationAdmin):
permission = 'manage_receipt_alerts'
list_display = ('name', 'mobile', 'is_active')
list_filter = ('is_active',)
search_fields = ('name', 'mobile')
@admin.register(models.PaymentReceiptAlert)
class ReceiptAlertAdmin(EvidenceAdmin):
list_display = ('receipt_number', 'recipient_name', 'recipient_mobile', 'channel', 'status', 'attempts', 'delivery_status')
list_select_related = ('job', 'revision__receipt', 'recipient')
readonly_fields = ('channel', 'status', 'attempts', 'delivery_status', 'last_error', 'sent_at', 'delivery_description')
def has_view_permission(self, request, obj=None):
return policies.has_permission(request.user, 'manage_receipt_alerts') or super().has_view_permission(request, obj)
def get_queryset(self, request):
if policies.has_permission(request.user, 'manage_receipt_alerts'):
return admin.ModelAdmin.get_queryset(self, request)
return super().get_queryset(request)
@admin.display(description='فیش')
def receipt_number(self, obj):
return obj.revision.receipt.number
@admin.display(description='کانال')
def channel(self, obj):
return obj.job.get_channel_display()
@admin.display(description='وضعیت ارسال')
def status(self, obj):
return obj.job.get_status_display()
@admin.display(description='تعداد تلاش')
def attempts(self, obj):
return obj.job.attempts
@admin.display(description='دریافت پیامک')
def delivery_status(self, obj):
return obj.job.get_delivery_status_display() or 'استعلام ثبت نشده'
@admin.display(description='آخرین خطا')
def last_error(self, obj):
return obj.job.last_error
@admin.display(description='زمان ارسال')
def sent_at(self, obj):
return obj.job.sent_at
@admin.display(description='شرح دریافت')
def delivery_description(self, obj):
return obj.job.delivery_description
+22
View File
@@ -0,0 +1,22 @@
from string import Formatter
from django.core.exceptions import ValidationError
ALERT_TAGS = ('amount_rial', 'customer_name', 'customer_mobile', 'received_at', 'paid_date',
'receipt_number', 'receipt_link', 'revision_version', 'manager_name')
DEFAULT_RECEIPT_SMS = 'فیش واریزی به مبلغ {amount_rial} ریال توسط {customer_name} به شماره تماس {customer_mobile} ارسال شد.'
DEFAULT_RECEIPT_CAPTION = ('فیش واریزی جدید\nمشتری: {customer_name}\nشماره تماس: {customer_mobile}\n'
'مبلغ: {amount_rial} ریال\nزمان ارسال: {received_at}\nتاریخ واریز: {paid_date}\n'
'شماره فیش: {receipt_number} · نسخه {revision_version}\n{receipt_link}')
ALERT_TEMPLATE_HELP = 'تگ‌های مجاز: ' + '، '.join('{' + tag + '}' for tag in ALERT_TAGS)
def validate_alert_template(value):
if not value.strip():
raise ValidationError('متن اعلان نمی‌تواند خالی باشد.')
try:
for _, field, spec, conversion in Formatter().parse(value):
if field is not None and (field not in ALERT_TAGS or spec or conversion):
raise ValueError
except ValueError:
raise ValidationError('قالب معتبر نیست؛ فقط تگ‌های معرفی‌شده و بدون قالب‌بندی اضافی مجازند.')
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class PaymentsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.payments'
verbose_name = 'واریزها و تأیید مالی'
+15
View File
@@ -0,0 +1,15 @@
from django.db import OperationalError, ProgrammingError
from . import policies
from .models import PaymentReceipt
def payment_navigation(request):
if not policies.is_staff(request.user):
return {}
try:
return {'payment_can_create': policies.can_create(request.user),
'payment_pending_count': policies.visible(PaymentReceipt.objects.all(),request.user).filter(status__in=['PENDING','PARTIAL']).count(),
'payment_can_manage_banks': policies.can_manage_banks(request.user),
'payment_can_view': policies.visible(PaymentReceipt.objects.all(),request.user).exists() or policies.config().open_view or request.user.has_perm('payments.view_paymentreceipt') or request.user.is_superuser}
except (OperationalError, ProgrammingError):
return {}
+77
View File
@@ -0,0 +1,77 @@
"""Reuse the contact directory's scoped history; never render all customer choices."""
from uuid import UUID
from django.db.models import Prefetch, Q
from django.http import Http404
from django.urls import reverse
from apps.accounts.models import normalize_mobile
from apps.core.persian_text import normalize_persian_text
from apps.tickets.selectors import staff_ticket_rows
from .validators import digits
def customer_rows(user, search=''):
# The existing directory projection counts only tickets visible to this staff user.
from apps.tickets.views import _customer_rows
rows = _customer_rows(user=user).filter(is_active=True)
if search:
terms_query = Q()
for term in search.split():
mobile = term
try:
mobile = normalize_mobile(term)
except ValueError:
if term.isascii() and term.isdecimal() and term.startswith('0'):
mobile = term[1:]
terms_query &= (Q(mobile__icontains=mobile) | Q(username__icontains=term) |
Q(first_name__icontains=term) | Q(last_name__icontains=term) |
Q(customer_profile__first_name__icontains=term) |
Q(customer_profile__last_name__icontains=term) |
Q(customer_profile__organization_name__icontains=term))
history = staff_ticket_rows(user).filter(
Q(ticket_number__icontains=search) | Q(subject__icontains=search) |
Q(subject__icontains=normalize_persian_text(search)))
rows = rows.filter(terms_query | Q(customer_tickets__pk__in=history.values('pk'))).distinct()
return rows.prefetch_related(Prefetch('customer_tickets',
queryset=staff_ticket_rows(user).order_by('-created_at', '-pk')[:3],
to_attr='payment_recent_tickets'))
def customer_data(customer):
profile = getattr(customer, 'customer_profile', None)
name = ' '.join(filter(None, [getattr(profile, 'first_name', ''), getattr(profile, 'last_name', '')]))
return {'value': str(customer.pk), 'id': str(customer.public_id),
'name': name or customer.get_full_name() or 'نام ثبت نشده',
'mobile': customer.local_mobile, 'organization': getattr(profile, 'organization_name', ''),
'location': '، '.join(filter(None, [getattr(profile, 'city', ''), getattr(profile, 'province', '')])),
'ticket_count': customer.ticket_count, 'active_count': customer.active_count,
'history': [{'number': ticket.ticket_number, 'subject': ticket.subject,
'status': ticket.get_status_display(), 'url': reverse('staff_tickets:detail', args=[ticket.public_id])}
for ticket in customer.payment_recent_tickets]}
def initial_customer(request):
public_id = request.GET.get('customer', '')
if not public_id or request.method != 'GET':
return {}
try:
public_id = UUID(public_id)
except (ValueError, TypeError, AttributeError):
raise Http404('مشتری معتبر نیست.')
customer = customer_rows(request.user).filter(public_id=public_id).first()
if customer is None:
raise Http404('مشتری فعال پیدا نشد.')
return {'customer': customer.pk}
def picker_context(request, form):
value = str(form['customer'].value() or '')
selected = customer_rows(request.user).filter(pk=value).first() if value.isascii() and value.isdecimal() else None
return {'customer_selection': customer_data(selected) if selected else None}
def search_results(user, query):
query = digits(query).translate(str.maketrans('يك', 'یک'))[:100]
if len(query) < 2:
return {'results': [], 'has_more': False}
rows = list(customer_rows(user, query)[:21])
return {'results': [customer_data(customer) for customer in rows[:20]], 'has_more': len(rows) > 20}
+48
View File
@@ -0,0 +1,48 @@
"""Deposit identifier algorithm copied from repository alghortim_shenase.py.
The input is the customer local mobile number, retaining its leading zero.
This is a deterministic identifier, not a random or unique-per-notice value.
"""
def normalize_deposit_identifier(value):
"""Remove leading zeroes from deposit IDs only; retain internal/check digits."""
value = str(value or '').translate(str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')).strip()
return (value.lstrip('0') or '0') if value else ''
def generate_id_algorithm(id_code: str) -> str:
"""
Calculates the verification code based on the reverse-engineered logic:
1. Iterate digits from right to left (index i = 0, 1, 2...).
2. Weights:
- If i < 8: Weight = 8 - i (Sequence: 8, 7, 6, 5, 4, 3, 2, 1)
- If i >= 8: Weight = i + 1 (Sequence: 9, 10, ...)
3. Sum = Sum(Digit * Weight)
4. Check Digit = Sum % 99
"""
# تمیزکاری ورودی (حذف فاصله‌ها و اطمینان از عددی بودن)
if not id_code.isdigit():
return "Error: ورودی باید فقط عدد باشد."
total_sum = 0
# پیمایش از راست به چپ
# reversed(id_code) ارقام را برعکس می‌کند
for i, char in enumerate(reversed(id_code)):
digit = int(char)
# تعیین وزن بر اساس ایندکس (مکان رقم از راست)
if i < 8:
weight = 8 - i
else:
weight = i + 1
total_sum += digit * weight
# محاسبه باقی‌مانده بر 99
check_digit = total_sum % 99
# فرمت‌دهی دو رقمی (مثلاً 6 می‌شود 06)
check_digit_str = f"{check_digit:02}"
return normalize_deposit_identifier(f"{id_code}{check_digit_str}")
+160
View File
@@ -0,0 +1,160 @@
import uuid
import jdatetime
from django import forms
from django.db.models import Q
from django.utils import timezone
from apps.accounts.models import User
from . import validators
from .models import BankInstitution, CompanyBankAccount, PaymentNotice
from .identity import customer_name, depositor_name
from .deposit_identifier import generate_id_algorithm
class ReceiptAlertConfigurationForm(forms.ModelForm):
clear_bale_token = forms.BooleanField(label='پاک کردن توکن ذخیره‌شده', required=False)
class Meta:
from .models import PaymentReceiptAlertConfiguration
model = PaymentReceiptAlertConfiguration
fields = '__all__'
widgets = {'bale_token': forms.PasswordInput(render_value=False)}
help_texts = {'bale_token': 'برای حفظ توکن فعلی این فیلد را خالی بگذارید؛ برای جایگزینی، توکن تازه وارد کنید.'}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.initial['bale_token'] = ''
def clean(self):
data = super().clean()
if data.get('clear_bale_token'):
data['bale_token'] = ''
elif not data.get('bale_token') and self.instance.pk:
data['bale_token'] = self.instance.bale_token
return data
class BankAccountForm(forms.ModelForm):
revision_token = forms.CharField(required=False, widget=forms.HiddenInput)
class Meta:
model = CompanyBankAccount
fields = ['revision_token', 'name', 'bank', 'holder', 'account_number', 'iban', 'card_number', 'requires_deposit_id', 'is_active', 'available_to_staff', 'available_to_customers', 'sort_order']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk:
self.initial['revision_token'] = self.instance.updated_at.isoformat()
def clean_account_number(self):
return validators.bank_number(self.cleaned_data['account_number'])
def clean_card_number(self):
value = validators.bank_number(self.cleaned_data['card_number'])
if value and (len(value) != 16 or sum((int(n) * (2 if i % 2 == 0 else 1)) // 10 + (int(n) * (2 if i % 2 == 0 else 1)) % 10 for i, n in enumerate(value)) % 10):
raise forms.ValidationError('شماره کارت ۱۶ رقمی معتبر وارد کنید.')
return value
def clean_iban(self):
return validators.iban(self.cleaned_data['iban'])
def clean(self):
data = super().clean()
if self.instance.pk and data.get('revision_token') != self.instance.updated_at.isoformat():
raise forms.ValidationError('حساب از زمان باز کردن فرم تغییر کرده است؛ صفحه را تازه کنید و تغییرات را دوباره بررسی کنید.')
if not any(data.get(key) for key in ('account_number', 'iban', 'card_number')):
raise forms.ValidationError('حداقل شماره حساب، شبا یا کارت را وارد کنید.')
return data
class NoticeForm(forms.Form):
submission_key = forms.UUIDField(widget=forms.HiddenInput)
customer = forms.ModelChoiceField(label='مشتری', queryset=User.objects.none(), widget=forms.HiddenInput,
error_messages={'required': 'مشتری را از نتایج جست‌وجو انتخاب کنید.',
'invalid_choice': 'مشتری انتخاب‌شده معتبر یا فعال نیست؛ دوباره جست‌وجو کنید.'})
account = forms.ModelChoiceField(label='حساب مقصد', queryset=CompanyBankAccount.objects.none())
subject = forms.CharField(label='موضوع', max_length=200)
amount = forms.CharField(label='مبلغ (ریال)')
deposit_id = forms.CharField(label='شناسه واریز', max_length=100, required=False)
invoice_number = forms.CharField(label='شماره فاکتور', max_length=100, required=False)
description = forms.CharField(label='توضیحات', required=False, widget=forms.Textarea)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.initial.setdefault('submission_key', uuid.uuid4())
self.fields['customer'].queryset = User.objects.filter(account_type='CUSTOMER', is_active=True).select_related('customer_profile')
self.fields['customer'].label_from_instance = lambda u: f'{getattr(u, "customer_profile", u)} — {u.local_mobile}'
self.fields['account'].queryset = CompanyBankAccount.objects.filter(is_active=True, available_to_staff=True).select_related('bank')
def clean_amount(self):
return validators.amount(self.cleaned_data['amount'])
def clean(self):
data = super().clean()
if data.get('account') and data['account'].requires_deposit_id and not data.get('deposit_id', '').strip():
self.add_error('deposit_id', 'این حساب شناسه‌دار است؛ شناسه واریز مشتری را حتماً وارد کنید.')
return data
class ReceiptForm(forms.Form):
depositor_name = forms.CharField(label='نام واریزکننده', max_length=150)
receipt_mode = forms.ChoiceField(label='نوع واریز', choices=[('linked', 'واریز مرتبط (پیشنهادی)'), ('independent', 'واریز مستقل')], widget=forms.RadioSelect, initial='linked')
notice_id = forms.ModelChoiceField(label='اعلامیه واریز', queryset=PaymentNotice.objects.none(), required=False)
paid_amount = forms.CharField(label='مبلغ پرداخت‌شده (ریال)')
destination_account = forms.ModelChoiceField(label='به کدام حساب ما واریز کردید؟', queryset=CompanyBankAccount.objects.none())
deposit_id = forms.CharField(label='شناسه واریز که با آن پرداخت انجام داده‌اید (اختیاری)', max_length=100, required=False)
origin_bank = forms.ModelChoiceField(label='بانک مبدأ (اختیاری)', queryset=BankInstitution.objects.filter(is_active=True), required=False)
origin_other = forms.CharField(label='نام بانک دیگر (اختیاری)', max_length=100, required=False)
paid_day = forms.ChoiceField(label='روز', choices=[('', 'روز')] + [(str(i), str(i)) for i in range(1, 32)])
paid_month = forms.ChoiceField(label='ماه', choices=[('', 'ماه')] + list(zip(map(str, range(1, 13)), ['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند'])))
paid_year = forms.ChoiceField(label='سال')
receipt_image = forms.FileField(label='تصویر فیش', widget=forms.ClearableFileInput(attrs={'accept': 'image/*'}))
tracking_number = forms.CharField(label='شماره پیگیری (اختیاری)', max_length=100, required=False)
description = forms.CharField(label='توضیحات (اختیاری)', required=False, widget=forms.Textarea)
submission_key = forms.UUIDField(widget=forms.HiddenInput)
def __init__(self, *args, customer, notice_ids=None, **kwargs):
super().__init__(*args, **kwargs)
self.initial.setdefault('depositor_name', customer_name(customer))
notices = PaymentNotice.objects.filter(customer=customer, cancelled_at__isnull=True)
if notice_ids is not None:
notices = notices.filter(pk__in=notice_ids)
self.fields['notice_id'].queryset = notices
self.fields['notice_id'].label_from_instance = lambda n: f'{n.subject} — {n.amount:,} ریال — {n.invoice_number}'
accounts = Q(is_active=True, available_to_customers=True) | Q(is_active=True, paymentnotice__in=notices)
self.fields['destination_account'].queryset = CompanyBankAccount.objects.filter(accounts).distinct()
self.account_warnings = {str(a.pk): {'required': a.requires_deposit_id, 'bank': a.bank.name}
for a in self.fields['destination_account'].queryset.select_related('bank')}
self.customer_deposit_id = generate_id_algorithm(customer.local_mobile) if customer.local_mobile else ''
current = jdatetime.date.fromgregorian(date=timezone.localdate())
self.fields['paid_year'].choices = [('', 'سال')] + [(str(y), str(y)) for y in range(current.year, current.year - 11, -1)]
self.initial.setdefault('submission_key', uuid.uuid4())
self.initial.setdefault('paid_month', str(current.month))
self.initial.setdefault('paid_year', str(current.year))
def clean_paid_amount(self):
return validators.amount(self.cleaned_data['paid_amount'])
def clean_depositor_name(self):
return depositor_name(self.cleaned_data['depositor_name'])
def clean_receipt_image(self):
upload = self.cleaned_data['receipt_image']
self.image_metadata = validators.image(upload)
return upload
def clean(self):
data = super().clean()
notice = data.get('notice_id') if data.get('receipt_mode') == 'linked' else None
data['notice_id'] = notice
if data.get('receipt_mode') == 'linked' and not notice:
self.add_error('notice_id', 'اعلامیه را انتخاب کنید یا واریز مستقل را بزنید.')
try:
data['paid_date'] = validators.paid_date(data.get('paid_day'), data.get('paid_month'), data.get('paid_year'))
if self.data.get('paid_date') and self.data['paid_date'] != data['paid_date'].isoformat():
raise forms.ValidationError('تاریخ ارسالی با تاریخ انتخاب‌شده سازگار نیست؛ دوباره انتخاب کنید.')
except forms.ValidationError as error:
self.add_error('paid_day', error)
account = data.get('destination_account')
if account and not account.available_to_customers and (not notice or notice.account_id != account.pk):
self.add_error('destination_account', 'این حساب فقط برای اعلامیه مربوط به آن قابل انتخاب است.')
return data
+28
View File
@@ -0,0 +1,28 @@
"""Customer identity and the payer recorded on each receipt are independent."""
from django.core.exceptions import ValidationError
from apps.core.persian_text import normalize_persian_text
def customer_name(customer):
profile = getattr(customer, 'customer_profile', None)
name = f'{profile.first_name} {profile.last_name}'.strip() if profile else ''
return normalize_persian_text(name or customer.get_full_name())
def depositor_name(value):
name = normalize_persian_text(value)
if not name.replace('\u200c', '').replace('\u200b', '').strip():
raise ValidationError('نام واریزکننده را وارد کنید.')
if len(name) > 150:
raise ValidationError('نام واریزکننده باید حداکثر ۱۵۰ کاراکتر باشد.')
return name
def receipt_identity(customer, payer):
name = customer_name(customer)
payer = normalize_persian_text(payer)
if not name:
return payer or 'نام ثبت نشده'
if not payer or name == payer:
return name
return f'{name} (واریزکننده: {payer})'
+43
View File
@@ -0,0 +1,43 @@
import re
from django.contrib.auth import logout
from django.db import transaction
from django.db.models import F
from django.shortcuts import redirect, render
from django.utils import timezone
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_http_methods
from . import access, services
from .models import PaymentAccessLink
@never_cache
@require_http_methods(['GET', 'POST'])
def open_link(request, token):
# Keep previously issued long links valid until their original expiry/revocation.
link = access.active_links().filter(token_hash=access.token_digest(token)).first() if re.fullmatch(r'(?:[a-z0-9]{10}|[A-Za-z0-9_-]{43})', token) else None
if link is None:
return access.unavailable_link(request)
if request.user.is_authenticated and request.user.pk != link.customer_id:
confirmed = request.method == 'POST' and request.POST.get('action') == 'logout_and_open' and request.POST.get('account_id') == str(request.user.pk)
if not confirmed:
response = render(request, 'payments/link_confirmation.html')
response['Referrer-Policy'] = 'same-origin'
response['X-Robots-Tag'] = 'noindex, nofollow'
return response
logout(request)
if not request.user.is_authenticated:
access.grant_payment_access(request, link)
# Retain the token across redirects in the session; the link model still stores only its hash.
request.session[access.SHARE_SESSION_KEY] = {'link_id': link.pk, 'token': token}
with transaction.atomic():
PaymentAccessLink.objects.filter(pk=link.pk).update(view_count=F('view_count') + 1, last_used_at=timezone.now())
services.audit(None, 'PAYMENT_LINK_OPENED', link, {
'ip_address': request.META.get('REMOTE_ADDR', ''),
'user_agent': request.META.get('HTTP_USER_AGENT', '')[:500]})
target = link.receipt or link.notice
response = redirect('payments:receipt_detail' if link.receipt_id else 'payments:notice_detail', public_id=target.public_id)
response['Referrer-Policy'] = 'no-referrer'
response['X-Robots-Tag'] = 'noindex, nofollow'
return response
+53
View File
@@ -0,0 +1,53 @@
"""Audit administrative financial grant changes with the actual request actor.
Only selected non-secret fields and payments permissions are captured."""
from django.apps import apps
from django.db import OperationalError, ProgrammingError
from .models import PaymentAuditEvent
TARGETS = {'accounts/user': 'accounts.User', 'auth/group': 'auth.Group',
'staff/organizationalunit': 'staff.OrganizationalUnit', 'staff/unitmembership': 'staff.UnitMembership',
'staff/staffprofile': 'staff.StaffProfile'}
def access_snapshot(obj):
fields = ['is_active','account_type','user_id','unit_id','ticket_scope','role','primary_unit_id']
result = {field:getattr(obj,field) for field in fields if hasattr(obj,field)}
for name in ['permissions','user_permissions']:
relation = getattr(obj,name,None)
if relation is not None:
result[name] = sorted(relation.filter(content_type__app_label='payments').values_list('codename',flat=True))
if hasattr(obj,'groups'):
result['groups'] = sorted(obj.groups.filter(permissions__content_type__app_label='payments').values_list('pk',flat=True).distinct())
return result
class FinancialGrantAuditMiddleware:
def __init__(self,get_response):
self.get_response=get_response
def __call__(self,request):
target = self.target(request)
before = access_snapshot(target) if target else None
response = self.get_response(request)
if target and response.status_code < 400:
try:
target.refresh_from_db()
after=access_snapshot(target)
if before != after:
from .services import audit
audit(request.user,'FINANCIAL_ACCESS_CHANGED',target,{'before':before,'after':after})
except target.DoesNotExist:
from .services import audit
audit(request.user,'FINANCIAL_ACCESS_REMOVED',target,{'before':before})
return response
def target(self,request):
if request.method != 'POST' or not request.user.is_authenticated or not request.path.startswith('/x5admin/'):
return None
parts=request.path.strip('/').split('/')
if len(parts)<5 or not parts[3].isdecimal():
return None
label=TARGETS.get('/'.join(parts[1:3]))
if not label:
return None
return apps.get_model(label).objects.filter(pk=parts[3]).first()
+269
View File
@@ -0,0 +1,269 @@
# Generated by Django 5.1.1 on 2026-10-05 10:44
import apps.payments.models
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('notifications', '0017_alter_smstemplate_event_type'),
('staff', '0012_organizationalunit_supervisor_mobile_5'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BankInstitution',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.SlugField(unique=True)),
('name', models.CharField(max_length=100)),
('is_active', models.BooleanField(default=True)),
('historical', models.BooleanField(default=False)),
],
options={
'ordering': ['historical', 'name'],
},
),
migrations.CreateModel(
name='PaymentAccessConfiguration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('singleton', models.PositiveSmallIntegerField(default=1, editable=False, unique=True)),
('open_view', models.BooleanField(default=True, verbose_name='مشاهده برای همه کارشناسان فعال')),
('open_create', models.BooleanField(default=True, verbose_name='ایجاد برای همه کارشناسان فعال')),
],
options={
'permissions': [('manage_payment_access', 'Manage payment access')],
},
),
migrations.CreateModel(
name='CompanyBankAccount',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('name', models.CharField(max_length=120, verbose_name='نام نمایشی حساب')),
('holder', models.CharField(max_length=150, verbose_name='صاحب حساب')),
('account_number', models.CharField(blank=True, max_length=40, verbose_name='شماره حساب')),
('iban', models.CharField(blank=True, max_length=26, verbose_name='شبا')),
('card_number', models.CharField(blank=True, max_length=16, verbose_name='شماره کارت')),
('requires_deposit_id', models.BooleanField(default=False, verbose_name='شناسه واریز الزامی')),
('is_active', models.BooleanField(default=True, verbose_name='فعال کلی')),
('available_to_staff', models.BooleanField(default=True, verbose_name='قابل انتخاب کارشناسان')),
('available_to_customers', models.BooleanField(default=True, verbose_name='قابل انتخاب مشتریان')),
('sort_order', models.PositiveIntegerField(default=0)),
('bank', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='payments.bankinstitution', verbose_name='بانک')),
],
options={
'ordering': ['sort_order', 'name', 'pk'],
'permissions': [('manage_company_bankaccounts', 'Manage company bank accounts')],
},
),
migrations.CreateModel(
name='PaymentAuditEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('actor_name', models.CharField(max_length=150)),
('action', models.CharField(max_length=50)),
('object_type', models.CharField(max_length=50)),
('object_id', models.CharField(max_length=64)),
('details', models.JSONField(default=dict)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('actor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at', '-pk'],
'permissions': [('view_payment_audit', 'View financial audit')],
},
),
migrations.CreateModel(
name='PaymentNotice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('account_snapshot', models.JSONField()),
('subject', models.CharField(max_length=200)),
('amount', models.BigIntegerField()),
('deposit_id', models.CharField(blank=True, max_length=100)),
('invoice_number', models.CharField(blank=True, max_length=100)),
('description', models.TextField(blank=True)),
('cancelled_at', models.DateTimeField(blank=True, null=True)),
('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='payments.companybankaccount')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='issued_payment_notices', to=settings.AUTH_USER_MODEL)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payment_notices', to=settings.AUTH_USER_MODEL)),
('owning_unit', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit')),
],
options={
'ordering': ['-created_at', '-pk'],
},
),
migrations.CreateModel(
name='PaymentNotification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('context', models.JSONField()),
('rendered_body', models.TextField(blank=True)),
('template_snapshot', models.JSONField(default=dict)),
('job', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='payment_notification', to='notifications.notificationjob')),
('notice', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentnotice')),
('unit', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit')),
],
),
migrations.CreateModel(
name='PaymentNotificationAttempt',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('started_at', models.DateTimeField(auto_now_add=True)),
('finished_at', models.DateTimeField(null=True)),
('success', models.BooleanField(null=True)),
('provider_code', models.CharField(blank=True, max_length=50)),
('provider_id', models.CharField(blank=True, max_length=500)),
('error', models.TextField(blank=True)),
('notification', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='attempt_log', to='payments.paymentnotification')),
],
),
migrations.CreateModel(
name='PaymentReceipt',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('status', models.CharField(choices=[('PENDING', 'در انتظار بررسی'), ('PARTIAL', 'تأیید ناقص'), ('APPROVED', 'تأیید شده'), ('REJECTED', 'رد شده')], db_index=True, default='PENDING', max_length=12)),
('submission_key', models.UUIDField(unique=True)),
('current_version', models.PositiveIntegerField(default=1)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payment_receipts', to=settings.AUTH_USER_MODEL)),
('notice', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='receipts', to='payments.paymentnotice')),
('owning_unit', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit')),
('submitted_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='submitted_payment_receipts', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at', '-pk'],
'permissions': [('approve_paymentreceipt', 'Approve payment receipt'), ('senior_approve_paymentreceipt', 'Senior final approval'), ('submit_customer_receipt', 'Submit receipt for customer'), ('view_all_payments', 'View payments across units')],
},
),
migrations.CreateModel(
name='PaymentReceiptRevision',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('version', models.PositiveIntegerField()),
('amount', models.BigIntegerField()),
('paid_date', models.DateField()),
('account_snapshot', models.JSONField()),
('origin_other', models.CharField(blank=True, max_length=100)),
('deposit_id', models.CharField(blank=True, max_length=100)),
('tracking_number', models.CharField(blank=True, max_length=100)),
('description', models.TextField(blank=True)),
('destination_mismatch', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('image', models.FileField(max_length=500, upload_to=apps.payments.models.receipt_path)),
('mime_type', models.CharField(max_length=100)),
('sha256', models.CharField(max_length=64)),
('original_name', models.CharField(max_length=255)),
('file_size', models.PositiveBigIntegerField()),
('destination', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='payments.companybankaccount')),
('origin_bank', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.bankinstitution')),
('receipt', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='revisions', to='payments.paymentreceipt')),
],
),
migrations.CreateModel(
name='ReceiptApprovalCycle',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('policy_snapshot', models.JSONField(default=dict)),
('status', models.CharField(choices=[('PENDING', 'در انتظار بررسی'), ('PARTIAL', 'تأیید ناقص'), ('APPROVED', 'تأیید شده'), ('REJECTED', 'رد شده')], default='PENDING', max_length=12)),
('finalized_at', models.DateTimeField(blank=True, null=True)),
('finalized_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('revision', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='cycle', to='payments.paymentreceiptrevision')),
],
),
migrations.CreateModel(
name='ReceiptApprovalAssignment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_senior', models.BooleanField(default=False)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assignments', to='payments.receiptapprovalcycle')),
],
),
migrations.AddField(
model_name='paymentnotification',
name='cycle',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.receiptapprovalcycle'),
),
migrations.CreateModel(
name='ReceiptDecision',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(choices=[('APPROVE', 'تأیید'), ('REJECT', 'رد'), ('SENIOR_APPROVE', 'تأیید ارشد')], max_length=20)),
('reason', models.TextField(blank=True)),
('actor_name', models.CharField(max_length=150)),
('created_at', models.DateTimeField(auto_now_add=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='decisions', to='payments.receiptapprovalcycle')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='PaymentApprovalPolicy',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=100, verbose_name='عنوان سیاست')),
('is_active', models.BooleanField(default=True)),
('reviewers', models.ManyToManyField(related_name='payment_review_policies', to=settings.AUTH_USER_MODEL, verbose_name='افراد الزامی تأیید')),
('seniors', models.ManyToManyField(blank=True, related_name='payment_senior_policies', to=settings.AUTH_USER_MODEL, verbose_name='تأییدکنندگان ارشد')),
('unit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit', verbose_name='واحد (خالی: عمومی)')),
],
options={
'permissions': [('manage_payment_approval_policy', 'Manage payment approval policy')],
'constraints': [models.UniqueConstraint(condition=models.Q(('is_active', True), ('unit__isnull', False)), fields=('unit',), name='pay_active_unit_policy'), models.UniqueConstraint(condition=models.Q(('is_active', True), ('unit__isnull', True)), fields=('is_active',), name='pay_active_global_policy')],
},
),
migrations.AddIndex(
model_name='paymentnotice',
index=models.Index(fields=['customer', '-created_at'], name='payments_pa_custome_bc6e3f_idx'),
),
migrations.AddIndex(
model_name='paymentnotice',
index=models.Index(fields=['owning_unit', '-created_at'], name='payments_pa_owning__f2b6af_idx'),
),
migrations.AddConstraint(
model_name='paymentnotice',
constraint=models.CheckConstraint(condition=models.Q(('amount__gt', 0), ('amount__lte', 9007199254740991)), name='pay_notice_amount'),
),
migrations.AddIndex(
model_name='paymentreceipt',
index=models.Index(fields=['customer', '-created_at'], name='payments_pa_custome_e266cc_idx'),
),
migrations.AddIndex(
model_name='paymentreceipt',
index=models.Index(fields=['status', '-created_at'], name='payments_pa_status_5cd50b_idx'),
),
migrations.AddConstraint(
model_name='paymentreceiptrevision',
constraint=models.UniqueConstraint(fields=('receipt', 'version'), name='pay_receipt_revision'),
),
migrations.AddConstraint(
model_name='paymentreceiptrevision',
constraint=models.CheckConstraint(condition=models.Q(('amount__gt', 0), ('amount__lte', 9007199254740991)), name='pay_receipt_amount'),
),
migrations.AddConstraint(
model_name='receiptapprovalassignment',
constraint=models.UniqueConstraint(fields=('cycle', 'user', 'is_senior'), name='pay_cycle_assignment'),
),
migrations.AddConstraint(
model_name='receiptdecision',
constraint=models.UniqueConstraint(fields=('cycle', 'user'), name='pay_one_vote_per_user'),
),
]
@@ -0,0 +1,23 @@
from django.db import migrations
BANKS = [["melli","ملی ایران"],["mellat","ملت"],["saderat","صادرات ایران"],["sepah","سپه"],["tejarat","تجارت"],["refah","رفاه کارگران"],["keshavarzi","کشاورزی"],["maskan","مسکن"],["sanat-madan","صنعت و معدن"],["tosee-saderat","توسعه صادرات ایران"],["tosee-taavon","توسعه تعاون"],["post","پست بانک ایران"],["pasargad","پاسارگاد"],["parsian","پارسیان"],["saman","سامان"],["eghtesad-novin","اقتصاد نوین"],["karafarin","کارآفرین"],["sina","سینا"],["shahr","شهر"],["sarmayeh","سرمایه"],["dey","دی"],["gardeshgari","گردشگری"],["iran-zamin","ایران زمین"],["khavarmianeh","خاورمیانه"],["resalat","قرض‌الحسنه رسالت"],["mehr-iran","قرض‌الحسنه مهر ایران"],["iran-venezuela","مشترک ایران و ونزوئلا"],["melal","مؤسسه اعتباری ملل"],["ayandeh","آینده"],["ansar","انصار"],["ghavamin","قوامین"],["hekmat","حکمت ایرانیان"],["mehr-eghtesad","مهر اقتصاد"],["kosar","مؤسسه کوثر"],["noor","مؤسسه نور"],["tosee","مؤسسه توسعه"]]
TAGS = ['recipient_name','company_name','notice_number','receipt_number','amount_rial','invoice_number','payment_link']
def seed(apps, schema_editor):
Bank = apps.get_model('payments', 'BankInstitution')
Template = apps.get_model('notifications', 'SMSTemplate')
for code, name in BANKS:
Bank.objects.get_or_create(code=code, defaults={'name':name, 'historical':code in {'ayandeh','ansar','ghavamin','hekmat','mehr-eghtesad','kosar','noor','tosee'}})
Bank.objects.get_or_create(code='other', defaults={'name': 'سایر'})
for event, title, body in [
('PAYMENT_NOTICE_CREATED','اعلامیه واریز','{recipient_name}، اعلامیه واریز {notice_number} به مبلغ {amount_rial} ریال برای شما صادر شد.\nمشاهده اطلاعات: {payment_link}\n{company_name}'),
('PAYMENT_RECEIPT_APPROVED','تأیید فیش','{recipient_name}، فیش واریز شما به شماره {receipt_number} و مبلغ {amount_rial} ریال تأیید شد.\nپیگیری: {payment_link}\n{company_name}'),
]:
Template.objects.get_or_create(unit=None,event_type=event, defaults={'title':title,'body':body,'allowed_tags':TAGS,'is_active':True})
apps.get_model('payments','PaymentAccessConfiguration').objects.get_or_create(singleton=1)
class Migration(migrations.Migration):
dependencies = [('payments','0001_initial'), ('notifications','0017_alter_smstemplate_event_type')]
operations = [migrations.RunPython(seed, migrations.RunPython.noop)]
@@ -0,0 +1,19 @@
# Generated by Django 5.1.1 on 2026-10-05 11:05
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payments', '0002_initial_configuration'),
]
operations = [
migrations.AddField(
model_name='paymentnotice',
name='issuance_key',
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
]
@@ -0,0 +1,46 @@
# Generated by Django 5.1.1 on 2026-10-06 05:38
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payments', '0003_paymentnotice_issuance_key'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='PaymentAccessLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token_hash', models.CharField(editable=False, max_length=64, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField()),
('revoked_at', models.DateTimeField(blank=True, null=True)),
('view_count', models.PositiveIntegerField(default=0)),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('notice', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentnotice')),
('receipt', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentreceipt')),
('submitted_receipts', models.ManyToManyField(blank=True, related_name='access_links', to='payments.paymentreceipt')),
],
options={
'verbose_name': 'لینک موقت پرداخت',
'verbose_name_plural': 'لینک\u200cهای موقت پرداخت',
'ordering': ['-created_at'],
},
),
migrations.AddField(
model_name='paymentnotification',
name='access_link',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentaccesslink'),
),
migrations.AddConstraint(
model_name='paymentaccesslink',
constraint=models.CheckConstraint(condition=models.Q(('notice__isnull', False), ('receipt__isnull', False), _connector='OR'), name='payment_link_has_target'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2026-10-06 08:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payments', '0004_paymentaccesslink_paymentnotification_access_link_and_more'),
]
operations = [
migrations.AddField(
model_name='paymentnotice',
name='deleted_at',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
]
@@ -0,0 +1,17 @@
from django.db import migrations
def normalize_existing_identifiers(apps, schema_editor):
digit_map = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')
for name in ('PaymentNotice', 'PaymentReceiptRevision'):
records = apps.get_model('payments', name).objects.using(schema_editor.connection.alias)
for pk, original in records.exclude(deposit_id='').values_list('pk', 'deposit_id').iterator(chunk_size=500):
value = original.translate(digit_map).strip()
normalized = (value.lstrip('0') or '0') if value else ''
if normalized != original:
records.filter(pk=pk).update(deposit_id=normalized)
class Migration(migrations.Migration):
dependencies = [('payments', '0005_paymentnotice_deleted_at')]
operations = [migrations.RunPython(normalize_existing_identifiers, migrations.RunPython.noop)]
@@ -0,0 +1,73 @@
# Generated by Django 5.1.1 on 2026-10-07 09:49
import apps.payments.alert_templates
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('notifications', '0018_alter_smstemplate_body'),
('payments', '0006_normalize_deposit_identifiers'),
]
operations = [
migrations.CreateModel(
name='PaymentReceiptAlertConfiguration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('bale_enabled', models.BooleanField(default=False, verbose_name='ارسال تصویر فیش به بله')),
('bale_token', models.CharField(blank=True, max_length=500, verbose_name='توکن ربات بله پرداخت')),
('bale_chat_id', models.CharField(blank=True, max_length=150, verbose_name='شناسه گروه بله')),
('bale_caption', models.TextField(default='فیش واریزی جدید\nمشتری: {customer_name}\nشماره تماس: {customer_mobile}\nمبلغ: {amount_rial} ریال\nزمان ارسال: {received_at}\nتاریخ واریز: {paid_date}\nشماره فیش: {receipt_number} · نسخه {revision_version}\n{receipt_link}', help_text='تگ\u200cهای مجاز: {amount_rial}، {customer_name}، {customer_mobile}، {received_at}، {paid_date}، {receipt_number}، {receipt_link}، {revision_version}، {manager_name}', max_length=3000, validators=[apps.payments.alert_templates.validate_alert_template], verbose_name='متن همراه تصویر')),
('sms_enabled', models.BooleanField(default=False, verbose_name='پیامک فیش جدید به مدیران')),
('sms_template', models.TextField(default='فیش واریزی به مبلغ {amount_rial} ریال توسط {customer_name} به شماره تماس {customer_mobile} ارسال شد.', help_text='تگ\u200cهای مجاز: {amount_rial}، {customer_name}، {customer_mobile}، {received_at}، {paid_date}، {receipt_number}، {receipt_link}، {revision_version}، {manager_name}', max_length=1000, validators=[apps.payments.alert_templates.validate_alert_template], verbose_name='متن پیامک مدیران')),
('notify_revisions', models.BooleanField(default=True, verbose_name='اطلاع\u200cرسانی فیش اصلاح\u200cشده مشتری')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='آخرین ویرایش')),
],
options={
'verbose_name': 'تنظیمات اطلاع\u200cرسانی فیش مشتری',
'verbose_name_plural': 'تنظیمات اطلاع\u200cرسانی فیش مشتری',
'permissions': [('manage_receipt_alerts', 'Manage receipt alert settings and recipients')],
'constraints': [models.CheckConstraint(condition=models.Q(('pk', 1)), name='pay_receipt_alert_singleton')],
},
),
migrations.CreateModel(
name='PaymentReceiptAlertRecipient',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=150, verbose_name='نام مدیر')),
('mobile', models.CharField(max_length=20, verbose_name='شماره موبایل مدیر')),
('is_active', models.BooleanField(default=True, verbose_name='دریافت پیامک فعال')),
('configuration', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recipients', to='payments.paymentreceiptalertconfiguration')),
],
options={
'verbose_name': 'مدیر دریافت\u200cکننده پیامک فیش',
'verbose_name_plural': 'مدیران دریافت\u200cکننده پیامک فیش',
},
),
migrations.CreateModel(
name='PaymentReceiptAlert',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('recipient_name', models.CharField(blank=True, max_length=150, verbose_name='نام گیرنده هنگام ثبت')),
('recipient_mobile', models.CharField(blank=True, max_length=20, verbose_name='موبایل گیرنده هنگام ثبت')),
('bale_chat_id', models.CharField(blank=True, max_length=150, verbose_name='گروه هنگام ثبت')),
('context', models.JSONField()),
('message_template', models.TextField(verbose_name='قالب هنگام ثبت')),
('rendered_body', models.TextField(blank=True, verbose_name='متن ارسال')),
('job', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='receipt_alert', to='notifications.notificationjob')),
('revision', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='alerts', to='payments.paymentreceiptrevision')),
('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentreceiptalertrecipient')),
],
options={
'verbose_name': 'سابقه اعلان فیش مشتری',
'verbose_name_plural': 'سوابق اعلان فیش مشتری',
},
),
migrations.AddConstraint(
model_name='paymentreceiptalertrecipient',
constraint=models.UniqueConstraint(fields=('configuration', 'mobile'), name='pay_receipt_alert_mobile'),
),
]
@@ -0,0 +1,12 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [('payments', '0007_receipt_alerts')]
operations = [
migrations.AddField(
model_name='paymentreceiptrevision', name='depositor_name',
field=models.CharField('نام واریزکننده', max_length=150, default=''),
preserve_default=False,
),
]
Whitespace-only changes.
+327
View File
@@ -0,0 +1,327 @@
"""Financial records are independent of tickets; issued instructions and evidence are snapshots."""
import uuid
from pathlib import Path
from django.conf import settings
from django.db import models
from apps.core.models import PublicIdModel, TimeStampedModel
from .deposit_identifier import normalize_deposit_identifier
from .alert_templates import DEFAULT_RECEIPT_SMS, DEFAULT_RECEIPT_CAPTION, ALERT_TEMPLATE_HELP, validate_alert_template
MAX_AMOUNT = 9_007_199_254_740_991
class BankInstitution(models.Model):
code = models.SlugField(unique=True)
name = models.CharField(max_length=100)
is_active = models.BooleanField(default=True)
historical = models.BooleanField(default=False)
class Meta:
ordering = ['historical', 'name']
def __str__(self):
return self.name
class CompanyBankAccount(PublicIdModel, TimeStampedModel):
name = models.CharField('نام نمایشی حساب', max_length=120)
bank = models.ForeignKey(BankInstitution, on_delete=models.PROTECT, verbose_name='بانک')
holder = models.CharField('صاحب حساب', max_length=150)
account_number = models.CharField('شماره حساب', max_length=40, blank=True)
iban = models.CharField('شبا', max_length=26, blank=True)
card_number = models.CharField('شماره کارت', max_length=16, blank=True)
requires_deposit_id = models.BooleanField('شناسه واریز الزامی', default=False)
is_active = models.BooleanField('فعال کلی', default=True)
available_to_staff = models.BooleanField('قابل انتخاب کارشناسان', default=True)
available_to_customers = models.BooleanField('قابل انتخاب مشتریان', default=True)
sort_order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ['sort_order', 'name', 'pk']
permissions = [('manage_company_bankaccounts', 'Manage company bank accounts')]
def __str__(self):
return self.name
def snapshot(self):
return {key: getattr(self, key) for key in ('name', 'holder', 'account_number', 'iban', 'card_number', 'requires_deposit_id')} | {'bank': self.bank.name, 'id': str(self.public_id)}
class PaymentAccessConfiguration(models.Model):
singleton = models.PositiveSmallIntegerField(default=1, unique=True, editable=False)
open_view = models.BooleanField('مشاهده برای همه کارشناسان فعال', default=True)
open_create = models.BooleanField('ایجاد برای همه کارشناسان فعال', default=True)
class Meta:
permissions = [('manage_payment_access', 'Manage payment access')]
class PaymentApprovalPolicy(TimeStampedModel):
name = models.CharField('عنوان سیاست', max_length=100)
unit = models.ForeignKey('staff.OrganizationalUnit', null=True, blank=True, on_delete=models.PROTECT, verbose_name='واحد (خالی: عمومی)')
reviewers = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='payment_review_policies', verbose_name='افراد الزامی تأیید')
seniors = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='payment_senior_policies', blank=True, verbose_name='تأییدکنندگان ارشد')
is_active = models.BooleanField(default=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['unit'], condition=models.Q(is_active=True, unit__isnull=False), name='pay_active_unit_policy'),
models.UniqueConstraint(fields=['is_active'], condition=models.Q(is_active=True, unit__isnull=True), name='pay_active_global_policy'),
]
permissions = [('manage_payment_approval_policy', 'Manage payment approval policy')]
def __str__(self):
return self.name
class PaymentNotice(PublicIdModel, TimeStampedModel):
issuance_key = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payment_notices')
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='issued_payment_notices')
owning_unit = models.ForeignKey('staff.OrganizationalUnit', null=True, on_delete=models.PROTECT)
account = models.ForeignKey(CompanyBankAccount, on_delete=models.PROTECT)
account_snapshot = models.JSONField()
subject = models.CharField(max_length=200)
amount = models.BigIntegerField()
deposit_id = models.CharField(max_length=100, blank=True)
invoice_number = models.CharField(max_length=100, blank=True)
description = models.TextField(blank=True)
cancelled_at = models.DateTimeField(null=True, blank=True)
deleted_at = models.DateTimeField(null=True, blank=True, editable=False)
def save(self, *args, **kwargs):
self.deposit_id = normalize_deposit_identifier(self.deposit_id)
super().save(*args, **kwargs)
class Meta:
ordering = ['-created_at', '-pk']
constraints = [models.CheckConstraint(condition=models.Q(amount__gt=0, amount__lte=MAX_AMOUNT), name='pay_notice_amount')]
indexes = [models.Index(fields=['customer', '-created_at']), models.Index(fields=['owning_unit', '-created_at'])]
@property
def number(self):
return f'PN-{self.public_id.hex[:12].upper()}'
class PaymentReceipt(PublicIdModel, TimeStampedModel):
class Status(models.TextChoices):
PENDING = 'PENDING', 'در انتظار بررسی'
PARTIAL = 'PARTIAL', 'تأیید ناقص'
APPROVED = 'APPROVED', 'تأیید شده'
REJECTED = 'REJECTED', 'رد شده'
customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payment_receipts')
submitted_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='submitted_payment_receipts')
notice = models.ForeignKey(PaymentNotice, null=True, blank=True, on_delete=models.PROTECT, related_name='receipts')
owning_unit = models.ForeignKey('staff.OrganizationalUnit', null=True, on_delete=models.PROTECT)
status = models.CharField(max_length=12, choices=Status.choices, default=Status.PENDING, db_index=True)
submission_key = models.UUIDField(unique=True)
current_version = models.PositiveIntegerField(default=1)
class Meta:
ordering = ['-created_at', '-pk']
indexes = [models.Index(fields=['customer', '-created_at']), models.Index(fields=['status', '-created_at'])]
permissions = [('approve_paymentreceipt', 'Approve payment receipt'), ('senior_approve_paymentreceipt', 'Senior final approval'), ('submit_customer_receipt', 'Submit receipt for customer'), ('view_all_payments', 'View payments across units')]
@property
def number(self):
return f'PR-{self.public_id.hex[:12].upper()}'
def receipt_path(instance, filename):
return f'payments/{instance.created_at:%Y/%m}/{uuid.uuid4().hex}{Path(filename).suffix.lower()}'
class PaymentReceiptRevision(models.Model):
receipt = models.ForeignKey(PaymentReceipt, on_delete=models.PROTECT, related_name='revisions')
version = models.PositiveIntegerField()
depositor_name = models.CharField('نام واریزکننده', max_length=150)
amount = models.BigIntegerField()
paid_date = models.DateField()
destination = models.ForeignKey(CompanyBankAccount, on_delete=models.PROTECT)
account_snapshot = models.JSONField()
origin_bank = models.ForeignKey(BankInstitution, null=True, blank=True, on_delete=models.PROTECT)
origin_other = models.CharField(max_length=100, blank=True)
deposit_id = models.CharField(max_length=100, blank=True)
tracking_number = models.CharField(max_length=100, blank=True)
description = models.TextField(blank=True)
destination_mismatch = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
image = models.FileField(upload_to=receipt_path, max_length=500)
mime_type = models.CharField(max_length=100)
sha256 = models.CharField(max_length=64)
original_name = models.CharField(max_length=255)
file_size = models.PositiveBigIntegerField()
def save(self, *args, **kwargs):
self.deposit_id = normalize_deposit_identifier(self.deposit_id)
super().save(*args, **kwargs)
class Meta:
constraints = [models.UniqueConstraint(fields=['receipt', 'version'], name='pay_receipt_revision'), models.CheckConstraint(condition=models.Q(amount__gt=0, amount__lte=MAX_AMOUNT), name='pay_receipt_amount')]
class ReceiptApprovalCycle(models.Model):
revision = models.OneToOneField(PaymentReceiptRevision, on_delete=models.PROTECT, related_name='cycle')
policy_snapshot = models.JSONField(default=dict)
status = models.CharField(max_length=12, choices=PaymentReceipt.Status.choices, default=PaymentReceipt.Status.PENDING)
finalized_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.PROTECT)
finalized_at = models.DateTimeField(null=True, blank=True)
class ReceiptApprovalAssignment(models.Model):
cycle = models.ForeignKey(ReceiptApprovalCycle, on_delete=models.PROTECT, related_name='assignments')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
is_senior = models.BooleanField(default=False)
class Meta:
constraints = [models.UniqueConstraint(fields=['cycle', 'user', 'is_senior'], name='pay_cycle_assignment')]
class ReceiptDecision(models.Model):
cycle = models.ForeignKey(ReceiptApprovalCycle, on_delete=models.PROTECT, related_name='decisions')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
action = models.CharField(max_length=20, choices=[('APPROVE', 'تأیید'), ('REJECT', 'رد'), ('SENIOR_APPROVE', 'تأیید ارشد')])
reason = models.TextField(blank=True)
actor_name = models.CharField(max_length=150)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [models.UniqueConstraint(fields=['cycle', 'user'], name='pay_one_vote_per_user')]
class PaymentAuditEvent(models.Model):
actor = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL)
actor_name = models.CharField(max_length=150)
action = models.CharField(max_length=50)
object_type = models.CharField(max_length=50)
object_id = models.CharField(max_length=64)
details = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
ordering = ['-created_at', '-pk']
permissions = [('view_payment_audit', 'View financial audit')]
class PaymentAccessLink(models.Model):
"""Revocable capability, separate from customer login and ticket magic links."""
customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
notice = models.ForeignKey(PaymentNotice, null=True, blank=True, on_delete=models.PROTECT)
receipt = models.ForeignKey(PaymentReceipt, null=True, blank=True, on_delete=models.PROTECT)
token_hash = models.CharField(max_length=64, unique=True, editable=False)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
revoked_at = models.DateTimeField(null=True, blank=True)
submitted_receipts = models.ManyToManyField(PaymentReceipt, blank=True, related_name='access_links')
view_count = models.PositiveIntegerField(default=0)
last_used_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
constraints = [models.CheckConstraint(
condition=models.Q(notice__isnull=False) | models.Q(receipt__isnull=False),
name='payment_link_has_target')]
verbose_name = 'لینک موقت پرداخت'
verbose_name_plural = 'لینک‌های موقت پرداخت'
class PaymentNotification(models.Model):
job = models.OneToOneField('notifications.NotificationJob', on_delete=models.PROTECT, related_name='payment_notification')
notice = models.ForeignKey(PaymentNotice, null=True, on_delete=models.PROTECT)
cycle = models.ForeignKey(ReceiptApprovalCycle, null=True, on_delete=models.PROTECT)
unit = models.ForeignKey('staff.OrganizationalUnit', null=True, on_delete=models.PROTECT)
context = models.JSONField()
rendered_body = models.TextField(blank=True)
template_snapshot = models.JSONField(default=dict)
access_link = models.ForeignKey(PaymentAccessLink, null=True, blank=True, on_delete=models.PROTECT)
class PaymentNotificationAttempt(models.Model):
notification = models.ForeignKey(PaymentNotification, on_delete=models.PROTECT, related_name='attempt_log')
started_at = models.DateTimeField(auto_now_add=True)
finished_at = models.DateTimeField(null=True)
success = models.BooleanField(null=True)
provider_code = models.CharField(max_length=50, blank=True)
provider_id = models.CharField(max_length=500, blank=True)
error = models.TextField(blank=True)
class PaymentReceiptAlertConfiguration(models.Model):
"""Independent, initially disabled destinations for customer receipt uploads."""
bale_enabled = models.BooleanField('ارسال تصویر فیش به بله', default=False)
bale_token = models.CharField('توکن ربات بله پرداخت', max_length=500, blank=True)
bale_chat_id = models.CharField('شناسه گروه بله', max_length=150, blank=True)
bale_caption = models.TextField('متن همراه تصویر', default=DEFAULT_RECEIPT_CAPTION,
validators=[validate_alert_template], help_text=ALERT_TEMPLATE_HELP, max_length=3000)
sms_enabled = models.BooleanField('پیامک فیش جدید به مدیران', default=False)
sms_template = models.TextField('متن پیامک مدیران', default=DEFAULT_RECEIPT_SMS,
validators=[validate_alert_template], help_text=ALERT_TEMPLATE_HELP, max_length=1000)
notify_revisions = models.BooleanField('اطلاع‌رسانی فیش اصلاح‌شده مشتری', default=True)
updated_at = models.DateTimeField('آخرین ویرایش', auto_now=True)
class Meta:
verbose_name = 'تنظیمات اطلاع‌رسانی فیش مشتری'
verbose_name_plural = 'تنظیمات اطلاع‌رسانی فیش مشتری'
permissions = [('manage_receipt_alerts', 'Manage receipt alert settings and recipients')]
constraints = [models.CheckConstraint(condition=models.Q(pk=1), name='pay_receipt_alert_singleton')]
def __str__(self):
return 'اطلاع‌رسانی فیش مشتری'
def save(self, *args, **kwargs):
self.pk = 1
super().save(*args, **kwargs)
def clean(self):
from django.core.exceptions import ValidationError
self.pk = 1
self.bale_token = self.bale_token.strip()
self.bale_chat_id = self.bale_chat_id.strip()
if self.bale_token and any(char.isspace() or char in '/?#' for char in self.bale_token):
raise ValidationError({'bale_token': 'توکن معتبر ربات را وارد کنید.'})
if self.bale_enabled and (not self.bale_token or not self.bale_chat_id):
raise ValidationError('برای فعال کردن بله، توکن ربات و شناسه گروه را تکمیل کنید.')
class PaymentReceiptAlertRecipient(models.Model):
configuration = models.ForeignKey(PaymentReceiptAlertConfiguration, on_delete=models.PROTECT, related_name='recipients')
name = models.CharField('نام مدیر', max_length=150)
mobile = models.CharField('شماره موبایل مدیر', max_length=20)
is_active = models.BooleanField('دریافت پیامک فعال', default=True)
class Meta:
verbose_name = 'مدیر دریافت‌کننده پیامک فیش'
verbose_name_plural = 'مدیران دریافت‌کننده پیامک فیش'
constraints = [models.UniqueConstraint(fields=['configuration', 'mobile'], name='pay_receipt_alert_mobile')]
def __str__(self):
return self.name
def clean(self):
from apps.accounts.models import normalize_mobile
from django.core.exceptions import ValidationError
from .validators import digits
try:
self.mobile = normalize_mobile(digits(self.mobile))
except ValueError:
raise ValidationError({'mobile': 'شماره موبایل معتبر مدیر را وارد کنید.'})
def save(self, *args, **kwargs):
self.clean()
super().save(*args, **kwargs)
class PaymentReceiptAlert(models.Model):
job = models.OneToOneField('notifications.NotificationJob', on_delete=models.PROTECT, related_name='receipt_alert')
revision = models.ForeignKey(PaymentReceiptRevision, on_delete=models.PROTECT, related_name='alerts')
recipient = models.ForeignKey(PaymentReceiptAlertRecipient, null=True, blank=True, on_delete=models.PROTECT)
recipient_name = models.CharField('نام گیرنده هنگام ثبت', max_length=150, blank=True)
recipient_mobile = models.CharField('موبایل گیرنده هنگام ثبت', max_length=20, blank=True)
bale_chat_id = models.CharField('گروه هنگام ثبت', max_length=150, blank=True)
context = models.JSONField()
message_template = models.TextField('قالب هنگام ثبت')
rendered_body = models.TextField('متن ارسال', blank=True)
class Meta:
verbose_name = 'سابقه اعلان فیش مشتری'
verbose_name_plural = 'سوابق اعلان فیش مشتری'
+91
View File
@@ -0,0 +1,91 @@
from django.conf import settings
from django.db import transaction
from django.urls import reverse
from django.utils import timezone
from apps.notifications.services import enqueue
from .models import PaymentNotification, PaymentNotificationAttempt
EVENTS = {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED', 'PAYMENT_RECEIPT_REJECTED'}
TAGS = ['recipient_name', 'company_name', 'notice_number', 'receipt_number', 'amount_rial', 'invoice_number', 'payment_link', 'rejection_reason']
def notice_sms_pending(notice):
return PaymentNotification.objects.filter(notice=notice, job__status__in=['PENDING', 'PROCESSING', 'RETRYING']).exists()
def context(customer, notice=None, receipt=None, amount=0):
profile = getattr(customer, 'customer_profile', None)
return {'recipient_name': str(profile) if profile else customer.username, 'company_name': settings.COMPANY_NAME,
'notice_number': notice.number if notice else '', 'receipt_number': receipt.number if receipt else '',
'amount_rial': f'{amount:,}', 'invoice_number': notice.invoice_number if notice else '', 'rejection_reason': '',
'payment_link': settings.PUBLIC_BASE_URL + (reverse('payments:receipt_detail', args=[receipt.public_id]) if receipt else reverse('payments:notices') + f'?notice={notice.public_id}')}
def queue_notice(notice):
job = enqueue('SMS', 'PAYMENT_NOTICE_CREATED', None, f'payment-notice:{notice.public_id}', customer=notice.customer, staff_user=notice.created_by)
PaymentNotification.objects.get_or_create(job=job, defaults={'notice': notice, 'unit': notice.owning_unit, 'context': context(notice.customer, notice=notice, amount=notice.amount)})
def queue_approval(receipt, cycle):
job = enqueue('SMS', 'PAYMENT_RECEIPT_APPROVED', None, f'payment-approved:{cycle.pk}', customer=receipt.customer, staff_user=cycle.finalized_by)
PaymentNotification.objects.get_or_create(job=job, defaults={'cycle': cycle, 'unit': receipt.owning_unit, 'context': context(receipt.customer, notice=receipt.notice, receipt=receipt, amount=cycle.revision.amount)})
def queue_rejection(receipt, cycle, reason):
job = enqueue('SMS', 'PAYMENT_RECEIPT_REJECTED', None, f'payment-rejected:{cycle.pk}', customer=receipt.customer, staff_user=cycle.finalized_by)
details = context(receipt.customer, notice=receipt.notice, receipt=receipt, amount=cycle.revision.amount)
PaymentNotification.objects.get_or_create(job=job, defaults={'cycle': cycle, 'unit': receipt.owning_unit,
'context': {**details, 'rejection_reason': reason}})
def payment_sms_body(job, notification):
"""Editable templates must always include the customer's rejection reason."""
from apps.notifications.management.commands.process_notifications import _render_sms
body = _render_sms(job.event_type, notification.context, notification.unit)
reason = notification.context.get('rejection_reason', '')
if reason and reason not in body:
body += '\nدلیل کارشناس: ' + reason
return body
def send_payment_sms(job, provider=None, provider_factory=None):
from apps.notifications.models import SMSTemplate
notification = PaymentNotification.objects.get(job=job)
# Persist the attempt before rendering/provider construction: failed preparation is history too.
attempt = PaymentNotificationAttempt.objects.create(notification=notification)
phase = 'آماده‌سازی قالب پیامک'
try:
with transaction.atomic():
notification = PaymentNotification.objects.select_for_update().get(pk=notification.pk)
if not notification.access_link_id:
from .access import issue_payment_link
receipt = notification.cycle.revision.receipt if notification.cycle_id else None
link, url = issue_payment_link(notice=notification.notice, receipt=receipt)
notification.access_link = link
old_url = notification.context.get('payment_link', '')
if old_url and notification.rendered_body:
notification.rendered_body = notification.rendered_body.replace(old_url, url)
notification.context = {**notification.context, 'payment_link': url}
notification.save(update_fields=['context', 'access_link', 'rendered_body'])
if not notification.rendered_body:
body = payment_sms_body(job, notification)
template = SMSTemplate.objects.filter(event_type=job.event_type, is_active=True, unit=notification.unit).first() if notification.unit_id else None
template = template or SMSTemplate.objects.get(event_type=job.event_type, is_active=True, unit__isnull=True)
notification.rendered_body = body
notification.template_snapshot = {'id': template.pk, 'updated_at': template.updated_at.isoformat()}
notification.save(update_fields=['rendered_body', 'template_snapshot', 'context', 'access_link'])
phase = 'ارتباط با ارائه‌دهنده پیامک'
provider = provider if provider is not None else provider_factory()
result = provider.send_message(job.customer.mobile, notification.rendered_body)
attempt.success = result.success
attempt.provider_id = str(result.provider_id or '')[:500]
attempt.provider_code = str(result.provider_code or '')[:50]
attempt.error = str(result.error or '')[:1000]
return result, None
except Exception:
attempt.success = False
attempt.error = 'خطا در ' + phase
raise
finally:
attempt.finished_at = timezone.now()
attempt.save()
+86
View File
@@ -0,0 +1,86 @@
from django.db.models import Q
from .models import PaymentAccessConfiguration, PaymentNotice
def is_staff(user):
profile = getattr(user, 'staff_profile', None)
return bool(user.is_authenticated and user.is_active and user.account_type == 'STAFF' and (profile is None or profile.is_active))
def has_permission(user, codename, unit=None):
if not is_staff(user):
return False
if user.has_perm(f'payments.{codename}'):
return True
if unit is None or not unit.is_active:
return False
return user.unit_memberships.filter(is_active=True, unit=unit).filter(
Q(permissions__content_type__app_label='payments', permissions__codename=codename) |
Q(unit__permissions__content_type__app_label='payments', unit__permissions__codename=codename)
).exists()
def config():
# No get_or_create on a read request; defaults keep initial view/create open only.
return PaymentAccessConfiguration.objects.first() or PaymentAccessConfiguration()
def can_create(user, unit=None):
if unit is None and is_staff(user):
from apps.tickets.policies import TicketPolicy
unit = TicketPolicy.primary_unit(user)
return is_staff(user) and (config().open_create or has_permission(user, 'add_paymentnotice', unit))
def can_manage_banks(user):
profile = getattr(user, 'staff_profile', None)
return is_staff(user) and (user.is_superuser or getattr(profile, 'role', '') == 'CEO' or has_permission(user, 'manage_company_bankaccounts'))
def may_delete_notice(user, notice):
role = getattr(getattr(user, 'staff_profile', None), 'role', '')
return is_staff(user) and (notice.created_by_id == user.pk or user.is_superuser or role in {'CEO', 'GENERAL_ADMIN'})
def can_delete_notice(user, notice):
return may_delete_notice(user, notice) and not notice.deleted_at and not notice.receipts.exists()
def notice_actions(queryset, user):
"""Allow deletion actors to reach this notice without broadening other lists."""
queryset = queryset.filter(deleted_at__isnull=True)
if not is_staff(user):
return queryset.none()
role = getattr(getattr(user, 'staff_profile', None), 'role', '')
if user.is_superuser or role in {'CEO', 'GENERAL_ADMIN'}:
return queryset
return queryset.filter(Q(created_by=user) | Q(pk__in=visible(queryset, user).values('pk')))
def visible(queryset, user):
if queryset.model == PaymentNotice:
queryset = queryset.filter(deleted_at__isnull=True)
if not user.is_authenticated or not user.is_active:
return queryset.none()
if user.account_type == 'CUSTOMER':
return queryset.filter(customer=user)
if not is_staff(user):
return queryset.none()
if config().open_view or user.has_perm('payments.view_all_payments') or user.is_superuser:
return queryset
permission = f'view_{queryset.model._meta.model_name}'
if user.has_perm(f'payments.{permission}'):
return queryset
units = user.unit_memberships.filter(is_active=True, unit__is_active=True).filter(
Q(permissions__content_type__app_label='payments', permissions__codename=permission) |
Q(unit__permissions__content_type__app_label='payments', unit__permissions__codename=permission)
).values_list('unit_id', flat=True)
return queryset.filter(owning_unit_id__in=units).distinct()
def can_decide(user, receipt, cycle, senior=False):
# Selection in the approval policy grants the role for this cycle. Requiring
# an additional Django permission made selected non-admin staff unable to vote.
return (is_staff(user) and receipt.submitted_by_id != user.pk and
visible(type(receipt).objects.filter(pk=receipt.pk), user).exists() and
cycle.assignments.filter(user=user, is_senior=senior).exists())
+106
View File
@@ -0,0 +1,106 @@
"""Receipt-only outbox handlers. No network operation runs during receipt registration."""
import logging
from pathlib import Path
from django.conf import settings
from django.db import transaction
from django.urls import reverse
from apps.core.templatetags.jalali import jalali_date, jalali_datetime
from apps.integrations.sms.base import SMSResult
from apps.notifications.services import enqueue
from .alert_templates import validate_alert_template
from .models import PaymentReceiptAlert, PaymentReceiptAlertConfiguration
logger = logging.getLogger(__name__)
RECEIPT_ALERT_EVENT = 'PAYMENT_RECEIPT_RECEIVED'
def receipt_alert_context(revision):
from .identity import receipt_identity
customer = revision.receipt.customer
return {'customer_name': receipt_identity(customer, revision.depositor_name),
'depositor_name': revision.depositor_name,
'customer_mobile': customer.local_mobile, 'amount_rial': f'{revision.amount:,}',
'received_at': jalali_datetime(revision.created_at), 'paid_date': jalali_date(revision.paid_date),
'receipt_number': revision.receipt.number, 'revision_version': str(revision.version), 'manager_name': '',
'receipt_link': settings.PUBLIC_BASE_URL.rstrip('/') + reverse('staff_payments:receipt_detail', args=[revision.receipt.public_id])}
def queue_receipt_alerts(revision, actor):
# Submission/revision services authorize staff before reaching this queue.
# Keep customer submissions restricted to their own receipt.
if actor.account_type != 'STAFF' and (actor.account_type != 'CUSTOMER' or actor.pk != revision.receipt.customer_id):
return
configuration = PaymentReceiptAlertConfiguration.objects.filter(pk=1).first()
if not configuration or not (configuration.bale_enabled or configuration.sms_enabled) or (revision.version > 1 and not configuration.notify_revisions):
return
context = receipt_alert_context(revision)
destinations = []
if configuration.bale_enabled:
destinations.append(('BALE', None, configuration.bale_caption))
if configuration.sms_enabled:
destinations.extend(('SMS', recipient, configuration.sms_template) for recipient in configuration.recipients.filter(is_active=True))
for channel, recipient, template in destinations:
try:
with transaction.atomic():
job = enqueue(channel, RECEIPT_ALERT_EVENT, None,
f'receipt-alert:{revision.pk}:{channel}:{recipient.pk if recipient else "group"}', customer=revision.receipt.customer)
PaymentReceiptAlert.objects.get_or_create(job=job, defaults={'revision': revision, 'recipient': recipient,
'recipient_name': recipient.name if recipient else '', 'recipient_mobile': recipient.mobile if recipient else '',
'bale_chat_id': configuration.bale_chat_id if channel == 'BALE' else '', 'message_template': template,
'context': {**context, 'manager_name': recipient.name if recipient else ''}})
except Exception as error:
logger.error('Receipt alert destination queue failed revision=%s channel=%s type=%s', revision.pk, channel, type(error).__name__)
def safe_queue_receipt_alerts(revision, actor):
# A failed configuration lookup/queue write rolls back only this savepoint.
try:
with transaction.atomic():
queue_receipt_alerts(revision, actor)
except Exception as error:
logger.error('Receipt alert queue failed revision=%s type=%s', revision.pk, type(error).__name__)
def prepare_receipt_alert(job):
configuration = PaymentReceiptAlertConfiguration.objects.filter(pk=1).first()
alert = PaymentReceiptAlert.objects.select_related('recipient').get(job=job)
if job.channel == 'BALE':
enabled = configuration and configuration.bale_enabled and configuration.bale_chat_id == alert.bale_chat_id
else:
enabled = configuration and configuration.sms_enabled and alert.recipient and alert.recipient.is_active and alert.recipient.mobile == alert.recipient_mobile
if enabled:
return True
job.status = 'CANCELLED'
job.last_error = 'اطلاع‌رسانی این کانال یا گیرنده غیرفعال شده یا مقصد تغییر کرده است.'
job.save(update_fields=['status', 'last_error'])
return False
def send_receipt_alert(job, sms_provider_factory=None):
"""A destination failure is returned to the worker's existing bounded retry policy."""
try:
alert = PaymentReceiptAlert.objects.select_related('revision').get(job=job)
if not alert.rendered_body:
validate_alert_template(alert.message_template)
alert.rendered_body = alert.message_template.format(**alert.context)
if alert.context.get('depositor_name') and '{customer_name}' not in alert.message_template:
alert.rendered_body += '\nمشتری / واریزکننده: ' + alert.context['customer_name']
alert.save(update_fields=['rendered_body'])
if job.channel == 'SMS':
from apps.integrations.sms.providers import get_sms_provider
return (sms_provider_factory or get_sms_provider)().send_message(alert.recipient_mobile, alert.rendered_body)
from apps.integrations.bale.providers import BaleBotProvider
configuration = PaymentReceiptAlertConfiguration.objects.get(pk=1)
if not configuration.bale_token:
return SMSResult(False, error='توکن ربات بله پرداخت تنظیم نشده است.')
if len(alert.rendered_body) > 4096:
return SMSResult(False, error='متن همراه تصویر بله بیش از ۴۰۹۶ کاراکتر است.')
with alert.revision.image.open('rb') as image:
return BaleBotProvider().send_photo(alert.bale_chat_id, image, alert.rendered_body,
token=configuration.bale_token, filename=f'receipt-{alert.revision.pk}{Path(alert.revision.image.name).suffix}',
mime_type=alert.revision.mime_type)
except Exception as error:
# HTTP exception strings can contain credentials in the request URL. Never persist them.
return SMSResult(False, error=f'خطا در ارسال اعلان فیش ({type(error).__name__})')
+145
View File
@@ -0,0 +1,145 @@
"""Bounded-query projections use only the current evidence revision for financial totals."""
from datetime import datetime, time, timedelta
from django.urls import reverse
from django.db.models import BigIntegerField, Count, F, OuterRef, Q, Subquery, Sum, Value
from django.db.models.functions import Coalesce
from django.utils import timezone
from .models import PaymentReceipt, PaymentReceiptRevision
def daily_payment_summary(receipts, notices):
"""Summarize permission-scoped, unfiltered querysets in two aggregate queries.
Submission means initial receipt creation. Final outcomes use the current
revision's finalization time, including receipts submitted on earlier days.
Superseded revisions and partial votes never count as final outcomes.
"""
local_timezone = timezone.get_default_timezone()
today = timezone.localdate(timezone=local_timezone)
start = timezone.make_aware(datetime.combine(today, time.min), local_timezone)
end = timezone.make_aware(datetime.combine(today + timedelta(days=1), time.min), local_timezone)
current = PaymentReceiptRevision.objects.filter(
receipt_id=OuterRef('pk'), version=OuterRef('current_version'))
receipts = receipts.annotate(
summary_amount=Subquery(current.values('amount')[:1]),
summary_finalized_at=Subquery(current.values('cycle__finalized_at')[:1]))
submitted = Q(created_at__gte=start, created_at__lt=end)
finalized = Q(summary_finalized_at__gte=start, summary_finalized_at__lt=end)
approved = finalized & Q(status=PaymentReceipt.Status.APPROVED)
rejected = finalized & Q(status=PaymentReceipt.Status.REJECTED)
pending = Q(status__in=[PaymentReceipt.Status.PENDING, PaymentReceipt.Status.PARTIAL])
summary = receipts.aggregate(
submitted_count=Count('pk', filter=submitted),
submitted_amount=Sum('summary_amount', filter=submitted, default=0),
approved_count=Count('pk', filter=approved),
approved_amount=Sum('summary_amount', filter=approved, default=0),
rejected_count=Count('pk', filter=rejected),
rejected_amount=Sum('summary_amount', filter=rejected, default=0),
pending_count=Count('pk', filter=pending),
pending_amount=Sum('summary_amount', filter=pending, default=0))
summary['notice_count'] = notices.filter(created_at__gte=start, created_at__lt=end).count()
summary['date'] = today
return summary
def notice_sms_history(notice):
"""Separate gateway acceptance from final handset delivery; preserve every send attempt."""
from django.db.models import Prefetch
from .models import PaymentNotificationAttempt
notifications = notice.paymentnotification_set.select_related('job__staff_user__staff_profile').prefetch_related(
Prefetch('attempt_log', queryset=PaymentNotificationAttempt.objects.order_by('-started_at', '-pk'), to_attr='send_attempts')
).order_by('-job__created_at', '-pk')
for notification in notifications:
job = notification.job
notification.manual_resend = ':resend:' in job.dedupe_key
notification.delivery_class = 'info'
if job.status != 'SENT':
notification.delivery_label = 'هنوز ارسال موفق ثبت نشده است.'
elif not job.provider_message_id:
notification.delivery_label = 'شناسه رهگیری ارائه نشده؛ دریافت توسط گوشی قابل تأیید نیست.'
elif not job.delivery_status:
notification.delivery_label = 'دریافت توسط گوشی قابل تأیید نیست.'
elif job.delivery_is_final:
notification.delivery_label = job.get_delivery_status_display()
notification.delivery_class = 'approved' if job.delivery_status == 'DELIVERED' else 'rejected'
elif job.delivery_status == 'PENDING':
notification.delivery_label = 'در انتظار استعلام دریافت توسط گوشی'
else:
suffix = 'در حال پیگیری' if job.delivery_next_check_at else 'دریافت توسط گوشی تأیید نشده است'
notification.delivery_label = f'نتیجه غیرقطعی: {job.get_delivery_status_display()}؛ {suffix}'
return notifications
def notices_with_totals(queryset):
approved = PaymentReceiptRevision.objects.filter(
receipt__notice_id=OuterRef('pk'), receipt__status='APPROVED',
version=F('receipt__current_version'), destination_mismatch=False
).values('receipt__notice_id').annotate(total=Sum('amount')).values('total')
return queryset.annotate(approved_total=Coalesce(Subquery(approved), Value(0), output_field=BigIntegerField()))
def receipts_with_current_values(queryset):
current = PaymentReceiptRevision.objects.filter(receipt_id=OuterRef('pk'), version=OuterRef('current_version'))
return queryset.annotate(current_amount=Subquery(current.values('amount')[:1]),
current_depositor_name=Subquery(current.values('depositor_name')[:1]),
current_paid_date=Subquery(current.values('paid_date')[:1]))
def notice_form_data(notice, staff=False):
"""Customer-owned notice summary; account numbers stay on the notice page."""
return {'id': str(notice.pk), 'subject': notice.subject, 'amount': str(notice.amount),
'account': notice.account_id, 'deposit': notice.deposit_id,
'invoice': notice.invoice_number,
'url': reverse('staff_payments:notice_detail' if staff else 'payments:notice_detail', args=[notice.public_id])}
def receipt_review_summaries(receipts):
"""Attach current-version decisions in one query, preserving snapshot actor names."""
from .models import ReceiptDecision
receipts = list(receipts)
indexed = {receipt.pk: receipt for receipt in receipts}
for receipt in receipts:
receipt.review_decisions = []
if indexed:
decisions = ReceiptDecision.objects.filter(
cycle__revision__receipt_id__in=indexed,
cycle__revision__version=F('cycle__revision__receipt__current_version')
).select_related('cycle__revision').order_by('created_at', 'pk')
for decision in decisions:
indexed[decision.cycle.revision.receipt_id].review_decisions.append(decision)
return receipts
def attach_notice_receipts(notices, visible_receipts):
"""Only fetch linked receipts visible to the current viewer, for this page."""
notices = list(notices)
indexed = {notice.pk: notice for notice in notices}
for notice in notices:
notice.related_receipts = []
if indexed:
receipts = receipt_review_summaries(receipts_with_current_values(
visible_receipts.filter(notice_id__in=indexed)))
for receipt in receipts:
indexed[receipt.notice_id].related_receipts.append(receipt)
return notices
def recent_pending_notice_choices(form, retain_original=None):
"""Limit the visible menu, keeping server-side ownership validation unsliced.
An explicitly selected pending notice replaces the last option when needed.
A revision retains its original notice even if it has since been settled.
This does not change which notices the financial service may validate.
"""
field = form.fields['notice_id']
pending = notices_with_totals(field.queryset).filter(amount__gt=F('approved_total'))
available = list(pending.order_by('-created_at', '-pk')[:10])
selected = form['notice_id'].value()
if selected and not any(str(n.pk) == str(selected) for n in available):
source = field.queryset if str(selected) == str(retain_original) else pending
selected_notice = source.filter(pk=selected).first() if str(selected).isdecimal() and len(str(selected)) <= 19 and int(selected) <= 9223372036854775807 else None
if selected_notice:
available = [selected_notice] + available[:9]
field.widget.choices = [('', field.empty_label)] + [(n.pk, field.label_from_instance(n)) for n in available]
return available
+355
View File
@@ -0,0 +1,355 @@
"""All business writes go through these atomic, permission-checked operations."""
from django.core.exceptions import PermissionDenied, ValidationError
from django.core import serializers
import json
from django.db import transaction
from django.utils import timezone
from apps.accounts.models import User
from apps.tickets.policies import TicketPolicy
from . import policies, validators
from .models import (CompanyBankAccount, PaymentNotice, PaymentReceipt, PaymentReceiptRevision,
PaymentApprovalPolicy, ReceiptApprovalCycle, ReceiptApprovalAssignment,
ReceiptDecision, PaymentAuditEvent)
def actor_name(actor):
profile = getattr(actor, 'staff_profile', None)
return getattr(profile, 'display_name', '') or actor.get_full_name() or actor.username
@transaction.atomic
def resend_notice_sms(actor, notice, request_key):
from uuid import UUID
from .notifications import context, notice_sms_pending
from .models import PaymentNotification
from apps.notifications.services import enqueue
if not policies.can_create(actor, notice.owning_unit) or not policies.notice_actions(PaymentNotice.objects.filter(pk=notice.pk), actor).exists():
raise PermissionDenied
try:
key = str(UUID(str(request_key)))
except (ValueError, TypeError, AttributeError):
raise ValidationError('درخواست ارسال معتبر نیست؛ صفحه را دوباره باز کنید.')
# Same customer -> notice lock order as issuance, receipt submission and deletion.
customer = User.objects.select_for_update().get(pk=notice.customer_id)
notice = PaymentNotice.objects.select_for_update().get(pk=notice.pk)
if notice.deleted_at or notice.cancelled_at or not customer.is_active or not customer.mobile:
raise ValidationError('این اعلامیه یا مشتری امکان ارسال پیامک ندارد.')
dedupe_key = f'payment-notice:{notice.public_id}:resend:{key}'
previous = PaymentNotification.objects.filter(notice=notice, job__dedupe_key=dedupe_key).select_related('job').first()
if previous:
return previous.job
if notice_sms_pending(notice):
raise ValidationError('پیامک این اعلامیه در صف یا در حال ارسال است؛ تا پایان تلاش جاری منتظر بمانید.')
job = enqueue('SMS', 'PAYMENT_NOTICE_CREATED', None, dedupe_key, customer=customer, staff_user=actor)
PaymentNotification.objects.create(job=job, notice=notice, unit=notice.owning_unit,
context=context(customer, notice=notice, amount=notice.amount))
audit(actor, 'NOTICE_SMS_RESEND_REQUESTED', notice, {'job_id': job.pk})
return job
def audit(actor, action, obj, details=None):
return PaymentAuditEvent.objects.create(actor=actor, actor_name=actor_name(actor) if actor else 'SYSTEM',
action=action, object_type=obj._meta.label_lower, object_id=str(getattr(obj, 'public_id', obj.pk)), details=details or {})
def configuration_snapshot(obj):
"""Serialize only the financial configuration passed here, including flags and M2M IDs."""
snapshot = json.loads(serializers.serialize('json', [obj]))[0]['fields']
if 'bale_token' in snapshot:
snapshot['bale_token'] = '[configured]' if snapshot['bale_token'] else ''
return snapshot
@transaction.atomic
def delete_notice(actor, notice):
# Receipt submission locks the same customer, then notice. Do not lock access
# links here: anonymous submission already holds its link before the customer.
actor = User.objects.select_related('staff_profile').get(pk=actor.pk)
User.objects.select_for_update().get(pk=notice.customer_id)
notice = PaymentNotice.objects.select_for_update().get(pk=notice.pk)
if not policies.may_delete_notice(actor, notice):
raise PermissionDenied
if notice.receipts.exists():
raise ValidationError('برای این اعلامیه فیش ثبت شده است؛ حذف آن امکان‌پذیر نیست، حتی اگر فیش رد شده باشد.')
if notice.deleted_at:
return
notice.deleted_at = notice.cancelled_at = timezone.now()
notice.save(update_fields=['deleted_at', 'cancelled_at', 'updated_at'])
from apps.notifications.models import NotificationJob
NotificationJob.objects.filter(payment_notification__notice=notice,
status__in=['PENDING', 'RETRYING', 'FAILED']).update(status='CANCELLED',
last_error='اعلامیه واریز حذف شده است.')
audit(actor, 'NOTICE_DELETED', notice, {'number': notice.number, 'customer_id': notice.customer_id,
'subject': notice.subject, 'amount': notice.amount})
@transaction.atomic
def issue_notice(actor, data):
unit = TicketPolicy.primary_unit(actor)
if not policies.can_create(actor, unit):
raise PermissionDenied
# Keep lock order consistent with receipt submission: customer, then account.
customer = User.objects.select_for_update().get(pk=data['customer'].pk, account_type='CUSTOMER', is_active=True)
account = CompanyBankAccount.objects.select_for_update(of=('self',)).select_related('bank').get(pk=data['account'].pk)
if not account.is_active or not account.available_to_staff:
raise ValidationError('این حساب دیگر برای صدور اعلامیه فعال نیست.')
from .deposit_identifier import normalize_deposit_identifier
deposit_id = normalize_deposit_identifier(data.get('deposit_id', ''))
if account.requires_deposit_id and not deposit_id:
raise ValidationError('برای این حساب شناسه واریز مشتری الزامی است.')
key = data.get('submission_key')
if key:
existing = PaymentNotice.objects.filter(issuance_key=key).first()
if existing:
if existing.created_by_id != actor.pk or existing.customer_id != customer.pk:
raise PermissionDenied
expected = {'account_id': account.pk, 'amount': validators.amount(data['amount']),
'deposit_id': deposit_id, 'subject': data['subject'],
'invoice_number': data.get('invoice_number', ''), 'description': data.get('description', '')}
if any(getattr(existing, field) != value for field, value in expected.items()):
raise ValidationError('این اعلامیه قبلاً ثبت شده است؛ برای اطلاعات متفاوت فرم تازه باز کنید.')
return existing
notice = PaymentNotice.objects.create(customer=customer, created_by=actor, owning_unit=unit, account=account,
account_snapshot=account.snapshot(), amount=validators.amount(data['amount']), deposit_id=deposit_id,
subject=data['subject'], invoice_number=data.get('invoice_number', ''), description=data.get('description', ''),
**({'issuance_key': key} if key else {}))
audit(actor, 'NOTICE_ISSUED', notice, {'account': account.snapshot(), 'amount': str(notice.amount), 'deposit_id': deposit_id})
from .notifications import queue_notice
queue_notice(notice)
return notice
def policy_for(unit):
policy = PaymentApprovalPolicy.objects.filter(is_active=True, unit=unit).first() if unit else None
return policy or PaymentApprovalPolicy.objects.filter(is_active=True, unit__isnull=True).first()
def assign_cycle(revision, actor):
policy = policy_for(revision.receipt.owning_unit)
reviewer_ids = list(policy.reviewers.values_list('pk', flat=True)) if policy else []
senior_ids = list(policy.seniors.values_list('pk', flat=True)) if policy else []
selected = User.objects.filter(pk__in=set(reviewer_ids + senior_ids))
if policy and (not reviewer_ids or any(not policies.is_staff(user) for user in selected)):
raise ValidationError('سیاست تأیید معتبر نیست؛ مدیر باید افراد فعال تأییدکننده را تعیین کند.')
cycle = ReceiptApprovalCycle.objects.create(revision=revision, policy_snapshot={
'policy_id': policy.pk if policy else None, 'name': policy.name if policy else 'هنوز تعیین نشده',
'method': 'ALL_SELECTED', 'reviewers': reviewer_ids, 'seniors': senior_ids})
ReceiptApprovalAssignment.objects.bulk_create([
ReceiptApprovalAssignment(cycle=cycle, user_id=pk, is_senior=senior)
for senior, ids in [(False, reviewer_ids), (True, senior_ids)] for pk in ids])
audit(actor, 'CYCLE_STARTED', revision.receipt, {'version': revision.version, 'policy': cycle.policy_snapshot})
return cycle
def receipt_metadata(data):
from .deposit_identifier import normalize_deposit_identifier
from .identity import depositor_name
return {'depositor_name': depositor_name(data.get('depositor_name')), 'amount': data['paid_amount'], 'paid_date': data['paid_date'], 'destination': data['destination_account'],
'origin_bank': data.get('origin_bank'), 'origin_other': data.get('origin_other', ''),
'deposit_id': normalize_deposit_identifier(data.get('deposit_id', '')), 'tracking_number': data.get('tracking_number', ''),
'description': data.get('description', '')}
def validate_destination(account, notice):
if not account.is_active:
raise ValidationError('این حساب غیرفعال شده است؛ برای ثبت پرداخت قبلی با کارشناس هماهنگ کنید.')
if not account.available_to_customers and (not notice or notice.account_id != account.pk):
raise ValidationError('حساب مقصد برای این واریز قابل انتخاب نیست.')
def _submit_receipt(actor, customer, data, file_info, created_files):
fields = receipt_metadata(data)
# Same-customer lock serializes retries of a submission nonce before inserting it.
User.objects.select_for_update().get(pk=customer.pk)
existing = PaymentReceipt.objects.filter(submission_key=data['submission_key']).first()
if existing:
revision = existing.revisions.get(version=1)
if existing.customer_id != customer.pk or existing.submitted_by_id != actor.pk:
raise PermissionDenied
if (revision.amount != data['paid_amount'] or revision.paid_date != data['paid_date'] or
revision.destination_id != data['destination_account'].pk or revision.sha256 != file_info['sha256'] or
existing.notice_id != getattr(data.get('notice_id'), 'pk', None) or
revision.depositor_name != fields['depositor_name']):
raise ValidationError('این ارسال قبلاً ثبت شده است؛ برای اطلاعات متفاوت فرم تازه باز کنید.')
return existing
notice = data.get('notice_id')
if notice:
notice = PaymentNotice.objects.select_for_update().get(pk=notice.pk, customer=customer)
if notice.cancelled_at:
raise ValidationError('اعلامیه لغو شده است؛ واریز مستقل را انتخاب کنید.')
account = CompanyBankAccount.objects.select_for_update().select_related('bank').get(pk=data['destination_account'].pk)
validate_destination(account, notice)
receipt = PaymentReceipt.objects.create(customer=customer, submitted_by=actor, notice=notice,
owning_unit=notice.owning_unit if notice else (TicketPolicy.primary_unit(actor) if policies.is_staff(actor) else None), submission_key=data['submission_key'])
fields['destination'] = account
fields['deposit_id'] = fields['deposit_id'] or (notice.deposit_id if notice and notice.account_id == account.pk else '')
revision = PaymentReceiptRevision(receipt=receipt, version=1,
account_snapshot=notice.account_snapshot if notice and notice.account_id == account.pk else account.snapshot(),
destination_mismatch=bool(notice and notice.account_id != account.pk), image=data['receipt_image'], **file_info, **fields)
try:
revision.save()
finally:
if revision.image and revision.image._committed:
created_files.append((revision.image.storage, revision.image.name))
assign_cycle(revision, actor)
audit(actor, 'RECEIPT_SUBMITTED', receipt, {'version': 1, 'amount': str(revision.amount), 'sha256': revision.sha256})
from .receipt_alerts import safe_queue_receipt_alerts
safe_queue_receipt_alerts(revision, actor)
return receipt
def submit_receipt(actor, customer, data, *, access_link=None):
if not actor.is_active or customer.account_type != 'CUSTOMER' or not customer.is_active:
raise PermissionDenied
if actor.pk != customer.pk and not policies.can_create(actor):
raise PermissionDenied
if actor.account_type == 'CUSTOMER' and actor.pk != customer.pk:
raise PermissionDenied
data = dict(data)
data['paid_amount'] = validators.amount(data['paid_amount'])
if data['paid_date'] > timezone.localdate():
raise ValidationError('تاریخ واریز نمی‌تواند بعد از امروز باشد.')
file_info = validators.image(data['receipt_image'])
# File storage is not transactional. Remove only the file created by this failed write.
created_files = []
try:
with transaction.atomic():
if access_link:
from .access import validate_link_submission
validate_link_submission(access_link, customer, data)
result = _submit_receipt(actor, customer, data, file_info, created_files)
if access_link:
access_link.submitted_receipts.add(result)
return result
except Exception:
for storage, name in created_files:
storage.delete(name)
raise
@transaction.atomic
def decide_receipt(actor, receipt_id, version, action, reason='', *, rejection_confirmed=True):
# Lock only the receipt: PostgreSQL cannot lock the nullable side of a LEFT JOIN.
receipt = PaymentReceipt.objects.select_for_update(of=('self',)).select_related('owning_unit').get(public_id=receipt_id)
# Never authorize a financial vote using a stale User/profile/permission cache.
actor = User.objects.select_for_update().get(pk=actor.pk)
if receipt.current_version != version:
raise ValidationError('اطلاعات تغییر کرده است؛ نسخه تازه را دوباره بررسی کنید.')
revision = receipt.revisions.get(version=version)
cycle = ReceiptApprovalCycle.objects.select_for_update().get(revision=revision)
if action not in {'APPROVE', 'REJECT', 'SENIOR_APPROVE'}:
raise ValidationError('نوع تصمیم معتبر نیست.')
allowed = policies.can_decide(actor, receipt, cycle, senior=action == 'SENIOR_APPROVE')
# Assigned seniors may also reject with a reason; all permission, visibility,
# own-submission and assignment checks remain in can_decide.
if not allowed and action == 'REJECT':
allowed = policies.can_decide(actor, receipt, cycle, senior=True)
if not allowed:
raise PermissionDenied
if action == 'REJECT' and not rejection_confirmed:
raise ValidationError('برای رد فیش، ارسال دلیل رد به مشتری را تأیید کنید.')
reason = reason.strip()
if action in {'REJECT', 'SENIOR_APPROVE'} and not reason:
raise ValidationError('دلیل این تصمیم را بنویسید.')
old = cycle.decisions.filter(user=actor).first()
if old:
if old.action == action and old.reason == reason:
return receipt
raise ValidationError('تصمیم شما ثبت شده است؛ تغییر آن نیازمند چرخه جدید است.')
if receipt.status not in {'PENDING', 'PARTIAL'}:
raise ValidationError('بررسی این فیش پایان یافته است.')
if not cycle.assignments.filter(is_senior=False).exists():
raise ValidationError('مدیر باید ابتدا افراد تأییدکننده را تعیین کند.')
if revision.destination_mismatch and action != 'REJECT' and not reason:
raise ValidationError('حساب مقصد با اعلامیه متفاوت است؛ نتیجه بررسی مغایرت را بنویسید.')
ReceiptDecision.objects.create(cycle=cycle, user=actor, action=action, reason=reason, actor_name=actor_name(actor))
if action == 'REJECT':
status = 'REJECTED'
elif action == 'SENIOR_APPROVE':
status = 'APPROVED'
else:
required = set(cycle.assignments.filter(is_senior=False).values_list('user_id', flat=True))
voted = set(cycle.decisions.filter(action='APPROVE').values_list('user_id', flat=True))
status = 'APPROVED' if required <= voted else 'PARTIAL'
cycle.status = receipt.status = status
if status in {'APPROVED', 'REJECTED'}:
cycle.finalized_by = actor
cycle.finalized_at = timezone.now()
cycle.save(update_fields=['status', 'finalized_by', 'finalized_at'])
receipt.save(update_fields=['status', 'updated_at'])
audit(actor, action, receipt, {'version': version, 'reason': reason, 'status': status, 'cycle': cycle.pk})
if status == 'APPROVED':
from .notifications import queue_approval
queue_approval(receipt, cycle)
elif status == 'REJECTED':
from .notifications import queue_rejection
queue_rejection(receipt, cycle, reason)
return receipt
@transaction.atomic
def bind_unassigned_cycle(actor, receipt):
"""An empty cycle cannot accept votes. An authorized manager can bind a newly configured policy."""
if not policies.has_permission(actor, 'manage_payment_approval_policy'):
raise PermissionDenied
receipt = PaymentReceipt.objects.select_for_update().get(pk=receipt.pk)
revision = receipt.revisions.get(version=receipt.current_version)
cycle = ReceiptApprovalCycle.objects.select_for_update().get(revision=revision)
if cycle.assignments.exists() or cycle.decisions.exists() or receipt.status != 'PENDING':
raise ValidationError('سیاست این چرخه قبلاً ثبت شده است و قابل بازنویسی نیست.')
policy = policy_for(receipt.owning_unit)
if not policy or not policy.reviewers.exists():
raise ValidationError('ابتدا یک سیاست فعال با افراد تأییدکننده ایجاد کنید.')
users = list(policy.reviewers.all()) + list(policy.seniors.all())
if any(not policies.is_staff(user) for user in users):
raise ValidationError('همه تأییدکنندگان باید فعال باشند.')
cycle.policy_snapshot = {'policy_id': policy.pk, 'name': policy.name, 'method': 'ALL_SELECTED', 'reviewers': list(policy.reviewers.values_list('pk', flat=True)), 'seniors': list(policy.seniors.values_list('pk', flat=True))}
cycle.save(update_fields=['policy_snapshot'])
ReceiptApprovalAssignment.objects.bulk_create([ReceiptApprovalAssignment(cycle=cycle, user=user, is_senior=senior) for senior, selected in [(False, policy.reviewers.all()), (True, policy.seniors.all())] for user in selected])
audit(actor, 'EMPTY_CYCLE_ASSIGNED', receipt, cycle.policy_snapshot)
def revise_receipt(actor, receipt_id, version, data):
data = dict(data)
data['paid_amount'] = validators.amount(data['paid_amount'])
if data['paid_date'] > timezone.localdate():
raise ValidationError('تاریخ واریز نمی‌تواند بعد از امروز باشد.')
file_info = validators.image(data['receipt_image'])
created_files = []
try:
with transaction.atomic():
receipt = PaymentReceipt.objects.select_for_update().get(public_id=receipt_id)
if actor.pk != receipt.customer_id and not policies.has_permission(actor,'submit_customer_receipt',receipt.owning_unit):
raise PermissionDenied
if not actor.is_active or receipt.status != 'REJECTED' or receipt.current_version != version:
raise ValidationError('فقط نسخه جاری فیش ردشده قابل اصلاح است.')
# Link identity is fixed for a revision; changing notice requires a distinct receipt.
if getattr(data.get('notice_id'),'pk',None) != receipt.notice_id:
raise ValidationError('ارتباط اعلامیه این فیش قابل تغییر نیست.')
account = CompanyBankAccount.objects.select_for_update().select_related('bank').get(pk=data['destination_account'].pk)
validate_destination(account,receipt.notice)
fields=receipt_metadata(data)
fields['destination']=account
notice = receipt.notice
fields['deposit_id'] = fields['deposit_id'] or (notice.deposit_id if notice and notice.account_id == account.pk else '')
new_version=version+1
revision=PaymentReceiptRevision(receipt=receipt,version=new_version,
account_snapshot=notice.account_snapshot if notice and notice.account_id == account.pk else account.snapshot(),
image=data['receipt_image'],destination_mismatch=bool(receipt.notice and receipt.notice.account_id!=account.pk),**file_info,**fields)
try:
revision.save()
finally:
if revision.image and revision.image._committed:
created_files.append((revision.image.storage,revision.image.name))
assign_cycle(revision,actor)
receipt.current_version=new_version
receipt.status='PENDING'
receipt.save(update_fields=['current_version','status','updated_at'])
audit(actor,'RECEIPT_REVISED',receipt,{'version':new_version,'previous_version':version,'sha256':revision.sha256})
from .receipt_alerts import safe_queue_receipt_alerts
safe_queue_receipt_alerts(revision, actor)
return receipt
except Exception:
for storage,name in created_files:
storage.delete(name)
raise
+24
View File
@@ -0,0 +1,24 @@
from django.urls import path
from . import views
app_name = 'staff_payments'
urlpatterns = [
path('', views.desk, name='desk'),
path('count/', views.counts, name='count'),
path('customers/search/', views.customer_search, name='customer_search'),
path('notices/generate-identifier/', views.generate_deposit_identifier, name='generate_identifier'),
path('notices/new/', views.notice_new, name='notice_new'),
path('notices/<uuid:public_id>/', views.notice_detail, name='notice_detail'),
path('notices/<uuid:public_id>/delete/', views.notice_delete, name='notice_delete'),
path('notices/<uuid:public_id>/resend-sms/', views.notice_resend_sms, name='notice_resend_sms'),
path('notices/<uuid:public_id>/payment-link/', views.notice_payment_link, name='notice_payment_link'),
path('receipts/new/<uuid:customer_id>/', views.staff_receipt_new, name='receipt_new'),
path('receipts/new/', views.staff_receipt_customer, name='receipt_customer'),
path('receipts/<uuid:public_id>/', views.staff_receipt_detail, name='receipt_detail'),
path('receipts/<uuid:public_id>/decide/', views.decision, name='decision'),
path('receipts/<uuid:public_id>/assign/', views.assign_policy, name='assign_policy'),
path('banks/', views.banks, name='banks'),
path('banks/new/', views.bank_edit, name='bank_new'),
path('banks/bulk/', views.bank_bulk, name='bank_bulk'),
path('banks/<uuid:public_id>/', views.bank_edit, name='bank_edit'),
path('banks/<uuid:public_id>/delete/', views.bank_delete, name='bank_delete'),
]
+15
View File
@@ -0,0 +1,15 @@
from django.urls import path
from . import views, link_views
app_name = 'payments'
urlpatterns = [
path('link/<str:token>/', link_views.open_link, name='open_link'),
path('', views.home, name='home'),
path('bank-accounts/', views.notices, name='notices'),
path('bank-accounts/<uuid:public_id>/', views.customer_notice_detail, name='notice_detail'),
path('bank-accounts/<uuid:public_id>/share-link/', views.customer_notice_share_link, name='notice_share_link'),
path('payment-receipt/', views.receipt_new, name='receipt_new'),
path('receipts/', views.receipt_list, name='receipt_list'),
path('receipts/<uuid:public_id>/', views.receipt_detail, name='receipt_detail'),
path('receipts/<uuid:public_id>/revise/', views.receipt_revise, name='receipt_revise'),
path('receipts/<uuid:public_id>/image/', views.receipt_image, name='receipt_image'),
]
+93
View File
@@ -0,0 +1,93 @@
import hashlib
import re
import warnings
from pathlib import Path
import jdatetime
import magic
from PIL import Image, UnidentifiedImageError
from django.core.exceptions import ValidationError
from django.utils import timezone
from apps.attachments.models import AttachmentPolicy
from .models import MAX_AMOUNT
DIGIT_MAP = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')
def digits(value):
return str(value or '').translate(DIGIT_MAP).strip()
def amount(value):
value = digits(value)
if not re.fullmatch(r'\d+|\d{1,3}(?:[,٬ ]\d{3})+', value):
raise ValidationError('مبلغ را به ریال، فقط با عدد صحیح وارد کنید.')
value = int(re.sub(r'[,٬ ]', '', value))
if not 0 < value <= MAX_AMOUNT:
raise ValidationError('مبلغ باید بیشتر از صفر و در محدوده مجاز باشد.')
return value
def bank_number(value):
value = re.sub(r'[\s-]', '', digits(value))
if value and not value.isascii():
raise ValidationError('شماره را با ارقام معتبر وارد کنید.')
if value and not value.isdecimal():
raise ValidationError('شماره حساب یا کارت باید فقط عدد باشد.')
return value
def iban(value):
value = re.sub(r'\s', '', digits(value)).upper()
if not value:
return ''
if not re.fullmatch(r'IR\d{24}', value) or int(value[4:] + '1827' + value[2:4]) % 97 != 1:
raise ValidationError('شماره شبای معتبر ایرانی وارد کنید؛ IR و ۲۴ رقم.')
return value
def paid_date(day, month, year):
if not all((day, month, year)):
raise ValidationError('تاریخ را کامل کنید؛ روز، ماه و سال لازم‌اند.')
try:
value = jdatetime.date(int(digits(year)), int(digits(month)), int(digits(day))).togregorian()
except (ValueError, TypeError, OverflowError):
raise ValidationError('تاریخ شمسی معتبر انتخاب کنید.')
today = timezone.localdate()
if value > today:
raise ValidationError('تاریخ واریز نمی‌تواند بعد از امروز باشد.')
if jdatetime.date.fromgregorian(date=value).year < jdatetime.date.fromgregorian(date=today).year - 10:
raise ValidationError('تاریخ خارج از بازه ده سال گذشته است.')
return value
def image(upload):
policy = AttachmentPolicy.objects.filter(attachment_type='IMAGE', is_enabled=True).first()
if not policy:
raise ValidationError('ارسال تصویر فعلاً فعال نیست؛ با کارشناس هماهنگ کنید.')
if not upload.size or upload.size > policy.max_file_size:
raise ValidationError('تصویر خالی یا بزرگ‌تر از حجم مجاز است.')
ext = Path(upload.name).suffix.lower()
if ext not in (policy.allowed_extensions or ['.jpg', '.jpeg', '.png', '.gif', '.webp']):
raise ValidationError('فقط تصویر مجاز فیش را انتخاب کنید.')
head = upload.read(8192)
upload.seek(0)
mime = magic.from_buffer(head, mime=True)
expected = {'.jpg':'image/jpeg', '.jpeg':'image/jpeg', '.png':'image/png', '.gif':'image/gif', '.webp':'image/webp'}
if expected.get(ext) != mime or (policy.allowed_mime_types and mime not in policy.allowed_mime_types):
raise ValidationError('محتوای واقعی فایل با نوع تصویر سازگار نیست.')
try:
with warnings.catch_warnings():
warnings.simplefilter('error', Image.DecompressionBombWarning)
with Image.open(upload) as picture:
if picture.width * picture.height > 25_000_000:
raise ValidationError('ابعاد تصویر بیش از حد مجاز است.')
picture.verify()
except (UnidentifiedImageError, OSError, SyntaxError, Image.DecompressionBombError, Image.DecompressionBombWarning):
raise ValidationError('تصویر خراب است یا باز نمی‌شود؛ تصویر دیگری انتخاب کنید.')
finally:
upload.seek(0)
digest = hashlib.sha256()
for chunk in upload.chunks():
digest.update(chunk)
upload.seek(0)
return {'mime_type': mime, 'sha256': digest.hexdigest(), 'original_name': Path(upload.name).name[:255], 'file_size': upload.size}
+538
View File
@@ -0,0 +1,538 @@
from functools import wraps
from django.contrib import messages
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.paginator import Paginator
from django.db import transaction
from django.db.models import Q, Count, Sum, F, Exists, OuterRef
from django.db.models.deletion import ProtectedError
from django.http import FileResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.http import require_POST
from . import policies, services, validators, selectors, customer_picker, access
from .forms import BankAccountForm, NoticeForm, ReceiptForm
from .models import (CompanyBankAccount, PaymentNotice, PaymentReceipt, PaymentReceiptRevision,
PaymentAuditEvent, ReceiptDecision)
def financial_required(staff=False, allow_payment_link=False):
def decorate(view):
@wraps(view)
def wrapped(request, *args, **kwargs):
blocked = access.prepare_payment_request(request, staff=staff, allow_payment_link=allow_payment_link)
if blocked is not None:
return blocked
response = view(request, *args, **kwargs)
if getattr(request, 'payment_access_link', None):
response['Referrer-Policy'] = 'same-origin'
response['X-Robots-Tag'] = 'noindex, nofollow'
return response
return wrapped
return decorate
def staff_context(**kwargs):
return {'active_staff_section': 'payments', 'active_tab_title': 'اعلامیه‌ها و فیش‌های واریزی', **kwargs}
@financial_required()
def home(request):
return render(request, 'payments/home.html', {'financial_page': True})
def complete_notice(notice):
from .deposit_identifier import normalize_deposit_identifier
notice.deposit_id = normalize_deposit_identifier(notice.deposit_id)
snapshot = notice.account_snapshot
approved = getattr(notice, 'approved_total', None)
if approved is None:
approved = notice.receipts.filter(status='APPROVED', revisions__version=F('current_version'), revisions__destination_mismatch=False).aggregate(total=Sum('revisions__amount'))['total'] or 0
notice.approved_amount = approved
notice.remaining_amount = max(0, notice.amount-approved)
notice.settlement_label = 'تسویه شده' if approved >= notice.amount else ('پرداخت جزئی' if approved else 'در انتظار فیش تأییدشده')
# Complete copy uses immutable data, including fields absent from the compact UI.
from apps.core.templatetags.jalali import jalali_datetime
notice.copy_text = '\n'.join(f'{label}: {value}' for label, value in [
('اعلامیه', notice.number), ('تاریخ صدور', jalali_datetime(notice.created_at)), ('موضوع', notice.subject), ('مبلغ (ریال)', f'{notice.amount:,}'),
('نام حساب', snapshot.get('name')), ('بانک', snapshot.get('bank')), ('صاحب حساب', snapshot.get('holder')),
('شماره حساب', snapshot.get('account_number')), ('شبا', snapshot.get('iban')),
('کارت', snapshot.get('card_number')), ('شناسه واریز', notice.deposit_id),
('فاکتور', notice.invoice_number), ('توضیحات', notice.description)] if value)
if notice.deposit_id:
notice.copy_text += '\nوارد کردن شناسه واریز هنگام پرداخت الزامی و اجباری است؛ آن را در فرم بانکی، موبایل‌بانک یا اینترنت‌بانک وارد کنید.'
return notice
@financial_required()
def notices(request):
items = selectors.notices_with_totals(policies.visible(PaymentNotice.objects.all(), request.user))
query = request.GET.get('q', '').strip()
if query:
items = items.filter(Q(subject__icontains=query) | Q(invoice_number__icontains=validators.digits(query)))
page = Paginator(items, 20).get_page(request.GET.get('page'))
page.object_list = selectors.attach_notice_receipts(page.object_list, policies.visible(PaymentReceipt.objects.all(), request.user))
for notice in page:
complete_notice(notice)
params = request.GET.copy()
params.pop('page', None)
return render(request, 'payments/notices.html', {'page_obj': page, 'query': query, 'query_string':params.urlencode(), 'open_notice': request.GET.get('notice', ''), 'financial_page': True})
@financial_required(allow_payment_link=True)
def customer_notice_detail(request, public_id):
notice = get_object_or_404(selectors.notices_with_totals(
access.visible_payments(request, PaymentNotice.objects.all())), public_id=public_id)
selectors.attach_notice_receipts([notice], access.visible_payments(request, PaymentReceipt.objects.all()))
return render(request, 'payments/customer_notice_detail.html', access.customer_context(request,
notice=complete_notice(notice), open_notice=str(notice.public_id), financial_page=True))
@require_POST
@financial_required(allow_payment_link=True)
def customer_notice_share_link(request, public_id):
notice = get_object_or_404(access.visible_payments(request, PaymentNotice.objects.all()),
public_id=public_id, customer__is_active=True)
url = access.notice_share_url(request, notice)
response = JsonResponse({'url': url})
response['Cache-Control'] = 'private, no-store'
return response
def receipt_form_page(request, customer, staff=False):
link = getattr(request, 'payment_access_link', None)
initial = access.receipt_initial(request, customer)
form = ReceiptForm(request.POST or None, request.FILES or None, customer=customer, initial=initial,
notice_ids=[link.notice_id] if link else None)
if request.method == 'POST' and form.is_valid():
try:
receipt = services.submit_receipt(customer if link else request.user, customer, form.cleaned_data, access_link=link)
messages.success(request, 'فیش ثبت شد و در انتظار بررسی است.')
if request.headers.get('Accept') == 'application/json':
return JsonResponse({'redirect_url': reverse('staff_payments:receipt_detail' if staff else 'payments:receipt_detail', args=[receipt.public_id])})
return redirect('staff_payments:receipt_detail' if staff else 'payments:receipt_detail', public_id=receipt.public_id)
except ValidationError as error:
form.add_error(None, error)
if request.method == 'POST' and request.headers.get('Accept') == 'application/json':
return JsonResponse({'errors': form.errors.get_json_data()}, status=422)
selected = form['notice_id'].value()
available = list(form.fields['notice_id'].queryset) if staff else selectors.recent_pending_notice_choices(form)
notice_data = [selectors.notice_form_data(n, staff=staff) for n in available]
from apps.attachments.models import AttachmentPolicy
image_policy = AttachmentPolicy.objects.filter(attachment_type='IMAGE', is_enabled=True).first()
ctx = {'form': form, 'selected_notice': selected, 'notice_data': notice_data, 'financial_page': True,
'max_bytes': image_policy.max_file_size if image_policy else 0, 'staff_form': staff, 'customer': customer,
'account_requirements': form.account_warnings, 'customer_deposit_id': form.customer_deposit_id}
return render(request, 'payments/receipt_form.html', staff_context(**ctx) if staff else access.customer_context(request, **ctx))
@financial_required(allow_payment_link=True)
def receipt_new(request):
return receipt_form_page(request, request.payment_customer)
@financial_required(allow_payment_link=True)
def receipt_list(request):
items = selectors.receipts_with_current_values(access.visible_payments(request, PaymentReceipt.objects.all()).select_related('notice'))
return render(request, 'payments/receipt_list.html', access.customer_context(request, financial_page=True,
page_obj=Paginator(items, 20).get_page(request.GET.get('page'))))
@financial_required(staff=True)
def staff_receipt_customer(request):
if not policies.can_create(request.user):
raise PermissionDenied
from django import forms
form = forms.Form(request.POST or None, initial=customer_picker.initial_customer(request))
# Reuse the same active-customer selector and display labels as notice issuance.
form.fields['customer'] = NoticeForm().fields['customer']
if request.method == 'POST' and form.is_valid():
return redirect('staff_payments:receipt_new', customer_id=form.cleaned_data['customer'].public_id)
return render(request, 'payments/staff_form.html',staff_context(form=form,heading='ثبت فیش برای مشتری',submit_label='ادامه', **customer_picker.picker_context(request, form)))
@financial_required(staff=True)
def customer_search(request):
if not policies.can_create(request.user):
raise PermissionDenied
response = JsonResponse(customer_picker.search_results(request.user, request.GET.get('q', '')))
response['Cache-Control'] = 'private, no-store'
return response
@financial_required(staff=True)
def staff_receipt_new(request, customer_id):
if not policies.can_create(request.user):
raise PermissionDenied
from apps.accounts.models import User
customer = get_object_or_404(User.objects.select_related('customer_profile'), public_id=customer_id, account_type='CUSTOMER', is_active=True)
return receipt_form_page(request, customer, staff=True)
def receipt_context(request, public_id, staff):
receipt = get_object_or_404(access.visible_payments(request, PaymentReceipt.objects.select_related('customer__customer_profile', 'notice', 'owning_unit')), public_id=public_id)
revision = receipt.revisions.select_related('destination', 'origin_bank', 'cycle').get(version=receipt.current_version)
cycle = revision.cycle
services.audit(request.payment_customer, 'RECEIPT_VIEWED', receipt)
decisions = ReceiptDecision.objects.filter(cycle__revision__receipt=receipt).select_related('user', 'cycle__revision').order_by('created_at', 'pk')
return {'receipt': receipt, 'revision': revision, 'cycle': cycle, 'decisions': decisions,
'payment_link_access': getattr(request, 'payment_access_link', None),
'revision_events': PaymentAuditEvent.objects.filter(object_type='payments.paymentreceipt', object_id=str(receipt.public_id), action__in=['RECEIPT_SUBMITTED', 'RECEIPT_REVISED']).order_by('created_at', 'pk'),
'can_approve': staff and policies.can_decide(request.user, receipt, cycle),
'can_senior': staff and policies.can_decide(request.user, receipt, cycle, senior=True),
'can_assign': staff and not cycle.assignments.exists() and policies.has_permission(request.user, 'manage_payment_approval_policy'),
'staff_detail': staff, 'financial_page': True, 'versions': receipt.revisions.select_related('cycle').order_by('version'),
'duplicate_image': PaymentReceiptRevision.objects.filter(sha256=revision.sha256,
receipt__in=policies.visible(PaymentReceipt.objects.all(),request.user)).exclude(receipt=receipt).exists() if staff else False}
@financial_required(allow_payment_link=True)
def receipt_detail(request, public_id):
return render(request, 'payments/receipt_detail.html', receipt_context(request, public_id, False))
@financial_required(staff=True)
def staff_receipt_detail(request, public_id):
return render(request, 'payments/receipt_detail.html', staff_context(**receipt_context(request, public_id, True)))
@financial_required(staff=None, allow_payment_link=True)
def receipt_image(request, public_id):
try:
version = int(request.GET.get('version', '1'))
except ValueError:
from django.http import Http404
raise Http404
revision = get_object_or_404(PaymentReceiptRevision.objects.select_related('receipt'), receipt__public_id=public_id, version=version)
if not access.visible_payments(request, PaymentReceipt.objects.filter(pk=revision.receipt_id)).exists():
raise PermissionDenied
services.audit(request.payment_customer, 'RECEIPT_FILE_REQUESTED', revision.receipt, {'version': revision.version})
# Authorize EVERY access, including debug: financial media never uses MEDIA_URL.
response = FileResponse(revision.image.open('rb'), content_type=revision.mime_type,
as_attachment=request.GET.get('download') == '1', filename=revision.original_name)
response['Cache-Control'] = 'private, no-store'
response['X-Content-Type-Options'] = 'nosniff'
return response
@financial_required(staff=True)
@require_POST
def decision(request, public_id):
get_object_or_404(policies.visible(PaymentReceipt.objects.all(),request.user),public_id=public_id)
try:
version = int(request.POST.get('version', '0'))
receipt = services.decide_receipt(request.user, public_id, version, request.POST.get('action'), request.POST.get('reason', ''),
rejection_confirmed=request.POST.get('confirm_rejection') == '1')
messages.success(request, 'تصمیم ثبت شد. ' + receipt.get_status_display())
except (ValidationError, ValueError) as error:
messages.error(request, '؛ '.join(error.messages) if isinstance(error, ValidationError) else 'نسخه فیش معتبر نیست.')
return redirect('staff_payments:receipt_detail', public_id=public_id)
@financial_required(staff=True)
@require_POST
def assign_policy(request, public_id):
receipt = get_object_or_404(policies.visible(PaymentReceipt.objects.all(), request.user), public_id=public_id)
try:
services.bind_unassigned_cycle(request.user, receipt)
messages.success(request, 'افراد تأییدکننده تعیین شدند.')
except ValidationError as error:
messages.error(request, '؛ '.join(error.messages))
return redirect('staff_payments:receipt_detail', public_id=public_id)
def filtered(queryset, request):
query = validators.digits(request.GET.get('q', ''))
if query:
condition = Q(customer__username__icontains=query) | Q(customer__mobile__icontains=query) | Q(customer__customer_profile__first_name__icontains=query) | Q(customer__customer_profile__last_name__icontains=query) | Q(customer__customer_profile__province__icontains=query) | Q(customer__customer_profile__city__icontains=query)
# PN/PR numbers are UUID prefixes, not database columns.
prefix = query.upper().removeprefix('PN-').removeprefix('PR-').replace('-', '')
if prefix and len(prefix) <= 32 and all(c in '0123456789ABCDEF' for c in prefix):
uuid_prefix = '-'.join(prefix[start:end] for start, end in [(0, 8), (8, 12), (12, 16), (16, 20), (20, 32)] if len(prefix) > start)
condition |= Q(public_id__istartswith=uuid_prefix)
if queryset.model == PaymentNotice:
condition |= Q(subject__icontains=query) | Q(invoice_number__icontains=query)
else:
condition |= Q(notice__invoice_number__icontains=query) | Q(revisions__tracking_number__icontains=query)
queryset = queryset.filter(condition)
for key, field in [('account', 'account_id' if queryset.model == PaymentNotice else 'revisions__destination_id'), ('unit', 'owning_unit_id'), ('customer', 'customer_id')]:
value = request.GET.get(key, '')
if value.isdecimal():
queryset = queryset.filter(**{field: value})
for key, op in [('from', 'gte'), ('to', 'lte')]:
value = request.GET.get(key, '')
if value:
try:
import datetime
parsed = datetime.date.fromisoformat(value)
queryset = queryset.filter(**{f'created_at__date__{op}': parsed})
except ValueError:
messages.error(request, 'بازه تاریخ معتبر نیست.')
return queryset.distinct()
@financial_required(staff=True)
def desk(request):
tab = request.GET.get('tab', 'receipts')
if tab not in {'receipts', 'notices'}:
tab = 'receipts'
mine = tab == 'receipts' and (request.GET.get('mine') == '1' or ('tab' not in request.GET and 'mine' not in request.GET))
receipts = selectors.receipts_with_current_values(policies.visible(PaymentReceipt.objects.select_related('customer__customer_profile', 'notice__created_by__staff_profile'), request.user))
notice_items = selectors.notices_with_totals(policies.visible(PaymentNotice.objects.select_related('customer__customer_profile', 'created_by__staff_profile', 'account'), request.user))
daily_summary = selectors.daily_payment_summary(receipts, notice_items)
if tab == 'notices':
items = filtered(notice_items, request)
state = request.GET.get('notice_state', '')
if state == 'settled':
items = items.filter(cancelled_at__isnull=True, approved_total__gte=F('amount'))
elif state == 'pending':
items = items.filter(cancelled_at__isnull=True, approved_total__lt=F('amount'))
elif state == 'cancelled':
items = items.filter(cancelled_at__isnull=False)
else:
items = filtered(receipts, request)
status = request.GET.get('status', '')
if status in PaymentReceipt.Status.values:
items = items.filter(status=status)
if request.GET.get('kind') == 'independent':
items = items.filter(notice__isnull=True)
elif request.GET.get('kind') == 'linked':
items = items.filter(notice__isnull=False)
if mine:
my_decision = ReceiptDecision.objects.filter(cycle__revision__receipt_id=OuterRef('pk'),
cycle__revision__version=OuterRef('current_version'), user=request.user)
items = items.filter(status__in=['PENDING','PARTIAL'], revisions__version=F('current_version'),
revisions__cycle__assignments__user=request.user).annotate(
my_decision_exists=Exists(my_decision)).filter(my_decision_exists=False).distinct()
page = Paginator(items.order_by('-created_at','-pk'), 25).get_page(request.GET.get('page'))
if tab == 'notices':
page.object_list = selectors.attach_notice_receipts(page.object_list, policies.visible(PaymentReceipt.objects.all(), request.user))
for notice in page.object_list:
complete_notice(notice)
else:
page.object_list = selectors.receipt_review_summaries(page.object_list)
query = request.GET.copy()
query.pop('page', None)
query['tab'] = tab
if mine:
query['mine'] = '1'
else:
query.pop('mine', None)
return render(request, 'payments/desk.html', staff_context(page_obj=page, tab=tab, mine=mine, statuses=PaymentReceipt.Status.choices,
can_create=policies.can_create(request.user), banks=CompanyBankAccount.objects.all(), query_string=query.urlencode(),
daily_summary=daily_summary))
@financial_required(staff=True)
@require_POST
def generate_deposit_identifier(request):
if not policies.can_create(request.user):
raise PermissionDenied
# Resolve the same active customer as NoticeForm; never trust a posted mobile.
try:
customer = NoticeForm().fields['customer'].clean(request.POST.get('customer'))
mobile = validators.digits(customer.local_mobile)
import re
if not re.fullmatch(r'09[0-9]{9}', mobile):
raise ValidationError('شماره موبایل مشتری معتبر نیست؛ ابتدا اطلاعات مشتری را اصلاح کنید.')
except ValidationError as error:
return JsonResponse({'error': '؛ '.join(error.messages)}, status=400)
from .deposit_identifier import generate_id_algorithm
response = JsonResponse({'identifier': generate_id_algorithm(mobile), 'customer': str(customer.pk)})
response['Cache-Control'] = 'no-store'
return response
@financial_required(staff=True)
def notice_new(request):
if not policies.can_create(request.user):
raise PermissionDenied
form = NoticeForm(request.POST or None, initial=customer_picker.initial_customer(request))
if request.method == 'POST' and form.is_valid():
try:
notice = services.issue_notice(request.user, form.cleaned_data)
messages.success(request, 'اعلامیه صادر شد و پیامک در صف اطلاع‌رسانی قرار گرفت.')
return redirect('staff_payments:notice_detail', public_id=notice.public_id)
except ValidationError as error:
form.add_error(None, error)
return render(request, 'payments/staff_form.html', staff_context(form=form, heading='ایجاد اعلامیه واریز', submit_label='صدور و اطلاع‌رسانی', identifier_accounts=list(CompanyBankAccount.objects.filter(requires_deposit_id=True).values_list('pk',flat=True)), **customer_picker.picker_context(request, form)))
@financial_required(staff=True)
def notice_detail(request, public_id):
notice = complete_notice(get_object_or_404(policies.notice_actions(PaymentNotice.objects.select_related('customer'), request.user), public_id=public_id))
services.audit(request.user, 'NOTICE_VIEWED', notice)
share = request.session.get('payment_link_share', {})
if share.get('notice') == str(public_id):
request.session.pop('payment_link_share', None)
else:
share = {}
from uuid import uuid4
from .notifications import notice_sms_pending
return render(request, 'payments/notice_detail.html', staff_context(notice=notice,
can_delete_notice=policies.can_delete_notice(request.user, notice),
may_delete_notice=policies.may_delete_notice(request.user, notice),
can_share_payment_link=policies.can_create(request.user) and notice.customer.is_active,
payment_share_url=share.get('url', ''),
can_resend_notice_sms=policies.can_create(request.user, notice.owning_unit) and notice.customer.is_active and not notice.cancelled_at,
notice_sms_pending=notice_sms_pending(notice), notice_sms_request_key=str(uuid4()),
receipts=policies.visible(notice.receipts.all(), request.user), notifications=selectors.notice_sms_history(notice)))
@financial_required(staff=True)
@require_POST
def notice_resend_sms(request, public_id):
notice = get_object_or_404(policies.notice_actions(PaymentNotice.objects.all(), request.user), public_id=public_id)
try:
services.resend_notice_sms(request.user, notice, request.POST.get('request_key', ''))
messages.success(request, 'درخواست ارسال مجدد پیامک در صف ثبت شد؛ نتیجه ارسال و دریافت در سوابق نمایش داده می‌شود.')
except ValidationError as error:
messages.error(request, '؛ '.join(error.messages))
return redirect('staff_payments:notice_detail', public_id=public_id)
@financial_required(staff=True)
@require_POST
def notice_delete(request, public_id):
notice = get_object_or_404(policies.notice_actions(PaymentNotice.objects.all(), request.user), public_id=public_id)
if not policies.may_delete_notice(request.user, notice):
raise PermissionDenied
if request.POST.get('confirm') != 'delete':
messages.error(request, 'برای حذف اعلامیه، ابتدا هشدار را بخوانید و حذف را تأیید کنید.')
return redirect('staff_payments:notice_detail', public_id=public_id)
try:
services.delete_notice(request.user, notice)
except ValidationError as error:
messages.error(request, '؛ '.join(error.messages))
return redirect('staff_payments:notice_detail', public_id=public_id)
messages.success(request, 'اعلامیه واریز حذف شد و لینک‌های آن دیگر قابل استفاده نیستند.')
return redirect(reverse('staff_payments:desk') + '?tab=notices')
@financial_required(staff=True)
@require_POST
def notice_payment_link(request, public_id):
if not policies.can_create(request.user):
raise PermissionDenied
notice = get_object_or_404(policies.visible(PaymentNotice.objects.all(), request.user),
public_id=public_id, customer__is_active=True)
link, url = access.issue_payment_link(notice=notice)
services.audit(request.user, 'PAYMENT_LINK_CREATED', link)
request.session['payment_link_share'] = {'notice': str(public_id), 'url': url}
return redirect('staff_payments:notice_detail', public_id=public_id)
@financial_required(staff=True)
def counts(request):
count = policies.visible(PaymentReceipt.objects.all(), request.user).filter(status__in=['PENDING', 'PARTIAL']).count()
response = JsonResponse({'pending_receipts': count})
response['Cache-Control'] = 'private, no-store'
return response
@financial_required(staff=True)
def banks(request):
if not policies.can_manage_banks(request.user):
raise PermissionDenied
items = CompanyBankAccount.objects.select_related('bank')
query = request.GET.get('q','').strip()
if query:
items = items.filter(Q(name__icontains=query) | Q(bank__name__icontains=query) | Q(holder__icontains=query))
for field in ['is_active', 'available_to_staff', 'available_to_customers']:
if request.GET.get(field) in ['0','1']:
items = items.filter(**{field: request.GET[field] == '1'})
params = request.GET.copy()
params.pop('page', None)
return render(request, 'payments/banks.html', staff_context(page_obj=Paginator(items,25).get_page(request.GET.get('page')),
query_string=params.urlencode(), bank_flags=[{'key':key,'label':label,'value':request.GET.get(key,'')} for key,label in [('is_active','فعال کلی'),('available_to_staff','پنل کارشناسان'),('available_to_customers','پنل مشتریان')]], active_staff_section='banks', active_tab_title='بانک‌ها'))
@financial_required(staff=True)
def bank_edit(request, public_id=None):
if not policies.can_manage_banks(request.user):
raise PermissionDenied
instance = get_object_or_404(CompanyBankAccount, public_id=public_id) if public_id else None
before = services.configuration_snapshot(instance) if instance else {}
form = BankAccountForm(request.POST or None, instance=instance)
if request.method == 'POST' and form.is_valid():
with transaction.atomic():
if instance:
current = CompanyBankAccount.objects.select_for_update().get(pk=instance.pk)
if form.cleaned_data['revision_token'] != current.updated_at.isoformat():
form.add_error(None, 'حساب هم‌زمان تغییر کرده است؛ صفحه را تازه کنید.')
return render(request,'payments/staff_form.html',staff_context(form=form,bank_account=instance,
heading='ویرایش حساب',submit_label='ذخیره حساب',active_staff_section='banks'))
before = services.configuration_snapshot(current)
account = form.save()
services.audit(request.user, 'BANK_SAVED', account, {'before': before, 'after': services.configuration_snapshot(account)})
return redirect('staff_payments:banks')
return render(request,'payments/staff_form.html',staff_context(form=form, bank_account=instance, heading='ویرایش حساب' if instance else 'ایجاد حساب', submit_label='ذخیره حساب', active_staff_section='banks'))
@financial_required(staff=True)
@require_POST
def bank_bulk(request):
if not policies.can_manage_banks(request.user):
raise PermissionDenied
field = request.POST.get('field')
if field not in ['is_active','available_to_staff','available_to_customers'] or request.POST.get('value') not in ['0','1']:
raise ValidationError('عملیات معتبر نیست.')
ids = [value for value in request.POST.getlist('accounts') if value.isdecimal()]
with transaction.atomic():
for account in CompanyBankAccount.objects.select_for_update().filter(pk__in=ids):
before = getattr(account, field)
setattr(account,field,request.POST['value'] == '1')
account.save(update_fields=[field,'updated_at'])
services.audit(request.user,'BANK_FLAG_CHANGED',account,{'field':field,'before':before,'after':getattr(account,field)})
return redirect('staff_payments:banks')
@financial_required(staff=True)
@require_POST
def bank_delete(request, public_id):
if not policies.can_manage_banks(request.user):
raise PermissionDenied
account = get_object_or_404(CompanyBankAccount,public_id=public_id)
try:
with transaction.atomic():
services.audit(request.user,'BANK_DELETED',account,account.snapshot())
account.delete()
except ProtectedError:
messages.error(request,'این حساب سابقه مالی دارد؛ به جای حذف آن را غیرفعال کنید.')
return redirect('staff_payments:banks')
@financial_required(allow_payment_link=True)
def receipt_revise(request, public_id):
link = getattr(request, 'payment_access_link', None)
receipt=get_object_or_404(access.visible_payments(request, PaymentReceipt.objects.all()),public_id=public_id,customer=request.payment_customer,status='REJECTED')
import jdatetime
current = receipt.revisions.get(version=receipt.current_version)
from .identity import customer_name
paid = jdatetime.date.fromgregorian(date=current.paid_date)
initial={'receipt_mode':'linked' if receipt.notice_id else 'independent','notice_id':receipt.notice_id,
'depositor_name':current.depositor_name or customer_name(request.payment_customer),
'paid_amount':str(current.amount),'destination_account':current.destination_id,'deposit_id':current.deposit_id,
'paid_day':str(paid.day),'paid_month':str(paid.month),'paid_year':str(paid.year),
'origin_bank':current.origin_bank_id,'origin_other':current.origin_other,
'tracking_number':current.tracking_number,'description':current.description}
form=ReceiptForm(request.POST or None,request.FILES or None,customer=request.payment_customer,initial=initial,
notice_ids=[link.notice_id] if link else None)
if request.method=='POST' and form.is_valid():
try:
services.revise_receipt(request.payment_customer,receipt.public_id,int(request.POST.get('version','0')),form.cleaned_data)
messages.success(request,'نسخه اصلاحی ثبت شد و دوباره بررسی می‌شود.')
if request.headers.get('Accept') == 'application/json':
return JsonResponse({'redirect_url': reverse('payments:receipt_detail', args=[receipt.public_id])})
return redirect('payments:receipt_detail',public_id=receipt.public_id)
except (ValidationError,ValueError) as error:
form.add_error(None,error if isinstance(error,ValidationError) else 'نسخه فیش معتبر نیست.')
if request.method == 'POST' and request.headers.get('Accept') == 'application/json':
return JsonResponse({'errors': form.errors.get_json_data()}, status=422)
from apps.attachments.models import AttachmentPolicy
policy=AttachmentPolicy.objects.filter(attachment_type='IMAGE',is_enabled=True).first()
notice_data=[selectors.notice_form_data(n) for n in selectors.recent_pending_notice_choices(form, retain_original=receipt.notice_id)]
return render(request,'payments/receipt_form.html',{'form':form,'notice_data':notice_data,'max_bytes':policy.max_file_size if policy else 0,
'payment_link_access': link,
'account_requirements':form.account_warnings, 'customer_deposit_id':form.customer_deposit_id,
'financial_page':True,'revision_version':receipt.current_version})
+11
View File
@@ -0,0 +1,11 @@
# خروجی مخاطبان برای Phone
این برنامه endpoint خواندنی `GET /api/phone-contacts?search=...&limit=50` را مطابق قرارداد ابتدای `EXTERNAL_CONTACT_APIS.md` ارائه می‌کند. پاسخ همیشه شیء JSON با کلید `contacts` است. هر مخاطب فعال دارای پروفایل و نام، با شمارهٔ ثبت‌شده‌اش برگردانده می‌شود. هر نتیجه فیلد مستقیم `mobile` و آرایهٔ سازگار با Phone یعنی `phones` را دارد. جست‌وجو با نام و با شمارهٔ کامل در قالب‌های `0`، `+98` و `0098` برای هر پیش‌شماره‌ای انجام می‌شود. مدل فعلی فقط شمارهٔ همراه را در مسیر عادی ثبت می‌کند؛ شمارهٔ ثابت تنها اگر از قبل در داده موجود باشد قابل جست‌وجو است. سقف پاسخ ۵۰ مخاطب است. جزئیات در `docs/PHONE_CONTACTS_API.md` آمده است.
## راه‌اندازی
1. Migration برنامه را با محیط مجازی پروژه اجرا کنید: `.venv/Scripts/python.exe manage.py migrate phone_contacts_api`.
2. در `/x5admin/`، بخش «دسترسی API مخاطبان Phone» ← «کلید API مخاطبان Phone» را باز کنید. یک کلید تصادفی حداقل ۳۲ نویسه‌ای ثبت کنید. مقدار کلید فقط هنگام ذخیره دریافت می‌شود و در دیتابیس به صورت هش نگهداری می‌شود. برای تعویض کلید، رکورد را ویرایش کنید؛ خالی گذاشتن فیلد کلید، مقدار قبلی را حفظ می‌کند. با خاموش کردن «فعال»، دسترسی بلافاصله قطع می‌شود.
3. در پنل Phone منبع GET بسازید: آدرس ثابت `https://<host>/api/phone-contacts`، پارامترها `{"search":"{{query}}","limit":50}`، هدر `{"Authorization":"Bearer {{env:PHONE_EXTERNAL_CRM_TOKEN}}"}`، مسیر فهرست `contacts`، مسیر نام `name` و نگاشت شماره‌ها `[{"path":"phones[]","value_path":"number","label_path":"type"}]`. مقدار `PHONE_EXTERNAL_CRM_TOKEN` در میزبان Phone باید همان کلید ثبت‌شده در دیتابیس این پروژه باشد.
کلید به کاربران سایت وابسته نیست؛ خروجی این endpoint شامل همه مخاطبان فعال دارای پروفایل و نام است. همان‌طور که راهنمای Phone توضیح می‌دهد، دسترسی کاربران Phone باید در سمت مصرف‌کننده یا با محدود کردن داده‌های این سرویس کنترل شود.
View File
Whitespace-only changes.
+44
View File
@@ -0,0 +1,44 @@
from django import forms
from django.contrib import admin
from .models import PhoneContactApiKey
class PhoneContactApiKeyForm(forms.ModelForm):
new_key = forms.CharField(
label="کلید جدید",
required=False,
min_length=32,
max_length=256,
strip=False,
widget=forms.PasswordInput(render_value=False),
help_text="برای ایجاد، کلید تصادفی حداقل ۳۲ نویسه‌ای وارد کنید. هنگام ویرایش، خالی بگذارید تا کلید فعلی حفظ شود.",
)
class Meta:
model = PhoneContactApiKey
fields = ("is_active",)
def clean_new_key(self):
value = self.cleaned_data["new_key"]
if not value and not self.instance.pk:
raise forms.ValidationError("برای ایجاد کلید، مقدار جدید لازم است.")
return value
@admin.register(PhoneContactApiKey)
class PhoneContactApiKeyAdmin(admin.ModelAdmin):
form = PhoneContactApiKeyForm
fields = ("new_key", "is_active", "updated_at")
readonly_fields = ("updated_at",)
def has_add_permission(self, request):
return super().has_add_permission(request) and not PhoneContactApiKey.objects.exists()
def has_delete_permission(self, request, obj=None):
return False
def save_model(self, request, obj, form, change):
if form.cleaned_data["new_key"]:
obj.set_key(form.cleaned_data["new_key"])
super().save_model(request, obj, form, change)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class PhoneContactsApiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.phone_contacts_api"
verbose_name = "دسترسی API مخاطبان Phone"
@@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="PhoneContactApiKey",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("key_hash", models.CharField(editable=False, max_length=128)),
("is_active", models.BooleanField(default=True, verbose_name="فعال")),
("updated_at", models.DateTimeField(auto_now=True, verbose_name="آخرین تغییر")),
],
options={"verbose_name": "کلید API مخاطبان Phone", "verbose_name_plural": "کلید API مخاطبان Phone"},
),
]
Whitespace-only changes.
+25
View File
@@ -0,0 +1,25 @@
from django.contrib.auth.hashers import check_password, make_password
from django.db import models
from apps.core.models import SingletonConfiguration
class PhoneContactApiKey(SingletonConfiguration):
"""One database-managed credential for the read-only Phone contact feed."""
key_hash = models.CharField(max_length=128, editable=False)
is_active = models.BooleanField(default=True, verbose_name="فعال")
updated_at = models.DateTimeField(auto_now=True, verbose_name="آخرین تغییر")
class Meta:
verbose_name = "کلید API مخاطبان Phone"
verbose_name_plural = "کلید API مخاطبان Phone"
def set_key(self, raw_key):
self.key_hash = make_password(raw_key)
def accepts(self, raw_key):
return bool(self.is_active and self.key_hash and check_password(raw_key, self.key_hash))
def __str__(self):
return "کلید API مخاطبان Phone"
+125
View File
@@ -0,0 +1,125 @@
import pytest
from django.contrib.auth import get_user_model
from django.urls import reverse
from apps.customers.models import CustomerProfile
from .models import PhoneContactApiKey
pytestmark = pytest.mark.django_db
URL = "/api/phone-contacts"
FIRST_KEY = "first-phone-contact-api-key-1234567890"
SECOND_KEY = "second-phone-contact-api-key-1234567890"
def make_contact(*, mobile, first_name, last_name="", active=True):
user = get_user_model().objects.create_user(
username=f"contact_{mobile}", mobile=mobile, is_active=active,
account_type="CUSTOMER",
)
return CustomerProfile.objects.create(user=user, first_name=first_name, last_name=last_name)
def authorize(client, key=FIRST_KEY):
return {"HTTP_AUTHORIZATION": f"Bearer {key}"}
def test_contact_feed_authentication_search_and_contract(client):
key = PhoneContactApiKey()
key.set_key(FIRST_KEY)
key.save()
make_contact(mobile="09123456789", first_name="علی", last_name="رضایی")
make_contact(mobile="09123456780", first_name="علی", last_name="غیرفعال", active=False)
make_contact(mobile="09123456781", first_name="مریم")
assert client.get(URL, {"search": "علی"}).status_code == 401
assert client.get(URL, {"search": "علی"}, **authorize(client, SECOND_KEY)).status_code == 401
response = client.get(URL, {"search": " علی رضایی ", "limit": "50"}, **authorize(client))
assert response.status_code == 200
assert response.json() == {"contacts": [{
"name": "علی رضایی",
"mobile": "09123456789",
"phones": [{"type": "همراه", "number": "09123456789"}],
}]}
assert response["Cache-Control"] == "no-store"
assert client.get(URL, {"search": "ع"}, **authorize(client)).json() == {"contacts": []}
assert client.get(URL, {"search": "هیچ"}, **authorize(client)).json() == {"contacts": []}
assert client.post(URL, **authorize(client)).status_code == 405
@pytest.mark.parametrize("search", [
"09123456789", "+989123456789", "00989123456789",
"۰۹۱۲۳۴۵۶۷۸۹", "۰۹۱۲-۳۴۵-۶۷۸۹",
])
def test_contact_feed_finds_same_mobile_in_supported_formats(client, search):
key = PhoneContactApiKey()
key.set_key(FIRST_KEY)
key.save()
make_contact(mobile="09123456789", first_name="علی", last_name="رضایی")
make_contact(mobile="09123456780", first_name="مریم")
response = client.get(URL, {"search": search}, **authorize(client))
assert response.status_code == 200
assert response.json() == {"contacts": [{
"name": "علی رضایی",
"mobile": "09123456789",
"phones": [{"type": "همراه", "number": "09123456789"}],
}]}
assert client.get(URL, {"search": "09120000000"}, **authorize(client)).json() == {"contacts": []}
@pytest.mark.parametrize("stored,search,display", [
("+989131234567", "09131234567", "09131234567"),
("+989131234567", "00989131234567", "09131234567"),
("+982112345678", "02112345678", "02112345678"),
("02112345678", "+982112345678", "02112345678"),
("+982112345678", "0098-21-1234-5678", "02112345678"),
])
def test_contact_feed_matches_any_iranian_prefix_without_mobile_area_code_assumption(client, stored, search, display):
key = PhoneContactApiKey()
key.set_key(FIRST_KEY)
key.save()
profile = make_contact(mobile="09123456789", first_name="علی", last_name="رضایی")
get_user_model().objects.filter(pk=profile.user_id).update(mobile=stored)
response = client.get(URL, {"search": search}, **authorize(client))
assert response.status_code == 200
assert response.json() == {"contacts": [{
"name": "علی رضایی",
"mobile": display,
"phones": [{"type": "همراه" if display.startswith("09") else "تلفن", "number": display}],
}]}
def test_key_rotation_and_disabling_take_effect_immediately(client):
key = PhoneContactApiKey()
key.set_key(FIRST_KEY)
key.save()
assert client.get(URL, {"search": "علی"}, **authorize(client)).status_code == 200
key.set_key(SECOND_KEY)
key.save()
assert client.get(URL, {"search": "علی"}, **authorize(client)).status_code == 401
assert client.get(URL, {"search": "علی"}, **authorize(client, SECOND_KEY)).status_code == 200
key.is_active = False
key.save()
assert client.get(URL, {"search": "علی"}, **authorize(client, SECOND_KEY)).status_code == 401
def test_admin_can_create_and_rotate_key_without_exposing_hash(client):
admin = get_user_model().objects.create_superuser(username="api_admin", password="secure-admin-password")
client.force_login(admin)
add_url = reverse("admin:phone_contacts_api_phonecontactapikey_add")
response = client.post(add_url, {"new_key": FIRST_KEY, "is_active": "on", "_save": "Save"})
assert response.status_code == 302
key = PhoneContactApiKey.objects.get()
assert key.key_hash != FIRST_KEY
assert key.accepts(FIRST_KEY)
change_url = reverse("admin:phone_contacts_api_phonecontactapikey_change", args=[key.pk])
response = client.post(change_url, {"new_key": SECOND_KEY, "is_active": "on", "_save": "Save"})
assert response.status_code == 302
key.refresh_from_db()
assert not key.accepts(FIRST_KEY)
assert key.accepts(SECOND_KEY)
assert SECOND_KEY not in client.get(change_url).content.decode()
+6
View File
@@ -0,0 +1,6 @@
from django.urls import path
from .views import phone_contacts
urlpatterns = [path("", phone_contacts, name="phone_contacts_api")]
+124
View File
@@ -0,0 +1,124 @@
import re
from django.db.models import Case, IntegerField, Q, Value, When
from django.http import JsonResponse
from django.views.decorators.http import require_GET
from apps.customers.models import CustomerProfile
from .models import PhoneContactApiKey
_PHONE_DIGITS = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
def _dialable_number(value):
number = re.sub(r"[\s().-]", "", str(value or "").translate(_PHONE_DIGITS))
return number if 0 < len(number) <= 64 and re.fullmatch(r"[0-9+*#]+", number) else ""
def _canonical_number(value):
number = _dialable_number(value)
if not number or not re.fullmatch(r"\+[0-9]+|[0-9*#]+", number):
return ""
if number.isdigit() and number.startswith("00") and len(number) > 2:
return "+" + number[2:]
if number.isdigit() and number.startswith("98") and len(number) >= 12:
return "+" + number
if number.isdigit() and number.startswith("0") and len(number) > 1:
return "+98" + number[1:]
if number.isdigit() and len(number) == 10:
return "+98" + number
return number
def _phone_variants(value):
canonical = _canonical_number(value)
if not canonical:
return ()
variants = {canonical, _dialable_number(value)}
if canonical.startswith("+98"):
variants.update({"0" + canonical[3:], "00" + canonical[1:], canonical[1:]})
elif canonical.startswith("+"):
variants.add("00" + canonical[1:])
return variants
def _display_number(value):
canonical = _canonical_number(value)
return "0" + canonical[3:] if canonical.startswith("+98") else canonical
def _json(data, status=200):
response = JsonResponse(data, status=status, json_dumps_params={"ensure_ascii": False})
response["Cache-Control"] = "no-store"
return response
def _matching_contacts(contacts, query):
phone_query = query.translate(_PHONE_DIGITS)
if re.fullmatch(r"[+*#0-9().\s-]+", phone_query):
variants = _phone_variants(phone_query)
return contacts.filter(user__mobile__in=variants).order_by("pk") if variants else contacts.none()
for word in re.split(r"\s+", query):
contacts = contacts.filter(
Q(first_name__icontains=word) | Q(last_name__icontains=word) |
Q(organization_name__icontains=word) | Q(user__first_name__icontains=word) |
Q(user__last_name__icontains=word) | Q(user__mobile__icontains=word)
)
preferred = (
Q(first_name__iexact=query) | Q(last_name__iexact=query) |
Q(organization_name__iexact=query) | Q(user__first_name__iexact=query) |
Q(user__last_name__iexact=query)
)
starts_with = (
Q(first_name__istartswith=query) | Q(last_name__istartswith=query) |
Q(organization_name__istartswith=query) | Q(user__first_name__istartswith=query) |
Q(user__last_name__istartswith=query)
)
return contacts.annotate(_search_rank=Case(
When(preferred, then=Value(0)),
When(starts_with, then=Value(1)),
default=Value(2), output_field=IntegerField(),
)).order_by("_search_rank", "pk")
@require_GET
def phone_contacts(request):
authorization = request.headers.get("Authorization", "")
scheme, separator, token = authorization.partition(" ")
if not separator or scheme.lower() != "bearer" or not token or token != token.strip():
return _json({"error": "unauthorized"}, 401)
credential = PhoneContactApiKey.objects.filter(pk=1, is_active=True).first()
if not credential or not credential.accepts(token):
return _json({"error": "unauthorized"}, 401)
query = request.GET.get("search", "").strip()[:80]
if len(query) < 2:
return _json({"contacts": []})
try:
limit = min(50, max(1, int(request.GET.get("limit", "50"))))
except ValueError:
return _json({"error": "invalid limit"}, 400)
query = query.translate(str.maketrans({"ي": "ی", "ك": "ک", "۰": "0", "۱": "1", "۲": "2", "۳": "3", "۴": "4", "۵": "5", "۶": "6", "۷": "7", "۸": "8", "۹": "9"}))
contacts = CustomerProfile.objects.select_related("user").filter(
user__account_type="CUSTOMER", user__is_active=True, user__mobile__isnull=False
).exclude(user__mobile="").filter(
Q(first_name__gt="") | Q(last_name__gt="") | Q(organization_name__gt="") |
Q(user__first_name__gt="") | Q(user__last_name__gt="")
)
results = []
for profile in _matching_contacts(contacts, query)[:limit]:
name = (f"{profile.first_name} {profile.last_name}".strip() or
f"{profile.user.first_name} {profile.user.last_name}".strip() or
profile.organization_name.strip())[:120]
number = _display_number(profile.user.mobile)
if name and number:
label = "همراه" if re.fullmatch(r"09[0-9]{9}", number) else "تلفن"
results.append({"name": name, "mobile": number, "phones": [{"type": label, "number": number}]})
return _json({"contacts": results})
+3 -3
View File
@@ -14,14 +14,14 @@ class UnitMembershipInline(admin.TabularInline):
@admin.register(OrganizationalUnit) @admin.register(OrganizationalUnit)
class OrganizationalUnitAdmin(admin.ModelAdmin): class OrganizationalUnitAdmin(admin.ModelAdmin):
list_display = ("name", "slug", "bale_chat_id", "sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active", "updated_at") list_display = ("name", "slug", "bale_chat_id", "sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active", "updated_at")
list_filter = ("show_in_customer_form", "accepts_external_tickets", "is_active") list_filter = ("supervisor_sms_ticket_link_enabled", "show_in_customer_form", "accepts_external_tickets", "is_active")
search_fields = ("name", "slug", "bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4") search_fields = ("name", "slug", "bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5")
list_editable = ("sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active") list_editable = ("sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active")
filter_horizontal = ("permissions",) filter_horizontal = ("permissions",)
inlines = (UnitMembershipInline,) inlines = (UnitMembershipInline,)
fieldsets = ( fieldsets = (
("مشخصات واحد", {"fields": ("name", "slug", "description", "search_keywords", "sort_order")}), ("مشخصات واحد", {"fields": ("name", "slug", "description", "search_keywords", "sort_order")}),
("اطلاع‌رسانی بله و سرپرستان", {"fields": ("bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4")}), ("اطلاع‌رسانی بله و سرپرستان", {"fields": ("bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5", "supervisor_sms_ticket_link_enabled")}),
("نمایش و دسترسی", {"fields": ("permissions", "show_in_customer_form", "accepts_external_tickets", "is_active")}), ("نمایش و دسترسی", {"fields": ("permissions", "show_in_customer_form", "accepts_external_tickets", "is_active")}),
) )
@@ -0,0 +1,13 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("staff", "0011_organizationalunit_supervisor_mobile_4")]
operations = [
migrations.AddField(
model_name="organizationalunit",
name="supervisor_mobile_5",
field=models.CharField(blank=True, max_length=16, verbose_name="شماره همراه سرپرست پنجم"),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2026-10-13 12:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('staff', '0012_organizationalunit_supervisor_mobile_5'),
]
operations = [
migrations.AddField(
model_name='organizationalunit',
name='supervisor_sms_ticket_link_enabled',
field=models.BooleanField(default=False, help_text='لینک کوتاه مشاهده و پاسخ با اعتبار ۷ روز؛ ورود کارشناسان و مجوز دسترسی به تیکت الزامی است.', verbose_name='افزودن لینک تیکت به پیامک سرپرستان'),
),
]
+9 -2
View File
@@ -37,6 +37,12 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
supervisor_mobile_2 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست دوم") supervisor_mobile_2 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست دوم")
supervisor_mobile_3 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست سوم") supervisor_mobile_3 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست سوم")
supervisor_mobile_4 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست چهارم") supervisor_mobile_4 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست چهارم")
supervisor_mobile_5 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست پنجم")
supervisor_sms_ticket_link_enabled = models.BooleanField(
default=False,
verbose_name="افزودن لینک تیکت به پیامک سرپرستان",
help_text="لینک کوتاه مشاهده و پاسخ با اعتبار ۷ روز؛ ورود کارشناسان و مجوز دسترسی به تیکت الزامی است.",
)
class Meta: class Meta:
ordering = ["sort_order", "name", "pk"] ordering = ["sort_order", "name", "pk"]
@@ -54,6 +60,7 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
self.supervisor_mobile_2, self.supervisor_mobile_2,
self.supervisor_mobile_3, self.supervisor_mobile_3,
self.supervisor_mobile_4, self.supervisor_mobile_4,
self.supervisor_mobile_5,
)))) ))))
def clean(self): def clean(self):
@@ -62,7 +69,7 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
normalized = [] normalized = []
errors = {} errors = {}
for field_name in ("supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4"): for field_name in ("supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5"):
value = getattr(self, field_name, "").strip() value = getattr(self, field_name, "").strip()
if not value: if not value:
setattr(self, field_name, "") setattr(self, field_name, "")
@@ -82,7 +89,7 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
from apps.accounts.models import normalize_mobile from apps.accounts.models import normalize_mobile
for field_name in ("supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4"): for field_name in ("supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5"):
value = getattr(self, field_name, "").strip() value = getattr(self, field_name, "").strip()
setattr(self, field_name, normalize_mobile(value) if value else "") setattr(self, field_name, normalize_mobile(value) if value else "")
self.bale_chat_id = self.bale_chat_id.strip() self.bale_chat_id = self.bale_chat_id.strip()
+40 -1
View File
@@ -1,5 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import GroupDispatch,SLAConfig,Ticket,TicketAccessGrant,TicketCategory,TicketEvent,TicketReadState,TicketTag from .models import DispatchResponseControl,GroupDispatch,SLAConfig,Ticket,TicketAccessGrant,TicketCategory,TicketEvent,TicketReadState,TicketTag
@admin.register(Ticket) @admin.register(Ticket)
class TicketAdmin(admin.ModelAdmin): class TicketAdmin(admin.ModelAdmin):
list_display=("ticket_number","subject","owning_unit","delivery_type","status","priority","owner","last_activity_at") list_display=("ticket_number","subject","owning_unit","delivery_type","status","priority","owner","last_activity_at")
@@ -7,6 +7,15 @@ class TicketAdmin(admin.ModelAdmin):
search_fields=("ticket_number","subject","customer__mobile","customer__customer_profile__organization_name") search_fields=("ticket_number","subject","customer__mobile","customer__customer_profile__organization_name")
filter_horizontal=("tags",) filter_horizontal=("tags",)
def get_readonly_fields(self, request, obj=None):
if obj and obj.group_dispatch_id and DispatchResponseControl.objects.filter(dispatch_id=obj.group_dispatch_id).exists():
return ("group_dispatch", "delivery_type", "customer", "created_by")
return super().get_readonly_fields(request, obj)
def get_queryset(self, request):
from .dispatch_responses import managed_ticket_q
return super().get_queryset(request).filter(managed_ticket_q(request.user)).distinct()
class DispatchTicketInline(admin.TabularInline): class DispatchTicketInline(admin.TabularInline):
model = Ticket model = Ticket
@@ -27,6 +36,36 @@ class GroupDispatchAdmin(admin.ModelAdmin):
readonly_fields = ("recipient_count", "dispatched_at") readonly_fields = ("recipient_count", "dispatched_at")
inlines = (DispatchTicketInline,) inlines = (DispatchTicketInline,)
def get_readonly_fields(self, request, obj=None):
fields = super().get_readonly_fields(request, obj)
if obj and DispatchResponseControl.objects.filter(dispatch_id=obj.pk).exists():
return fields + ("unit", "operation_type", "created_by", "contact_group")
return fields
def get_queryset(self, request):
from .policies import DispatchPolicy
from django.db.models import Q
return super().get_queryset(request).filter(Q(response_control__isnull=True) | Q(pk__in=DispatchPolicy.visible_dispatches(request.user)))
@admin.register(DispatchResponseControl)
class DispatchResponseControlAdmin(admin.ModelAdmin):
list_display = ("dispatch", "mode", "notify_sms", "opened_at", "opened_by")
readonly_fields = tuple(field.name for field in DispatchResponseControl._meta.fields)
def get_queryset(self, request):
from .policies import DispatchPolicy
return super().get_queryset(request).filter(dispatch__in=DispatchPolicy.visible_dispatches(request.user))
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.register(TicketCategory) @admin.register(TicketCategory)
class TicketCategoryAdmin(admin.ModelAdmin): class TicketCategoryAdmin(admin.ModelAdmin):
Loaded 100 of 327 files, more files were not shown because too many files have changed in this diff. Show more