Compare commits
31
Commits
1d3cff0b63
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe7a10cb61 | ||
|
|
089db0afa4 | ||
|
|
6b546aad76 | ||
|
|
35a1ec1794 | ||
|
|
90931a5a64 | ||
|
|
bb3ddd1610 | ||
|
|
4f7486152c | ||
|
|
b53a3e0c5c | ||
|
|
d99e47ecce | ||
|
|
1cbbbbd0dc | ||
|
|
de3135b588 | ||
|
|
2ae79b1ca4 | ||
|
|
79fe1cddbb | ||
|
|
39e64854b8 | ||
|
|
04d2bc957f | ||
|
|
0b56766f96 | ||
|
|
d5c22a39dc | ||
|
|
81335b1439 | ||
|
|
4525ec3e6a | ||
|
|
9af91941d9 | ||
|
|
c7993aae44 | ||
|
|
38b1cf06df | ||
|
|
cb92abbb41 | ||
|
|
33930e972c | ||
|
|
a26ae28189 | ||
|
|
83f4d7c728 | ||
|
|
863ca89485 | ||
|
|
0ed677e5fc | ||
|
|
6b841dc5fa | ||
|
|
d72381f0ac | ||
|
|
ffc1ec0dcf |
No files matched your search
@@ -45,6 +45,8 @@ SMS_MAX_RETRIES=3
|
||||
SMS_RETRY_DELAY_SECONDS=180
|
||||
SMS_DELIVERY_CHECK_DELAY_SECONDS=180
|
||||
SMS_DELIVERY_MAX_CHECKS=15
|
||||
SMS_DEFINITIVE_DELIVERY_RETRY_AFTER_SECONDS=2700
|
||||
SMS_HISTORICAL_DELIVERY_RETRY_STAGGER_SECONDS=30
|
||||
SECURE_SSL_REDIRECT=true
|
||||
SECURE_HSTS_SECONDS=31536000
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS=true
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
> **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.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Validate important Graphify findings against the referenced source code or project documentation before acting on them.
|
||||
| Metric / Dimension | **Ripwire** (Deterministic AST) | **Graphify** (Semantic KG) |
|
||||
|---|---|---|
|
||||
| **Primary Scope** | Local source code AST, symbol graphs, in-repo text | Cross-artifact: Code + Docs + PDFs + Media + SQL Schema |
|
||||
| **Best at** | Callers, callees, impact, tests, quality delta, `--arch`, `--doc-drift` | Conceptual "How A relates to B", ADR/RFC context, schemas |
|
||||
| **Primary CLI** | `ripwire . --for="…"`, `--callers=SYM`, `--impact=SYM`, `--test-gate` | `graphify query "…"`, `graphify path "A" "B"`, `graphify explain "node"` |
|
||||
| **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.
|
||||
- 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.
|
||||
Never stall on tool errors or hallucinate flags. Dynamic CLI help always takes precedence over memorized parameters.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
---
|
||||
|
||||
## 3. Git Operations & Safety
|
||||
|
||||
- **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.
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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}"
|
||||
@@ -58,10 +58,10 @@ class StaffProfileInline(admin.StackedInline):
|
||||
|
||||
@admin.register(User)
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
fieldsets = UserAdmin.fieldsets + (("سامانه تیکت", {"fields": ("mobile", "account_type")}),)
|
||||
add_fieldsets = UserAdmin.add_fieldsets + (("سامانه تیکت", {"fields": ("mobile", "account_type")}),)
|
||||
list_display = ("username", "mobile", "account_type", "is_active", "is_staff")
|
||||
list_filter = UserAdmin.list_filter + ("account_type",)
|
||||
fieldsets = UserAdmin.fieldsets + (("سامانه تیکت", {"fields": ("mobile", "account_type", "voip_extension", "voip_mobile", "voip_call")}),)
|
||||
add_fieldsets = UserAdmin.add_fieldsets + (("سامانه تیکت", {"fields": ("mobile", "account_type", "voip_extension", "voip_mobile", "voip_call")}),)
|
||||
list_display = ("username", "mobile", "voip_extension", "voip_mobile", "voip_call", "account_type", "is_active", "is_staff")
|
||||
list_filter = UserAdmin.list_filter + ("account_type", "voip_call")
|
||||
inlines = (StaffProfileInline,)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
|
||||
@@ -2,3 +2,6 @@ from django.apps import AppConfig
|
||||
class AccountsConfig(AppConfig):
|
||||
name = "apps.accounts"
|
||||
|
||||
def ready(self):
|
||||
from . import customer_access # Register the login-session receiver.
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.utils import timezone
|
||||
from apps.core.session_access import grant_scoped_session_access
|
||||
|
||||
from .models import TicketMagicLink
|
||||
|
||||
@@ -8,10 +9,7 @@ MAGIC_LINK_SESSION_KEY = "ticket_magic_link_id"
|
||||
|
||||
def grant_ticket_magic_access(request, link):
|
||||
"""Grant an anonymous session access to exactly one ticket until link expiry."""
|
||||
request.session.cycle_key()
|
||||
request.session[MAGIC_LINK_SESSION_KEY] = link.pk
|
||||
remaining = max(1, int((link.expires_at - timezone.now()).total_seconds()))
|
||||
request.session.set_expiry(remaining)
|
||||
grant_scoped_session_access(request, MAGIC_LINK_SESSION_KEY, link.pk, link.expires_at)
|
||||
|
||||
|
||||
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
@@ -19,14 +19,26 @@ class User(PublicIdModel, AbstractUser):
|
||||
class AccountType(models.TextChoices):
|
||||
CUSTOMER = "CUSTOMER", "مشتری"
|
||||
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)
|
||||
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):
|
||||
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)
|
||||
@property
|
||||
def local_mobile(self):
|
||||
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 Purpose(models.TextChoices): LOGIN = "LOGIN", "ورود"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
from .customer_access import customer_login_access
|
||||
|
||||
app_name = "accounts"
|
||||
urlpatterns = [
|
||||
path("login/access/", customer_login_access, name="login_access"),
|
||||
path("", views.home, name="home"),
|
||||
path("login/", views.customer_login, name="login"),
|
||||
path("login/password/", views.customer_password_login, name="password_login"),
|
||||
|
||||
+72
-13
@@ -2,8 +2,11 @@ from django.contrib import messages
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import login, logout
|
||||
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.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 apps.customers.models import CustomerProfile
|
||||
from django.contrib.auth.decorators import login_required
|
||||
@@ -11,14 +14,28 @@ from django.db import transaction
|
||||
from django.db.models import F
|
||||
from .models import MagicLinkAccessLog, OTPChallenge, TicketMagicLink, User
|
||||
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
|
||||
import logging
|
||||
|
||||
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):
|
||||
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
|
||||
def home(request):
|
||||
@@ -27,9 +44,14 @@ def home(request):
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def customer_login(request):
|
||||
_remember_destination(request)
|
||||
if response:=_authenticated_home(request): return response
|
||||
form = MobileForm(request.POST or None)
|
||||
block_reason = request.session.pop(BLOCK_REASON_KEY, "")
|
||||
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
|
||||
try:
|
||||
challenge, code = OTPChallenge.issue(form.cleaned_data["mobile"], request)
|
||||
@@ -45,13 +67,18 @@ def customer_login(request):
|
||||
challenge.delete()
|
||||
logger.exception("OTP SMS delivery failed")
|
||||
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"])
|
||||
def verify_otp(request):
|
||||
if response:=_authenticated_home(request): return response
|
||||
challenge = get_object_or_404(OTPChallenge, pk=request.session.get("otp_challenge_id"))
|
||||
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 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})
|
||||
@@ -60,20 +87,25 @@ def verify_otp(request):
|
||||
if not user.is_active: form.add_error(None, "حساب شما غیرفعال است.")
|
||||
else:
|
||||
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", "کد نامعتبر یا منقضی است.")
|
||||
return render(request, "accounts/otp.html", {"form": form, "mobile": challenge.mobile})
|
||||
|
||||
@require_http_methods(["GET","POST"])
|
||||
def customer_password_login(request):
|
||||
_remember_destination(request)
|
||||
if response:=_authenticated_home(request): return response
|
||||
form=CustomerPasswordLoginForm(request.POST or None)
|
||||
block_reason = request.session.pop(BLOCK_REASON_KEY, "")
|
||||
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)
|
||||
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,"شماره موبایل یا رمز عبور صحیح نیست.")
|
||||
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
|
||||
@require_http_methods(["GET","POST"])
|
||||
@@ -87,14 +119,28 @@ def customer_profile(request):
|
||||
messages.success(request,"پروفایل شما ذخیره شد."); return redirect("accounts:profile")
|
||||
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"])
|
||||
def staff_login(request):
|
||||
destination = _staff_login_destination(request)
|
||||
if response:=_authenticated_home(request): return response
|
||||
form = StaffLoginForm(request.POST or None)
|
||||
if request.method == "POST" and form.is_valid():
|
||||
user = form.authenticate(request)
|
||||
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, "اطلاعات ورود صحیح نیست.")
|
||||
return render(request, "accounts/staff_login.html", {"form": form})
|
||||
|
||||
@@ -106,17 +152,30 @@ def logout_view(request):
|
||||
response["Cache-Control"]="no-store"
|
||||
return response
|
||||
|
||||
@never_cache
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def consume_magic_link(request, token):
|
||||
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/"
|
||||
messages.error(request, "این لینک منقضی شده یا اشتباه است. لطفاً با شماره موبایل خود وارد شوید تا بتوانید پیامها را ببینید.")
|
||||
return redirect("accounts:login")
|
||||
if request.user.is_authenticated:
|
||||
if request.user.pk != link.customer_id:
|
||||
from django.core.exceptions import PermissionDenied
|
||||
raise PermissionDenied
|
||||
else:
|
||||
response = redirect("accounts:login")
|
||||
response["Referrer-Policy"] = "no-referrer"
|
||||
return response
|
||||
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, "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)
|
||||
ip_address = request.META.get("REMOTE_ADDR")
|
||||
user_agent = request.META.get("HTTP_USER_AGENT", "")[:500]
|
||||
|
||||
@@ -1,3 +1,58 @@
|
||||
from django import forms
|
||||
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)
|
||||
@@ -19,3 +19,16 @@ def signed_media_redirect(relative_path, *, download=False, lifetime=300):
|
||||
if download:
|
||||
query["download"] = "1"
|
||||
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
|
||||
@@ -5,9 +5,17 @@ from django.db import models
|
||||
from apps.core.models import PublicIdModel
|
||||
def attachment_path(instance,filename):
|
||||
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}"
|
||||
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)
|
||||
|
||||
def __str__(self):
|
||||
return self.attachment_type
|
||||
|
||||
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","سایر"
|
||||
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)
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
import mimetypes
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import FileResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from apps.tickets.policies import TicketPolicy
|
||||
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 django.views.decorators.cache import never_cache
|
||||
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
|
||||
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
|
||||
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.deleted_at and not request.user.has_perm("messaging.view_deleted_message_content"): raise PermissionDenied
|
||||
return attachment
|
||||
@never_cache
|
||||
def view(request,public_id):
|
||||
attachment=_authorized_attachment(request,public_id)
|
||||
if settings.USE_NGINX_MEDIA:
|
||||
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
|
||||
return attachment_response(_authorized_attachment(request, public_id))
|
||||
@never_cache
|
||||
def download(request,public_id):
|
||||
attachment=_authorized_attachment(request,public_id)
|
||||
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)
|
||||
return attachment_response(_authorized_attachment(request, public_id), download=True)
|
||||
+32
-1
@@ -1,6 +1,7 @@
|
||||
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)
|
||||
@@ -29,6 +30,36 @@ class SiteBrandingAdmin(admin.ModelAdmin):
|
||||
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)
|
||||
class PageTitleAdmin(admin.ModelAdmin):
|
||||
list_display = ("route_name", "title", "is_active", "updated_at")
|
||||
|
||||
@@ -56,13 +56,15 @@ def staff_operations_access(request):
|
||||
if not getattr(user, "is_authenticated", False) or getattr(user, "account_type", "") != "STAFF":
|
||||
return {}
|
||||
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"))
|
||||
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 {"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"),
|
||||
"contacts": allowed("customers.view_customerprofile"),
|
||||
"dispatches": allowed("tickets.create_bulk_dispatch") or allowed("tickets.create_inquiry") or allowed("tickets.dispatch_group_tickets"),
|
||||
"contacts": True,
|
||||
"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"),
|
||||
"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
|
||||
|
||||
|
||||
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):
|
||||
help = "Import a verified production seed bundle into a pristine PostgreSQL database."
|
||||
|
||||
@@ -80,10 +88,7 @@ class Command(BaseCommand):
|
||||
copied_files = []
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Migrations contain defaults. Flush those rows before loading
|
||||
# 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)
|
||||
_load_seed_data(bundle_dir)
|
||||
# Old SQLite snapshots may predate unit-scoped queues. A
|
||||
# category already belongs to exactly one unit, so it is a
|
||||
# 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
@@ -31,7 +31,20 @@ class PublicIdModel(models.Model):
|
||||
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."""
|
||||
site_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_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):
|
||||
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):
|
||||
"""Editable browser title for a named Django route."""
|
||||
route_name = models.CharField(max_length=160, unique=True, help_text="مانند accounts:login")
|
||||
|
||||
@@ -16,6 +16,7 @@ SEED_MODEL_LABELS = (
|
||||
"tickets.SLAConfig",
|
||||
"tickets.TicketTag",
|
||||
"tickets.GroupDispatch",
|
||||
"tickets.DispatchResponseControl",
|
||||
"tickets.Ticket",
|
||||
"tickets.TicketAccessGrant",
|
||||
"tickets.TicketEvent",
|
||||
|
||||
@@ -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)
|
||||
@@ -46,6 +46,14 @@ def jalali_date(value):
|
||||
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
|
||||
def persian_time(value):
|
||||
"""Display local Iran time using Persian digits."""
|
||||
|
||||
+19
-3
@@ -1,5 +1,5 @@
|
||||
from django.contrib import admin
|
||||
from .models import ContactGroup, CustomerProfile
|
||||
from .models import ContactEditLog, ContactGroup, CustomerProfile
|
||||
|
||||
|
||||
@admin.register(ContactGroup)
|
||||
@@ -11,7 +11,23 @@ class ContactGroupAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(CustomerProfile)
|
||||
class CustomerProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "stakeholder_type", "organization_name", "first_name", "last_name")
|
||||
list_filter = ("stakeholder_type", "groups")
|
||||
list_display = ("user", "stakeholder_type", "organization_name", "first_name", "last_name", "login_blocked")
|
||||
list_filter = ("stakeholder_type", "groups", "login_blocked")
|
||||
search_fields = ("user__mobile", "organization_name", "first_name", "last_name")
|
||||
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='مسدود کردن ورود مشتری / مخاطب'),
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,7 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db import transaction
|
||||
from django.core.exceptions import ValidationError
|
||||
from apps.core.models import PersianTextNormalizationMixin, TimeStampedModel
|
||||
|
||||
|
||||
@@ -51,4 +53,48 @@ class CustomerProfile(PersianTextNormalizationMixin, TimeStampedModel):
|
||||
groups = models.ManyToManyField(ContactGroup, blank=True, related_name="contacts")
|
||||
province = 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)
|
||||
|
||||
|
||||
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 = "سوابق ویرایش مخاطبان"
|
||||
@@ -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")
|
||||
@@ -15,6 +15,26 @@ class ConsoleBaleProvider:
|
||||
return NotificationResult(True, "console")
|
||||
|
||||
class BaleBotProvider:
|
||||
def send_photo(self, chat_id, photo, caption='', *, token=None, filename='receipt.jpg', mime_type='image/jpeg'):
|
||||
"""Upload private evidence bytes; do not expose a media URL or log a bot token."""
|
||||
token = settings.BALE_BOT_TOKEN if token is None else token
|
||||
if not token or not chat_id:
|
||||
return NotificationResult(False, error='توکن یا گروه بله تنظیم نشده است.')
|
||||
field, method = ('photo', 'sendPhoto') if mime_type in ('image/jpeg', 'image/png') else ('document', 'sendDocument')
|
||||
try:
|
||||
response = requests.post(f"{settings.BALE_API_BASE.rstrip('/')}/bot{token}/{method}",
|
||||
data={'chat_id': chat_id, 'caption': caption}, files={field: (filename, photo, mime_type)},
|
||||
timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
|
||||
if not response.ok:
|
||||
return NotificationResult(False, error=f'خطای HTTP بله: {response.status_code}')
|
||||
data = response.json()
|
||||
result = data.get('result') if isinstance(data, dict) else None
|
||||
if data.get('ok') is not True or not isinstance(result, dict) or not result.get('message_id'):
|
||||
return NotificationResult(False, error='بله ارسال تصویر را تأیید نکرد.')
|
||||
return NotificationResult(True, str(result['message_id']))
|
||||
except (requests.RequestException, ValueError, AttributeError) as error:
|
||||
return NotificationResult(False, error=f'خطا در ارتباط با بله ({type(error).__name__})')
|
||||
|
||||
def send_message(self, chat_id, text):
|
||||
if not chat_id: return NotificationResult(False, error="Bale chat id is not configured")
|
||||
response = requests.post(f"{settings.BALE_API_BASE.rstrip('/')}/bot{settings.BALE_BOT_TOKEN}/sendMessage", json={"chat_id": chat_id, "text": text, "disable_web_page_preview": True}, timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
|
||||
|
||||
@@ -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, "خطا در ارتباط با سرویس تماس.")
|
||||
@@ -1,3 +1,5 @@
|
||||
from django.contrib import admin
|
||||
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)
|
||||
@@ -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
|
||||
@@ -6,6 +6,8 @@ from apps.tickets.policies import TicketPolicy
|
||||
from .models import Message,MessageRevision
|
||||
@transaction.atomic
|
||||
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)
|
||||
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
|
||||
@@ -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
|
||||
@transaction.atomic
|
||||
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)
|
||||
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
|
||||
|
||||
@@ -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/"))
|
||||
@login_required
|
||||
def revisions(request,public_id):
|
||||
from apps.tickets.dispatch_responses import can_read_response
|
||||
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
|
||||
return render(request,"messaging/revisions.html",{"message":msg,"revisions":msg.revisions.select_related("edited_by")})
|
||||
@@ -1,12 +1,27 @@
|
||||
from django.contrib import admin
|
||||
from .models import NotificationJob,NotificationPolicy,SMSTemplate
|
||||
from .models import AnnouncementJob,NotificationJob,NotificationPolicy,SMSTemplate
|
||||
@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)
|
||||
class SMSTemplateAdmin(admin.ModelAdmin):
|
||||
list_display=("event_type","unit","title","is_active","updated_at")
|
||||
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)
|
||||
class NotificationPolicyAdmin(admin.ModelAdmin):
|
||||
list_display=("key","quiet_period_seconds","is_active","updated_at")
|
||||
|
||||
@@ -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"])
|
||||
@@ -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.messaging.models import MessageReadReceipt
|
||||
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.tickets.models import Ticket
|
||||
|
||||
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):
|
||||
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}/"
|
||||
@@ -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()
|
||||
raw_excerpt = " ".join((message.body if message else "").split())
|
||||
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)
|
||||
jalali = jdatetime.datetime.fromgregorian(datetime=local_created, locale=jdatetime.FA_LOCALE).strftime("%d %B، %H:%M")
|
||||
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}/"])
|
||||
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):
|
||||
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
|
||||
if job.event_type == "UNIT_SUPERVISOR_NEW_TICKET":
|
||||
from apps.tickets.short_links import append_supervisor_ticket_link
|
||||
recipient = job.payload.get("recipient_mobile", "")
|
||||
if not recipient:
|
||||
raise ValueError("Unit supervisor mobile is not configured")
|
||||
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
|
||||
if job.event_type == "CUSTOMER_TICKET_CREATED":
|
||||
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}")
|
||||
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):
|
||||
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()
|
||||
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
|
||||
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()
|
||||
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
|
||||
return _send_customer_reply_bale(job, unit_chat_id)
|
||||
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
|
||||
if not profile.bale_notifications_enabled: return None
|
||||
@@ -158,11 +206,54 @@ def _record_success(job, result):
|
||||
job.last_error = ""
|
||||
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.delivery_is_final = False
|
||||
if job.channel == NotificationJob.Channel.SMS and job.provider_message_id:
|
||||
job.delivery_status = NotificationJob.DeliveryStatus.PENDING
|
||||
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):
|
||||
if options.get("channel") not in (None, NotificationJob.Channel.SMS):
|
||||
return
|
||||
@@ -200,18 +291,30 @@ def _process_delivery_checks(options):
|
||||
NotificationJob.objects.filter(pk__in=[job.pk for job in jobs]).update(
|
||||
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:
|
||||
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:
|
||||
job.delivery_status = ""
|
||||
job.delivery_next_check_at = None
|
||||
job.delivery_description = "این ارائهدهنده از استعلام دلیوری پشتیبانی نمیکند."
|
||||
job.delivery_is_final = False
|
||||
else:
|
||||
job.delivery_check_attempts += 1
|
||||
job.delivery_checked_at = timezone.now()
|
||||
job.delivery_status_code = result.provider_code[:50]
|
||||
job.delivery_description = result.description[:500]
|
||||
job.delivery_is_final = result.is_final
|
||||
if result.is_final:
|
||||
job.delivery_status = result.status
|
||||
job.delivery_next_check_at = None
|
||||
@@ -221,11 +324,20 @@ def _process_delivery_checks(options):
|
||||
else:
|
||||
job.delivery_status = result.status if result.status in NotificationJob.DeliveryStatus.values else NotificationJob.DeliveryStatus.ERROR
|
||||
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.save(update_fields=[
|
||||
"delivery_status", "delivery_status_code", "delivery_description",
|
||||
"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:
|
||||
from apps.tickets.services import refresh_group_dispatch_status
|
||||
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")
|
||||
def handle(self, *args, **options):
|
||||
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)
|
||||
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")
|
||||
@@ -260,7 +374,11 @@ class Command(BaseCommand):
|
||||
for job in jobs:
|
||||
issued_link = None
|
||||
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
|
||||
if job.channel == NotificationJob.Channel.BALE and job.event_type == "CUSTOMER_TICKET_REPLY" and not _prepare_customer_reply_bale(job):
|
||||
continue
|
||||
@@ -282,6 +400,8 @@ class Command(BaseCommand):
|
||||
if job.ticket_id and job.ticket.group_dispatch_id:
|
||||
from apps.tickets.services import refresh_group_dispatch_status
|
||||
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)
|
||||
if options["once"] or options.get("job_id"): break
|
||||
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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
+38
@@ -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),
|
||||
]
|
||||
@@ -10,23 +10,51 @@ def sms_template_tags():
|
||||
class SMSTemplate(PersianTextNormalizationMixin, models.Model):
|
||||
persian_text_fields={"title":False,"body":True}
|
||||
class Event(models.TextChoices):
|
||||
DISPATCH_RESPONSES_OPENED="DISPATCH_RESPONSES_OPENED","بازگشایی پاسخهای ارسال گروهی"
|
||||
CUSTOMER_TICKET_CREATED="CUSTOMER_TICKET_CREATED","ثبت تیکت توسط مشتری"
|
||||
STAFF_TICKET_CREATED="STAFF_TICKET_CREATED","ثبت تیکت توسط کارشناس"
|
||||
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=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)
|
||||
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)
|
||||
is_active=models.BooleanField(default=True)
|
||||
updated_at=models.DateTimeField(auto_now=True)
|
||||
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")]
|
||||
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):
|
||||
self.allowed_tags = self.tags_for_event(self.event_type)
|
||||
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)
|
||||
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_check_attempts=models.PositiveSmallIntegerField(default=0)
|
||||
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:
|
||||
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 "")
|
||||
@@ -1,10 +1,46 @@
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
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):
|
||||
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):
|
||||
"""Queue one concise new-ticket SMS for each configured unit supervisor."""
|
||||
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]
|
||||
|
||||
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):
|
||||
"""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
|
||||
|
||||
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
|
||||
for job in jobs:
|
||||
message_id=job.payload.get("latest_message_id")
|
||||
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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
|
||||
@@ -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('قالب معتبر نیست؛ فقط تگهای معرفیشده و بدون قالببندی اضافی مجازند.')
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PaymentsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.payments'
|
||||
verbose_name = 'واریزها و تأیید مالی'
|
||||
@@ -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 {}
|
||||
@@ -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}
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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})'
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
+46
@@ -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.
@@ -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 = 'سوابق اعلان فیش مشتری'
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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__})')
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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}
|
||||
@@ -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})
|
||||
@@ -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 باید در سمت مصرفکننده یا با محدود کردن دادههای این سرویس کنترل شود.
|
||||
Whitespace-only changes.
@@ -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)
|
||||
@@ -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.
@@ -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"
|
||||
@@ -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()
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import phone_contacts
|
||||
|
||||
|
||||
urlpatterns = [path("", phone_contacts, name="phone_contacts_api")]
|
||||
@@ -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
@@ -14,14 +14,14 @@ class UnitMembershipInline(admin.TabularInline):
|
||||
@admin.register(OrganizationalUnit)
|
||||
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_filter = ("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")
|
||||
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", "supervisor_mobile_5")
|
||||
list_editable = ("sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active")
|
||||
filter_horizontal = ("permissions",)
|
||||
inlines = (UnitMembershipInline,)
|
||||
fieldsets = (
|
||||
("مشخصات واحد", {"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")}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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='افزودن لینک تیکت به پیامک سرپرستان'),
|
||||
),
|
||||
]
|
||||
@@ -37,6 +37,12 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
|
||||
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_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:
|
||||
ordering = ["sort_order", "name", "pk"]
|
||||
@@ -54,6 +60,7 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
|
||||
self.supervisor_mobile_2,
|
||||
self.supervisor_mobile_3,
|
||||
self.supervisor_mobile_4,
|
||||
self.supervisor_mobile_5,
|
||||
))))
|
||||
|
||||
def clean(self):
|
||||
@@ -62,7 +69,7 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
|
||||
|
||||
normalized = []
|
||||
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()
|
||||
if not value:
|
||||
setattr(self, field_name, "")
|
||||
@@ -82,7 +89,7 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
|
||||
def save(self, *args, **kwargs):
|
||||
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()
|
||||
setattr(self, field_name, normalize_mobile(value) if value else "")
|
||||
self.bale_chat_id = self.bale_chat_id.strip()
|
||||
|
||||
+40
-1
@@ -1,5 +1,5 @@
|
||||
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)
|
||||
class TicketAdmin(admin.ModelAdmin):
|
||||
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")
|
||||
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):
|
||||
model = Ticket
|
||||
@@ -27,6 +36,36 @@ class GroupDispatchAdmin(admin.ModelAdmin):
|
||||
readonly_fields = ("recipient_count", "dispatched_at")
|
||||
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)
|
||||
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
Reference in new issue
Block a user