Compare commits
3
Commits
35a1ec1794
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe7a10cb61 | ||
|
|
089db0afa4 | ||
|
|
6b546aad76 |
No files matched your search
+199
-7
@@ -1,11 +1,11 @@
|
||||
Project Root: customerTicket
|
||||
Generated At: 2026-10-11T08:57:27+00:00
|
||||
Python Files: 304
|
||||
Generated At: 2026-10-14T08:18:05+00:00
|
||||
Python Files: 333
|
||||
Django Files: 49
|
||||
JavaScript/TypeScript Files: 39
|
||||
HTML Files: 73
|
||||
Total Files: 1412
|
||||
Total Directories: 134
|
||||
JavaScript/TypeScript Files: 41
|
||||
HTML Files: 77
|
||||
Total Files: 1594
|
||||
Total Directories: 144
|
||||
|
||||
customerTicket
|
||||
├── .agents
|
||||
@@ -144,11 +144,15 @@ customerTicket
|
||||
│ │ │ ├── 0016_announcementjob_requested_by_announcementjob_source_and_more.py
|
||||
│ │ │ ├── 0017_alter_smstemplate_event_type.py
|
||||
│ │ │ ├── 0018_alter_smstemplate_body.py
|
||||
│ │ │ ├── 0019_alter_smstemplate_event_type.py
|
||||
│ │ │ ├── 0020_receipt_rejection_template.py
|
||||
│ │ │ ├── 0021_dispatch_response_opening.py
|
||||
│ │ │ └── __init__.py
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── admin.py
|
||||
│ │ ├── announcement_worker.py
|
||||
│ │ ├── apps.py
|
||||
│ │ ├── dispatch_opening.py
|
||||
│ │ ├── models.py
|
||||
│ │ └── services.py
|
||||
│ ├── payments
|
||||
@@ -160,6 +164,7 @@ customerTicket
|
||||
│ │ │ ├── 0005_paymentnotice_deleted_at.py
|
||||
│ │ │ ├── 0006_normalize_deposit_identifiers.py
|
||||
│ │ │ ├── 0007_receipt_alerts.py
|
||||
│ │ │ ├── 0008_paymentreceiptrevision_depositor_name.py
|
||||
│ │ │ └── __init__.py
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── access.py
|
||||
@@ -170,6 +175,7 @@ customerTicket
|
||||
│ │ ├── customer_picker.py
|
||||
│ │ ├── deposit_identifier.py
|
||||
│ │ ├── forms.py
|
||||
│ │ ├── identity.py
|
||||
│ │ ├── link_views.py
|
||||
│ │ ├── middleware.py
|
||||
│ │ ├── models.py
|
||||
@@ -208,6 +214,7 @@ customerTicket
|
||||
│ │ │ ├── 0010_organizationalunit_notification_destinations.py
|
||||
│ │ │ ├── 0011_organizationalunit_supervisor_mobile_4.py
|
||||
│ │ │ ├── 0012_organizationalunit_supervisor_mobile_5.py
|
||||
│ │ │ ├── 0013_organizationalunit_supervisor_sms_ticket_link_enabled.py
|
||||
│ │ │ └── __init__.py
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── admin.py
|
||||
@@ -233,19 +240,26 @@ customerTicket
|
||||
│ │ │ ├── 0015_seed_customer_category_catalog.py
|
||||
│ │ │ ├── 0016_scope_legacy_public_tickets_to_units.py
|
||||
│ │ │ ├── 0017_ticketaccessgrant_first_viewed_at_and_more.py
|
||||
│ │ │ ├── 0018_dispatch_response_opening.py
|
||||
│ │ │ ├── 0019_staffticketshortlink.py
|
||||
│ │ │ └── __init__.py
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── admin.py
|
||||
│ │ ├── apps.py
|
||||
│ │ ├── category_catalog.py
|
||||
│ │ ├── customer_urls.py
|
||||
│ │ ├── dispatch_presentation.py
|
||||
│ │ ├── dispatch_response_views.py
|
||||
│ │ ├── dispatch_responses.py
|
||||
│ │ ├── forms.py
|
||||
│ │ ├── models.py
|
||||
│ │ ├── policies.py
|
||||
│ │ ├── response_admin.py
|
||||
│ │ ├── selectors.py
|
||||
│ │ ├── services.py
|
||||
│ │ ├── sharing.py
|
||||
│ │ ├── sharing_views.py
|
||||
│ │ ├── short_links.py
|
||||
│ │ ├── staff_conversation.py
|
||||
│ │ ├── staff_urls.py
|
||||
│ │ └── views.py
|
||||
@@ -276,6 +290,12 @@ customerTicket
|
||||
│ │ ├── payment-receipt.html
|
||||
│ │ ├── payment-services.js
|
||||
│ │ ├── README.md
|
||||
│ │ ├── receipt-action-main-desktop.png
|
||||
│ │ ├── receipt-action-main-mobile.png
|
||||
│ │ ├── receipt-action-original-before-mobile.png
|
||||
│ │ ├── receipt-action-preview-desktop.png
|
||||
│ │ ├── receipt-action-preview-mobile.png
|
||||
│ │ ├── receipt-action-preview.html
|
||||
│ │ ├── receipt-amount.js
|
||||
│ │ ├── receipt-banks.js
|
||||
│ │ ├── receipt-date.js
|
||||
@@ -302,16 +322,19 @@ customerTicket
|
||||
│ ├── CHAHARSU_PAYMENTS_IMPLEMENTATION.md
|
||||
│ ├── CHAHARSU_PAYMENTS_IMPLEMENTATION_PLAN.md
|
||||
│ ├── DEPLOYMENT_COOLIFY.md
|
||||
│ ├── DISPATCH_RESPONSE_OPENING.md
|
||||
│ ├── MESSAGE_READ_RECEIPTS.md
|
||||
│ ├── ORGANIZATIONAL_TICKETING.md
|
||||
│ ├── ORGANIZATIONAL_TICKETING_GAP_ANALYSIS.md
|
||||
│ ├── PAYMENT_LINKS.md
|
||||
│ ├── PAYMENT_NOTICE_DELETION.md
|
||||
│ ├── PAYMENT_RECEIPT_ALERTS.md
|
||||
│ ├── PAYMENT_RECEIPT_CUSTOMER_DETAILS_QA.md
|
||||
│ ├── PHONE_CONTACTS_API.md
|
||||
│ ├── PRODUCTION_SEED.md
|
||||
│ ├── QA_PERMISSION_MATRIX.md
|
||||
│ ├── STAFF_SHARING.md
|
||||
│ ├── STAFF_TICKET_SHORT_LINKS.md
|
||||
│ └── THEMING.md
|
||||
├── graphify-out
|
||||
│ ├── 2026-09-05
|
||||
@@ -388,6 +411,27 @@ customerTicket
|
||||
│ │ ├── graph.json
|
||||
│ │ ├── GRAPH_REPORT.md
|
||||
│ │ └── manifest.json
|
||||
│ ├── 2026-10-11
|
||||
│ │ ├── .graphify_analysis.json
|
||||
│ │ ├── .graphify_labels.json
|
||||
│ │ ├── cost.json
|
||||
│ │ ├── graph.json
|
||||
│ │ ├── GRAPH_REPORT.md
|
||||
│ │ └── manifest.json
|
||||
│ ├── 2026-10-12
|
||||
│ │ ├── .graphify_analysis.json
|
||||
│ │ ├── .graphify_labels.json
|
||||
│ │ ├── cost.json
|
||||
│ │ ├── graph.json
|
||||
│ │ ├── GRAPH_REPORT.md
|
||||
│ │ └── manifest.json
|
||||
│ ├── 2026-10-13
|
||||
│ │ ├── .graphify_analysis.json
|
||||
│ │ ├── .graphify_labels.json
|
||||
│ │ ├── cost.json
|
||||
│ │ ├── graph.json
|
||||
│ │ ├── GRAPH_REPORT.md
|
||||
│ │ └── manifest.json
|
||||
│ ├── before-html-semantic-2026-10-04T06-37-15-050Z
|
||||
│ │ ├── .graphify_analysis.json
|
||||
│ │ ├── .graphify_labels.json
|
||||
@@ -409,6 +453,7 @@ customerTicket
|
||||
│ │ │ ├── 03040dc947a4bba22aafbc6262c9db2c8549826ae5aa1ac4cc1821806f93561f.json
|
||||
│ │ │ ├── 039590a02c184d21a28caec4178b981a824ec4356a225d1ce546cc212d2f550f.json
|
||||
│ │ │ ├── 040cdac326eb1631b97e9aeddca78ac07e236243b5b8ab2d77bac37b50a91cdd.json
|
||||
│ │ │ ├── 042c2088faaafc0dd7d522f52e043a37d879ccf3886eeb86fd8ee76caee8274c.json
|
||||
│ │ │ ├── 04b84d19d132764512441a31b31294e4f19dc963ed0a6e3abdd3eca7719e8bca.json
|
||||
│ │ │ ├── 057ccb6901a83922bee4265993977ceb0d0ac425c900cb2d2f3181998df71796.json
|
||||
│ │ │ ├── 058b4e36240cd25d158647c300472baad5e7101ebde0319883abb0edc8c9c74d.json
|
||||
@@ -428,30 +473,37 @@ customerTicket
|
||||
│ │ │ ├── 0f0c4146a70cadafffd025d6954e9202626be74ff97539e984c8b27cda5ed662.json
|
||||
│ │ │ ├── 0f28b01b2484ce84b5251ff848065a2c6f7f574665305b5ecf8cb31a755a7982.json
|
||||
│ │ │ ├── 0feebff2f8ac6ee66557b8692e3a537c0834b6bc4c28a6b9da6f3edf619283c1.json
|
||||
│ │ │ ├── 111fa3c9367ec64a9771decb235bbf09fb0a2bdabe28dd3950016297769be8eb.json
|
||||
│ │ │ ├── 118544d17e045bbbae483669adbc365dbc9b0d707514cff54c8b84ca16042967.json
|
||||
│ │ │ ├── 11d2b71feb1480622fb18ee3795cf070421b146e4273b5016fad81e7cc8e2c7e.json
|
||||
│ │ │ ├── 11ff4da3afbce9cae19102862deefdf725fa363130fad753fa189fa4f6c8c4de.json
|
||||
│ │ │ ├── 12a877e28c12d0651d6487751c6b746afbe8361f9ba7e1b672f09bd596fbcf82.json
|
||||
│ │ │ ├── 12d6990913d6d9783d6c386522e92c492d1f7e7b18775a88d1fbb36597c0697b.json
|
||||
│ │ │ ├── 12db2ab20d1e0be8080b5edd6cc40cacfdff350dd585126a0e0ce144073ad12f.json
|
||||
│ │ │ ├── 12f95828a2c1818402459627e36481199020e31faa280d214f81700fb9ad520d.json
|
||||
│ │ │ ├── 1436cce90bf2f00f050c9eb67cddf5672ed6a3db185ef663c257ee7936b175ae.json
|
||||
│ │ │ ├── 143ae363276b4a1db64805529e618db71c204aa56bc8b54761693a9a2060336c.json
|
||||
│ │ │ ├── 1468b7e152af37dfc56ad4b7c02e23deeb6880efba63a89dcb81934bb30054bd.json
|
||||
│ │ │ ├── 14ffc999200b172d3919e3d9899656c1f8a212769b1e86fe388b82fa302ed1be.json
|
||||
│ │ │ ├── 15539048155c760644fc70dd1fbff3855ceedf11bc6ef7b270d1c7280cb4df48.json
|
||||
│ │ │ ├── 175f840f1587093869a030b52c3b019e020c2fb7cf1223579b251b4857752130.json
|
||||
│ │ │ ├── 17ab5521ea572ea85443db030bd5085f1443c20b20da9cbf808f21ad695eb319.json
|
||||
│ │ │ ├── 17c16884f2afd6ff3a88f7215e7f3ff60eb1199727ddae073765ed12d625b43d.json
|
||||
│ │ │ ├── 18ba25713068baf53992eb4ea94f95212ecaeb931fdcf6769970fc2fd19b2f93.json
|
||||
│ │ │ ├── 199589c167d3eeae5d660bade254f9a273e5f39e1110be9c1eef12d320faa80a.json
|
||||
│ │ │ ├── 19ed1276256eb4d44b9cea2dddac4de3801d67a08cf381a55466e82ee6f2bbc6.json
|
||||
│ │ │ ├── 19ee52d79b7d8eec47a4708aff6c85884496a64aaf84591b70505ce03999283c.json
|
||||
│ │ │ ├── 1bfc839bf502b97d0b70b0bacc9781cae7511d27d078ed6ac6978fd438833dd1.json
|
||||
│ │ │ ├── 1c74c48acbde13e6bc5c2f63b204a1c051f22a8f48cd7b40ac54fc95e8809018.json
|
||||
│ │ │ ├── 1c9f81b7412f571aa252ed10230220684b6657e1115f75a8c3a64b792517d41c.json
|
||||
│ │ │ ├── 1d0ab0c5a2f15cd1c5a78495225dc535c02b58e9da10f882f48182e39b3f8b7b.json
|
||||
│ │ │ ├── 1d35824a27269763b24ce6ed893eed531073cc9e051a8c4f934df5a5a4e2638b.json
|
||||
│ │ │ ├── 1d6b17be6b2c0e7d25b4622b4a8c09ab705cecf9db09e2f86c35a4a564485791.json
|
||||
│ │ │ ├── 1db075565e2fe1027675b7ee03d055150e6b741f3e9865bcd46d9a6113c538a8.json
|
||||
│ │ │ ├── 1e3bef21b169a2020643dc56fc33c665acc164b4b8f07639a9b1f58c37bb4caf.json
|
||||
│ │ │ ├── 1e4a0b7beaeed9f0f2d7088d5944743bf6307504aa956e35ed8ce54bede8ac5a.json
|
||||
│ │ │ ├── 1f440edc6feb5bf8c2a462b1b4a0b44bd22b4a763286fb2b340c797c282b5bcd.json
|
||||
│ │ │ ├── 1f5b6250ae5014735ba63bb94ef5c36cdee4213584c957e6a1473793cb6ee0c1.json
|
||||
│ │ │ ├── 1f6cfd55d13bc48aa6c68558090ceeaf24a54c7bec690d51d95243c904daf0a6.json
|
||||
│ │ │ ├── 1fabd63a56e826ff351d3edca222f5f958004c01f0bd16aed30080e8b13da33f.json
|
||||
│ │ │ ├── 21f38c2f6a379ab402b4d3bd1b0809129c8554ebb01202a160c42a1e9b49b717.json
|
||||
@@ -464,28 +516,35 @@ customerTicket
|
||||
│ │ │ ├── 244d7b4b6409bf25f038ee2751756e867872e873deb39a2c0319233b208bac01.json
|
||||
│ │ │ ├── 252160e3514d77f06df93fd38cddf31464cc41b33126f89b7cf3732afd80ac0f.json
|
||||
│ │ │ ├── 254ff59f2f43fd16c2028b338ec47a892294021f1a354238db59cb2ae6cbdf4a.json
|
||||
│ │ │ ├── 25fa532662f496bc59d96370020c7dd625f0ff62b46f0eb1f4c8d1d94d16b76f.json
|
||||
│ │ │ ├── 2609e627752bd20df923f6c69e090173a368757b3dbd2ce418e3cc9f67c697ba.json
|
||||
│ │ │ ├── 26a41d5fda805c545065f2da12215d350587a61aff951937f54d64bb713f0fab.json
|
||||
│ │ │ ├── 26c6832fa65fe38fa62f98bbfd8d544cf6a470cce547579e1d650390e8b9fded.json
|
||||
│ │ │ ├── 26e3b3874f2dc2c3709945e2f6cf050e5c525933de9f09a8c91b244e3bfa8ca7.json
|
||||
│ │ │ ├── 27668af545c6617927904e70fa532e756b5ecdf46e3071256fa379ce3bdc9974.json
|
||||
│ │ │ ├── 2782dd32ec82052ba614fbf227165cf220ac3475521b90ea987f648d080979a5.json
|
||||
│ │ │ ├── 283dea41a51b90d6d7af34f71843eb53275797d2ba7613073ecf01bb96384f72.json
|
||||
│ │ │ ├── 28cefeff27c9cb5760aead796c37afdb1311bff0a5ff759136f0473ffa0152ff.json
|
||||
│ │ │ ├── 28f6ba204a62353723042ebaf872ce606630d0a70d1442e02a206a016f6a0fb7.json
|
||||
│ │ │ ├── 298888276da87757f59b0e0280ea46039c0ee05945961c3e00b287abcbc6a2f0.json
|
||||
│ │ │ ├── 29aca949b15e953360acae5ba86aac6c34ba8efddc55636fa05c28bd259ec66e.json
|
||||
│ │ │ ├── 29ad05a046027ffb50f2f5afac110df1e7296417ee6844041d84181cbfdfb104.json
|
||||
│ │ │ ├── 2a68e54ab0e9bec51e402455f9592be0fb5f1dbf18210f8338f01fc35b54fd2e.json
|
||||
│ │ │ ├── 2b7cc5d0dec7234082c1797ab637b916ac7df39277f8fbf109ae90190fc1e900.json
|
||||
│ │ │ ├── 2c76016c5b2c45bddcb0aec92b2365ea389feafb0667b2367350c756c7c51d87.json
|
||||
│ │ │ ├── 2c8d91fbfca457646cf60edb03fd3a89a2494a6b6612087854edeac5a119739a.json
|
||||
│ │ │ ├── 2cedf41a22ec35c4568262c1fbb02208903d5ae3d91d6bee9192012c136208c6.json
|
||||
│ │ │ ├── 2d500bf973b697eff7baff105e67f777d7831687b6b552cb786d0d2b6e34c70e.json
|
||||
│ │ │ ├── 2df1edd1fd32c467f237babe4a94f2ed73b67270ede6fa8298b32eab7c3eb80b.json
|
||||
│ │ │ ├── 2e7903ddca9e29c53f1706f2755303e56d60436e494d066d011fdba93e701903.json
|
||||
│ │ │ ├── 2edf2af73b8c2b6f4f195ec878746ca0ecc6575576fec24c2cd5be8d7d30d4a4.json
|
||||
│ │ │ ├── 2ee3c4ba0f8d0c7b04d9b4e795b38c0ab686690e154a22d83b997fdf84c2b801.json
|
||||
│ │ │ ├── 2f2e2e2f48d6f1c856d699606fbc6705871328d271a8ef3fb7bb9e638449d40f.json
|
||||
│ │ │ ├── 2fd5953e7926f320bdeac2d559967758aef4632bf7dfa26a5ee50ad386fc9b67.json
|
||||
│ │ │ ├── 30a1c433d0992b236232f8d17b7e17bbcfac1fd7385c607d1d09e716ba7da9db.json
|
||||
│ │ │ ├── 3142f9d4517f137483c716dd61985811bbf006b85506791e9a2ee37d58b2f53e.json
|
||||
│ │ │ ├── 314eb1aeeb47d9039980ee994d0682472193ed4e187b497797cd3c51a51b18a4.json
|
||||
│ │ │ ├── 321c97fc6e1ed25ab2ea85ec70f9b61cc545bc5f17e8534a471aead3a35dca36.json
|
||||
│ │ │ ├── 32a4505d16f4fd1efc5bc0c2ea7c8900419856b21a41b06d988cc424258ea1c9.json
|
||||
│ │ │ ├── 32c40d17e90ccf2511e124b4fcc5b3674a7200dd5215ee8742d319f8090b04ba.json
|
||||
│ │ │ ├── 336d5e1042b05e1f144a030b9f84836283398076b9f90d8cd4dc37622ab00caf.json
|
||||
@@ -493,6 +552,7 @@ customerTicket
|
||||
│ │ │ ├── 345cea9e55c5f1954119121edf5ec28f1514b11d83de6c5bddf80d014bd98c94.json
|
||||
│ │ │ ├── 34b48eae711a65d522f81081262d437469f08182368d46643dc80eadd96bbdb5.json
|
||||
│ │ │ ├── 34cbb71eca44ad5849b04248e513e7cc33c3d3f1a1f66cc27f732666e585bf19.json
|
||||
│ │ │ ├── 34ee95cf6ae71da6e132fc7523dcf0e14fb271942cdebb9fcea20941e9c8f6d0.json
|
||||
│ │ │ ├── 35087fe77e282f61d742ba82850f71b99fb5a515c98302e214809a662caace81.json
|
||||
│ │ │ ├── 36000938c9c3b13fbd92622f905f8c4f4bfbfdabe66e3357e88c162a4fb198ce.json
|
||||
│ │ │ ├── 360e95dd7f9428a51961a1f4ae7968a6e3101b8df00027432c6c743f64ccf9f8.json
|
||||
@@ -502,6 +562,7 @@ customerTicket
|
||||
│ │ │ ├── 393b2ca571bf671f26f243678a1be73266fe7435c43d7c837f1b53d4dc54cc66.json
|
||||
│ │ │ ├── 3948717314f47afa835e760073df4764ac427f4858f5ec0ee6a2f298ac393d49.json
|
||||
│ │ │ ├── 39712339ec9b29be60c73ea29595c45b9e853f2facc2c4cad9864ecfce375f8b.json
|
||||
│ │ │ ├── 39ec3a5032b396c043170c52f702a4511e2f50e6b340b01fe4e2ebbaefa7a8c8.json
|
||||
│ │ │ ├── 3b3003c9f49c174331e4f6eff0a43e09b9d64d0813c39e0c24805ca796d0d15a.json
|
||||
│ │ │ ├── 3b9252049dbf8ef67e64a756bb41325a6cf50190004d4442119d438620bacc72.json
|
||||
│ │ │ ├── 3b97506bb601aaab9825ae607b89cebbd3a1eb30a6176cb038baf9898ab0e4fb.json
|
||||
@@ -509,8 +570,10 @@ customerTicket
|
||||
│ │ │ ├── 3ca58a3cda78344757f73fb9dc390d4bb3b45f4a711a1a058c049801282ec1f8.json
|
||||
│ │ │ ├── 3cec8055583c7d94e03ced535376a8bd9d3d335cd6c7e328f38dc2a9e6b1e397.json
|
||||
│ │ │ ├── 3d66d87daf2039a3fcd22d5d844e30c00443bed258dc048fbf939d97258e08d2.json
|
||||
│ │ │ ├── 3e23ccb6b6b6496c83b1332b2bad75bc7708e3e24e540289368d15d1288206e3.json
|
||||
│ │ │ ├── 3e4a861d0f7c53bd5ec082f319d974a4a34d5943bf2408a639d11e0ce9bfad46.json
|
||||
│ │ │ ├── 3f0cad4a4a6eb440a2b133bf72f2e72d6e7ef3668f4c8dbc2c759dbd1a41eb6f.json
|
||||
│ │ │ ├── 3ff030d6213f732c2bdc25a4191f8a42cf0d2270fbf07baabff5fa43dcbab6e4.json
|
||||
│ │ │ ├── 401b3df2aa1a5c618d917ae2422e552c59dfbc5220b312fe7977d751168f6987.json
|
||||
│ │ │ ├── 406e12ea55e61f7314b3b46df9e152e549fae319a2f71cbb01e7fe8bc4bde7e0.json
|
||||
│ │ │ ├── 408f6eb568e45e950fdc170776dabf9087829df0db630e16643fba8995318d26.json
|
||||
@@ -520,9 +583,11 @@ customerTicket
|
||||
│ │ │ ├── 42ffbc76dd6afcd90fde30281453fcc765ed07bc0e729fae4eee28d86f840578.json
|
||||
│ │ │ ├── 433946952194be24247d30e4d5ba4d67b64ee2cda27a9212f16b4160a6f3fb71.json
|
||||
│ │ │ ├── 434bb9df51f8929ca10167ce360dbaa9d93fdbfbd27053bdf11f3b171189edb8.json
|
||||
│ │ │ ├── 43da9af305342f5aa4e621721301e188b3d4116d7564e754253beeb4abfea0cd.json
|
||||
│ │ │ ├── 44c4bd6b78660abe3642da6dfe25e665ac7bed04e759528556f437423fbbfe36.json
|
||||
│ │ │ ├── 478a054f8292cd197743a61b5e3e04487f67f8db76ae658a28b04e4aef670ab2.json
|
||||
│ │ │ ├── 479992e96c0c5fac0a00bd4d9e0822dd52273158f5364c26884649b017e5b874.json
|
||||
│ │ │ ├── 479f2c5aa963a3f44aa0abfd802dcea79d026cba9c75e5b04fb21231cb555485.json
|
||||
│ │ │ ├── 47fb67d01a9006c7f835ad87b75b95bb1365c27b342144f081d858e07fd309b3.json
|
||||
│ │ │ ├── 485d542ea0237235e2d09999130ffa61c11da13873b62ca3e9228896f29da35f.json
|
||||
│ │ │ ├── 48f2dd332e3e37756440716095b3b0ba781ebd5abae50130c6b8bfee5a0468cd.json
|
||||
@@ -534,9 +599,12 @@ customerTicket
|
||||
│ │ │ ├── 4ae084687e4372e69f349e8bbc5cf603768a032d3d9ee4d2504e6709ed8ee440.json
|
||||
│ │ │ ├── 4ae419f04a3a3b1221faef43b8526910f1958336162fbce432bab0e596ef2aa1.json
|
||||
│ │ │ ├── 4ae7b7ad5faee3292f9987cf047c1b9f484bb316d9e8c177dd85345bdfc861f3.json
|
||||
│ │ │ ├── 4bdeb74662fa32d7740eab7a45cff522a207ae42c63c9b5873e16e98c8e9ba5e.json
|
||||
│ │ │ ├── 4bf46c6c0f27e14312f7c1311bca1bf5e1bfee29ea5cb35e6f94142141985d23.json
|
||||
│ │ │ ├── 4c7b260c004c38debe30be2b4f5fe3d5bafd7d2aac7ca6a4c1d250644c8dce19.json
|
||||
│ │ │ ├── 4c924746bf7373f2f4b09200257622c6f97c2ba7fb3edc7f6595709e3c7ce9de.json
|
||||
│ │ │ ├── 4ccefdf6d084d96689cc37f74a4b38f2b6d357fa595c259864b087c2766cf7e6.json
|
||||
│ │ │ ├── 4cef0397e20a8910f4671f5238b0f10eef4a606ff0d74d3c7410f5637cd583c6.json
|
||||
│ │ │ ├── 4cf64fe0b79aec18c49355f7d82ab04ae66067042a6c364e89842109427ffb5d.json
|
||||
│ │ │ ├── 4d244ac4c7ac7dddfbb2d8ad6e70b2f9ef0d5226d2660b2ef38ef109f19eacc4.json
|
||||
│ │ │ ├── 4e56ef4c75a0ef1d175a72a6980ba047bb4aa51d49b8a17471d8f342a0b779c0.json
|
||||
@@ -546,16 +614,22 @@ customerTicket
|
||||
│ │ │ ├── 508a0054ec5c5b3353e94566a0f728d533ec47721e20e6037aa55e2d665a5143.json
|
||||
│ │ │ ├── 52cd74ad5803b940489587043429b741434ae931fa4d244f5b0dc53582cee050.json
|
||||
│ │ │ ├── 5323b09e3c325ba8c161f5e2cab233b25cfa1b96ff042c6ae79ae1b029e2b1bc.json
|
||||
│ │ │ ├── 536551cdd087d14f12d75c528f7c0adf9ee3ab6f31d9b7b9f046d8d2d4bf4eb6.json
|
||||
│ │ │ ├── 548ad74cf54ac9bbfc7a148b65d3fc872223dce345313584ea043538366699da.json
|
||||
│ │ │ ├── 54d567d298ddc3c274327011a29bd4f2f2cb31efaae5ecb3a7a94164a3eb3c1f.json
|
||||
│ │ │ ├── 55322e8bcc443a33b31b8e43e27a92b0c89335d5669d7046d34f58d33edef673.json
|
||||
│ │ │ ├── 56f2dee4d30586e872b0df42c6a731dc02143d57c517087a25beb3107c59ecc7.json
|
||||
│ │ │ ├── 5715b58eb1bffef5f32507ec66b2dd71ca9a9b27b3ba31f13c4b1c29b7f42117.json
|
||||
│ │ │ ├── 57bc96866536f0d67300ed29e56bd0f45fa9119c787fac805f228adcab18dc6f.json
|
||||
│ │ │ ├── 57e592c703d95adc50236d5c01fd6314a85061e0969c885789c1045c6d9e3b7a.json
|
||||
│ │ │ ├── 57e983c63cbc017b431587a44d1dbb894ba49714422fe477a917f8d066953f7b.json
|
||||
│ │ │ ├── 57fe8325b25a8879793a5d96b0ed3d069c07b148f81bfc7c07da75ec353cfe76.json
|
||||
│ │ │ ├── 582c91a8ddabbea5521fc65d724e4fb3672c070f9213fae7b58595fcf62f3c85.json
|
||||
│ │ │ ├── 584447cf308657a696ef8465181b12d2c06f9dc5ff69752bea66bd0b9631feec.json
|
||||
│ │ │ ├── 591ef8879d355b6e3a0f7619bf275b8ab072a6e64b9c5a4b30fd892b792a2695.json
|
||||
│ │ │ ├── 592a03ef799b68757045867944b83c39e0d478ee3cc7eb3707d2cde946728943.json
|
||||
│ │ │ ├── 596a13e9b322f8c3332a3ae214ca7bf368ba6158ff3adcc7db4582f102ea51dc.json
|
||||
│ │ │ ├── 598faf3f82fa76bcdeb084eec0d3fb6880941e43e5d8c40ec10f73ddd7da100e.json
|
||||
│ │ │ ├── 5a20a0814514ccdb84861bfc664f1659e9fb8f233dda858d580ca95df84c7c0f.json
|
||||
│ │ │ ├── 5a34ded8b2dd4c10c4251d287580918b88690c7058dd2299d2d670170104241d.json
|
||||
│ │ │ ├── 5ab2de631b0d480df2665ade93dae4732a4d6a1f44b809b2051ccff4696515af.json
|
||||
@@ -569,6 +643,7 @@ customerTicket
|
||||
│ │ │ ├── 5fcd0fdc47a301cbd07130dfa7545017b5975c29b9d7458ec65938ed503506a6.json
|
||||
│ │ │ ├── 608fed7bef745c90c049732f5db2efe3982e483670dac36fff2b6e22ff2953fa.json
|
||||
│ │ │ ├── 60ef2d6fded9e070e35cfed119e8325010aa76bce63aa74ca0f25005d8ed9517.json
|
||||
│ │ │ ├── 62a442e4f632d9f6f6d189a9e2d59b67d6805f5f479dd3034933e59f897ada7b.json
|
||||
│ │ │ ├── 62dab578f0debe93a8df61a1776953980abd254ae6f1eea77afb00dfefd28f15.json
|
||||
│ │ │ ├── 62e55882d2ce81dcfe5df1d58395e767ded55d6e39997d82cc3489c5e02ae910.json
|
||||
│ │ │ ├── 62fba7c993ca0a10198cab8e5a7538f39f88c00face1d324cbf4613effb5ee1b.json
|
||||
@@ -587,6 +662,7 @@ customerTicket
|
||||
│ │ │ ├── 69bef93e5bbc63880e88aca4535dc6a229a57edcfbc65fa829912a3bb847a6ff.json
|
||||
│ │ │ ├── 6a9ce83009b1228569506d7054e15c8c59b4fa9dceb9c15b9cafa288756a2d8c.json
|
||||
│ │ │ ├── 6bd49215f8d2739a9e365dcc8855b95cfa34352c067a9e9ff48a623318c4ddaa.json
|
||||
│ │ │ ├── 6be67cd5ad404d51ec194da65dc4348d5b444292801207ee360699a1f8bee839.json
|
||||
│ │ │ ├── 6c69494ba124d4bc564bcd3a3df1f33ae0ee0769001a2dfc46825ef8b44dc3e2.json
|
||||
│ │ │ ├── 6d3b094dc56f9b26ce7374ba28e460b85248711223cf516f8e732c5d8f4d9d75.json
|
||||
│ │ │ ├── 6d5685e1562faf37f6c30c614bcc7628edd19cd8181d5791aff416daf9fd5a6c.json
|
||||
@@ -595,6 +671,8 @@ customerTicket
|
||||
│ │ │ ├── 6ee9154979fb497a8ec229a7177660af53a487e1aa17c7b555cdc67797615162.json
|
||||
│ │ │ ├── 7093cbd0a855750457814388b6e527fd3b612163f7a2b898ed6ce470dae74843.json
|
||||
│ │ │ ├── 712474bfe126be3bc31a5e719d9119149235071ef01e34cbe1e71ac49fd991ca.json
|
||||
│ │ │ ├── 71916a519f47715fb6a1b6d0aa35d4b29876de58d1db8058fdc7411959b1a6cb.json
|
||||
│ │ │ ├── 724ffe81448b103a20c616e5a5458ab25dd1b7b609cb8ad5062d652e38bdd4fb.json
|
||||
│ │ │ ├── 72ae8a21c9106c639725190cc02f4e07efe7c645896cc4410923ad36751fbb2e.json
|
||||
│ │ │ ├── 72de23f55766289c980e6ccf604ca5d162e0989a222271b7496775ba4d749e3a.json
|
||||
│ │ │ ├── 7379dad88b1741f6f4d999fcea3eb0ac4ffe56f72c07dc11881e94615457bd52.json
|
||||
@@ -608,11 +686,13 @@ customerTicket
|
||||
│ │ │ ├── 7975e2dc7e1d1eb2154b9aba8f19b6207acf85a245cdda0089c9dd0f80463ef5.json
|
||||
│ │ │ ├── 79ede3c31ce20a16af1a767ed248e4a7840803b6d34cf90f1205aa36f3acd7be.json
|
||||
│ │ │ ├── 7a886aec3fe4d432060a7ad6c88a9e0fd810a122ad1d244456af089a828af8c1.json
|
||||
│ │ │ ├── 7aae245e37fceb3e2773bd83ce355517aea9ff35a05a7ec69b73f54fb0722725.json
|
||||
│ │ │ ├── 7b25ccdfeab6d57509c1b8a9745fb601ab7e8fcae0e73a22932ceb725fd38c9a.json
|
||||
│ │ │ ├── 7b6916f499ae0ceae3eb3d509b51f42a7eaa98e43e161fe87c44b9a5bb01287b.json
|
||||
│ │ │ ├── 7bc90a6d89ccfaea18fd0aa05f2ab506d0639360f33413bfa9385ddf49e6d2fe.json
|
||||
│ │ │ ├── 7cb69c820b549230484188086c76dd8d20f68f682fa6345c879cb80d54e09849.json
|
||||
│ │ │ ├── 7cd278b7ea82af8777e4da2f84fd1773dae3dd371744f66bbe12c0b925fd6486.json
|
||||
│ │ │ ├── 7d1800caddb416b3c12e4c593d0240bd52111e04f5da48a44cf879ee42aa3603.json
|
||||
│ │ │ ├── 7d276789b80c8d9d1677556b3fb33b2da05988e97f6bf28903a044bb9fd64894.json
|
||||
│ │ │ ├── 7de85cc2c6e58ead611e3ac76417ecd5316b7fd3f82ae70937c829eb809ea840.json
|
||||
│ │ │ ├── 7e136779a95d8e8e2ac6cad2192890254fd0d54d46b4f5514debba0f22618d75.json
|
||||
@@ -622,16 +702,20 @@ customerTicket
|
||||
│ │ │ ├── 7fa9734f0b6c5bbde268cb55ce9ba5016e9fd7162daaa665ed305a2d7e999bbe.json
|
||||
│ │ │ ├── 80468d8a995fb4092dfe5e57e25592c06713287d48367b675ae4d00955d4b25f.json
|
||||
│ │ │ ├── 806a3b07b690ca3871b1854aaff6f9042e48cf7aaa9bb9b6f15ab9634eae3b37.json
|
||||
│ │ │ ├── 80e8fca4176bc01c35ef1fed2dde8b59db8579229320ae3c4aaf328bb2370e8f.json
|
||||
│ │ │ ├── 80f142842848c4420eeec86959e77c24566467dbece6a275dd9c6da15f98551c.json
|
||||
│ │ │ ├── 81548b27f9188856c3a1dfc9acc5ca22e0671d1c90f84f7ac722d64d132b7a10.json
|
||||
│ │ │ ├── 81b1650d100b8e191c8ef7eef6f8e84e3881c1a2c0d08bed0c35751ee262c405.json
|
||||
│ │ │ ├── 81d5fbc2e91118aac67b18de1ab980f4dd81c8f7804a195e30017626db220ce8.json
|
||||
│ │ │ ├── 8288f4fa57d35d3d5e6b8c39227307c12362b9939e5b5a24447a786416b00362.json
|
||||
│ │ │ ├── 82fcc152dd67510406aa894d1e38939bac365093de9f34db4dfe2485806fda4f.json
|
||||
│ │ │ ├── 833fb89a8d72afc195b8f5bd7338109d8ce7be79f240dac31c4dcd36c8c674f6.json
|
||||
│ │ │ ├── 83ba45b41bd900a8e4b8c4f817e0c2a0098028ca7bbf7d7bc2514505ad25eeba.json
|
||||
│ │ │ ├── 84036d64b3037781ed2a78fa2313860f63eb93a122e3b816db3c6036b18063ce.json
|
||||
│ │ │ ├── 84579b3014e1a83e4201862df1e93378f5f953fbb547fea30965e335be5e7b1e.json
|
||||
│ │ │ ├── 8493b4822bc3ed0ccd847d04d26598953d76d438e8381c1102922ac8530cd4d4.json
|
||||
│ │ │ ├── 84dedd4165eed360c4051690b2aa84f6ecc08f3f053f4129ca6f3e33c334ba35.json
|
||||
│ │ │ ├── 84f37ec0d0c9371255a0deb927163c86c757d95aefa9c3e56907bfa89c6a6532.json
|
||||
│ │ │ ├── 853fba8af82971f4fa56d957282d45d335d2e794f8e11001977161265af2ec79.json
|
||||
│ │ │ ├── 85797284a266529c7ce83ed46795c36a9e23deedd8d2676424d2ef2f0272b7ae.json
|
||||
│ │ │ ├── 8595a5d566d43428818ee229e64f6b56453cc9ca61fd68be0b1c3c783d326315.json
|
||||
@@ -644,10 +728,14 @@ customerTicket
|
||||
│ │ │ ├── 899b16856382d32c4e5b39d02cb251bdedaf35c5a81bede5c19bfaff092208e9.json
|
||||
│ │ │ ├── 8ab55a4f4f776ea75d01aaafdff3502c07b5b86e359789b22c590c7f988bf5f4.json
|
||||
│ │ │ ├── 8af38f9544549f250eff9b83a6c2a0e51f86d6ac51de0db24b48b7c654723a39.json
|
||||
│ │ │ ├── 8b2385018fe75c262a72683cad6bad184f23bb5c2592477a7c03ae3de1e34c6e.json
|
||||
│ │ │ ├── 8b92443bbe1ebe0a8534d065ce886b14e563f6d55f6a3c8b3457c78230d55fa5.json
|
||||
│ │ │ ├── 8e4cb8d07f4d43e171550ff2fc9423efd032b911d7ffee51259e437d4dc32d4f.json
|
||||
│ │ │ ├── 8eafe8590caa628433866798d33e09db75ad30d406aab7c11ed9f62888176b13.json
|
||||
│ │ │ ├── 8f6a7f00c0a8c66241db8c1450cb431107dea30bf65b8e13783ce7f976d98aac.json
|
||||
│ │ │ ├── 8f763b17555f00e8d20ed1ea8e9a065526f0e1fe942e70a62112c13309d78bae.json
|
||||
│ │ │ ├── 900c1aa0f3432793946966ccd1705f31d35f396e872f6beaa491cc4aa397fb47.json
|
||||
│ │ │ ├── 908ce79cabf730090327f55066484254a74c995422c57af507da3ab4e24297b3.json
|
||||
│ │ │ ├── 91c2dc58207c4311391f22d524f461032f7ef3c9443d8f584edb2128966821bb.json
|
||||
│ │ │ ├── 91d83c62f39f219ccb6125502bf8e0a71a587dfc0a66d977a9bb93c8f2dc5dc6.json
|
||||
│ │ │ ├── 91f4a9afed43fafc3d99d7fe5eaa945cd5c89e29d4d6f6448b630c3e58db05e8.json
|
||||
@@ -656,6 +744,7 @@ customerTicket
|
||||
│ │ │ ├── 94ea5a4bac836b2d19b08e446cf90eaea49b9eddc2591d261c384b3513e36f26.json
|
||||
│ │ │ ├── 95dbd272df1d7fd18ba53b86ffda71c7a57046e6a303b88683e323e80ced3a91.json
|
||||
│ │ │ ├── 9697c37cf3a88d2b4eb5419b2381951a9aca966172316db79e7b4c32557d5e87.json
|
||||
│ │ │ ├── 971bcfb611719b6ecbaa2fab9e54895ab1a7e723f910cf36a7f62bde36a909cc.json
|
||||
│ │ │ ├── 97506546de142970349e57e8a14064280e66a4fcdbced5dac55fc263630a2247.json
|
||||
│ │ │ ├── 97d8ebb860cbcd63be9ef80c6e2094e4a7ccba668b291d3c4815133d5fb8f37e.json
|
||||
│ │ │ ├── 980f1be05d605b9093619724462c9881a8be063a5439567866172a35f8d22316.json
|
||||
@@ -663,12 +752,17 @@ customerTicket
|
||||
│ │ │ ├── 98ca8ab4bab0509a82824f0275ca7d1e9e80f5c8189579ae410a115c62126652.json
|
||||
│ │ │ ├── 9931a5b7d7ab58794d003cce1d0719c201c623a844c7f834f66c88e9390c824e.json
|
||||
│ │ │ ├── 996411678d23c3cf8a0e6c1c5076701e8bf5f3dad21ca907ec4116d15366a668.json
|
||||
│ │ │ ├── 99893d006aaeff35ca26608c96a0315981c67391546c01bb7d88aa19c5db2734.json
|
||||
│ │ │ ├── 99f94b7b3b60f627c482cc195709b98cd2faa55f14c19c4bf5e0af8a88f34b7a.json
|
||||
│ │ │ ├── 9a7813a5b55310e424eabea79f20cec77304a5ea2e0825056b243b3333474ea6.json
|
||||
│ │ │ ├── 9ae4928d33e27747b0808cf3e76cc1d27207ad3fe3ae792683616ff8138dc2fb.json
|
||||
│ │ │ ├── 9b267b0c45b93cecc1294ca1e040e5a18fdc9e0bee69a0ca8571ee11becdee97.json
|
||||
│ │ │ ├── 9b8bd2aff50e08b6f053b74a409c9439db41e692e29aabca5b67057884d945af.json
|
||||
│ │ │ ├── 9be5120f28278ca0713aded1ab546d4d4c7074572b49d9d273a48c0899308a57.json
|
||||
│ │ │ ├── 9c6a2c8389a2bd803947027830631a4cd8c6135334036f14ac996cd9ce5119a2.json
|
||||
│ │ │ ├── 9cac12079d625e44ef3f62c6e4c9c7848f1cdd4d086c9251b3c81101cf4c114e.json
|
||||
│ │ │ ├── 9ce16f69c6b04f703adb24ec7498a2d2ecbf6f7524c459deb25111247e550698.json
|
||||
│ │ │ ├── 9ced19a2c0c7eb3f36febda525c01ee787f5e5f3af0933a532074258309b167b.json
|
||||
│ │ │ ├── 9d0e2a1fb14529aabd31c82c8d8f5543c0068366932934038ba93e234f291e22.json
|
||||
│ │ │ ├── 9d3a68c9991464131299b924086faf1e0c474c156019d681ba41e64d8100a7b1.json
|
||||
│ │ │ ├── 9dd5b6a7f155cef3a16a5dc01a1f0cbd458d129e379b93566b68f9c366f21aeb.json
|
||||
@@ -678,12 +772,18 @@ customerTicket
|
||||
│ │ │ ├── a04a8ed5ca0ac9661dda4f6f369248937cf67e61cc982a1f17b24495263d53d6.json
|
||||
│ │ │ ├── a081526ffd8f52690e0834cc71e9d2bf987f949c2d0de68a6eb024063d97e64b.json
|
||||
│ │ │ ├── a130f47959c058751c2afdefa119ce88d7c59fa65883f7e0a69099a022033d4d.json
|
||||
│ │ │ ├── a14c8d1646ade56cdc65885029fe1219a3ad34721276c86d0086b2c5ba63cf3a.json
|
||||
│ │ │ ├── a1bd5a32741129c967a18413ae1be6bf3e20754251066d342d652a668ef95025.json
|
||||
│ │ │ ├── a1bf8757307620bdf8ec85dc9ed54370649324109e6fd40075acefe3e304c88a.json
|
||||
│ │ │ ├── a2fea28bcbfaa240ed1524c5f422481c263eb6f010e0e06ddf8e45c7d1d88e6d.json
|
||||
│ │ │ ├── a31c3a51f3dda4d4231fb5a6d17090edda317ee168e35b23b0c8ffd09e3f1e15.json
|
||||
│ │ │ ├── a3c770875ffabc18c595532501255df82b1a09246ee54b831e7c2221932d4f88.json
|
||||
│ │ │ ├── a3f339fe1ccb99a051c1a3bff70e7de0e2ee6b952db0aa2d6739e97331c29cd9.json
|
||||
│ │ │ ├── a48597143008bc272cad9fff8c2c09df65d71fe4cec49d76589737d20c2adc3b.json
|
||||
│ │ │ ├── a4a27e7b3842713c361895d6d9c5fc9e4341cce71c5842f014748e1c1c23a197.json
|
||||
│ │ │ ├── a4a568f58efea076f16698bb9bb6279a92ab44167a6e97d424fff44900ff973e.json
|
||||
│ │ │ ├── a51a42c75dfee2196ae2aab4c061ab07bb30bd1bdab5e6f68ff3e22d7da1f871.json
|
||||
│ │ │ ├── a51bc493a9af4b7b946f4b9cfeae33bb56c5af747c6ea2454e81e9105361ac0b.json
|
||||
│ │ │ ├── a5231d68fe35e6b02ff4ff70e02a3229c08d9b6c2bae774a1b3227823b95e15a.json
|
||||
│ │ │ ├── a558b874a8c8e3baac9b3c2f6c0aa82abe95057cc86115aa8ba60cf2b0965abf.json
|
||||
│ │ │ ├── a614593a975a3af1612ede797f69fd29220ee9251ab016d34d5e612a04696ce9.json
|
||||
@@ -694,23 +794,29 @@ customerTicket
|
||||
│ │ │ ├── a8274003d8d4ef62b23c66e3baecf14eea260f99a4509411d58506f560c2b474.json
|
||||
│ │ │ ├── a836a06de0905683ff2aee057760fe6e4a3f9161d0cb60544fb7afc9ce782671.json
|
||||
│ │ │ ├── a93b84a0def4d2e0e97ff35032871b0ce64786ff468912aed21fd958b9fe782b.json
|
||||
│ │ │ ├── aa55d877d67523f73fb5426f1e9de1c3224456e73515121885ae326c6bed434f.json
|
||||
│ │ │ ├── ab1648cc3b4d4bba5754e19eeb80ea160fd20207d5530ac371647168f9ed211a.json
|
||||
│ │ │ ├── ab90a45c416c5da4e2e4506ea08cee8fd010acc608a718fee1985213112bf776.json
|
||||
│ │ │ ├── abaaf1c3e88b939bf1833294f5233fa8e7c94091077495c424a0779298cc885d.json
|
||||
│ │ │ ├── abf7b1f16f646d3846b5cff929107d533600504756199d90267f299148d00cbf.json
|
||||
│ │ │ ├── ac454f6bfed5913148eb8c9caaa0e30f99bac381936b44612bdee8274bb27b29.json
|
||||
│ │ │ ├── ad1175b041ab42a2615856a41fbab394ae63ef9dd991901c0658c3fb0846ad3f.json
|
||||
│ │ │ ├── ae4d845b7155a0e8a73ab943ab7c4524cd6e73c3f90be008edb478c968d4fd9c.json
|
||||
│ │ │ ├── aeb59c5e1447023f9c49003f6bf4eb26a7b9a34ca5658f7ba2cde1b99e25ec76.json
|
||||
│ │ │ ├── aee78d4e87210ef46180d47e7015345bd66a7763cd140d84cd86539e720e8e67.json
|
||||
│ │ │ ├── af8910b042526b71500ba7a2e8de889cd8e2fa522b0c8ce45c56b63f206e8da1.json
|
||||
│ │ │ ├── afa89caafcdde5a4d7d85aad1db8c0abaddcebf884d84e8d23c50556b136b1a9.json
|
||||
│ │ │ ├── b00d36db9926c6e18f7a28ed299a8439188a3597a31dcd2e4c476044631b0808.json
|
||||
│ │ │ ├── b0f297dddb05ea13b6bade486a2e88b6746c3c727b9ddfbd640942776b2e2cfb.json
|
||||
│ │ │ ├── b0f3c37fe7f5327da6c37161e405561df7eb920611513b0f7ec544e30763a312.json
|
||||
│ │ │ ├── b137ac0b92c8567809fb0323cd320fbf60c45ae6fad1bbdfb10d6efb22989f0b.json
|
||||
│ │ │ ├── b18617b602ceed65a2741da0eb8f93e4a0296233c96e6a4160f735fe1b6d7ee7.json
|
||||
│ │ │ ├── b390f7bae9f80c48e703a6664d9de6ec9c141cdf8ac2f012d67ac2b4d8a25b4e.json
|
||||
│ │ │ ├── b396b497b11e135cfc19ccbee84e3e67df0e401a412f51c0336c89cb3609b5f5.json
|
||||
│ │ │ ├── b3b223f1a3b058d17a3c0a0e8cb95b4ff2cd54bc57bc5f57f0fd7132919e7007.json
|
||||
│ │ │ ├── b3f59496efa1bcbf6d511580880b5dff989b379977f7649d1a59c73566a35532.json
|
||||
│ │ │ ├── b4446ca11a1f4f6ea45256e755ed56d6ba7c330278e15ead8cae2de28d5e18d8.json
|
||||
│ │ │ ├── b49c628bcd67f1930070ae2de9f370225f2326665544c87bcc9fb443729879cf.json
|
||||
│ │ │ ├── b5b7236fe4db451727eafb0dc2e904fd860f9000b44f898ce55f8ac8709ecee4.json
|
||||
│ │ │ ├── b6e731246dc10854a0fa0455c3c5816e29887b1a8c436d518926e1c2a5e36239.json
|
||||
│ │ │ ├── b7014ccf0ecd3cdccb86d9ac37bd58c5ca00b59d2de58a194eb66ac139e14a5c.json
|
||||
@@ -719,11 +825,13 @@ customerTicket
|
||||
│ │ │ ├── b96845015841d34aa0edcc02a6a51a4ec5f77bd6827d5e9bf8ca6f620c8de3dd.json
|
||||
│ │ │ ├── bb3d09cc5d1bc380e841e2eb19cb497391117101c73c358e6db03db4d6ae51e7.json
|
||||
│ │ │ ├── bb78c0a0d3cbee2cd3238fcc9a1c14cb8bd9ce97a78de60af3aa49f132f966a8.json
|
||||
│ │ │ ├── bc7ff4cd0f26e1e11e3d5e05323b9ede1f540e452bfd40c7d400931c104ac191.json
|
||||
│ │ │ ├── be227af94c0ffcf8abcd019a59577ad2aa5807dffa85ff8c1e8ef2e8de5ec8a9.json
|
||||
│ │ │ ├── beab35cee5ff6b070f62aef30a8db07392e21e64dc733c643575a0c6a7387ffc.json
|
||||
│ │ │ ├── bf759ce9656625911599b351f64028b9bf0bcdcf4adc084e96e9cccc80bbe50c.json
|
||||
│ │ │ ├── c1060dca2a3f437f386324975be659215fe9f7e689137dc85e823879b462d11f.json
|
||||
│ │ │ ├── c10793a36312ddf2bd8ed9edbae5d67a19f54b78ac3cae01682d38c34f2bdf11.json
|
||||
│ │ │ ├── c11eda2ccb808eeb7890628c3e9964ef24745f46ac0d9d503348cc5ad161bbdc.json
|
||||
│ │ │ ├── c199fcf03e3df0de15c0e7fa5f89fbd9649f2be4a257c8de9a7ebc69ce0d629a.json
|
||||
│ │ │ ├── c1a36d35264d786572e00bd20a4d62c18000e4a5a744b4bbd585c66ba2b1afa4.json
|
||||
│ │ │ ├── c251d835538bf3c08a96268c8b1feaae8084a9c298d634ce2f2ee3b4b15188ce.json
|
||||
@@ -732,6 +840,7 @@ customerTicket
|
||||
│ │ │ ├── c478d961844d415bd36c75ab4921d7bafdc9419a9e0bf6a1efc44f14130e50fe.json
|
||||
│ │ │ ├── c4878d8d8ae19e705821ae40afc1ede4be1a64b559dfdf89f898cb4383718c93.json
|
||||
│ │ │ ├── c4a56e0890c5b5bdc963949bff54e8a268081652516ec17e155781a93c56b390.json
|
||||
│ │ │ ├── c4e73c9f9c0baae99d7c40979cdd7f558410145e6251485932cc8bfab0eeaac2.json
|
||||
│ │ │ ├── c6c7b21caf5043dfdf6c25863bf23da656645640cf2ccd914c223f8984a1b3ef.json
|
||||
│ │ │ ├── c8e562a594b215d21999461d5c5fc0894fef0eec4ed7a7ea5b975df305f3d53b.json
|
||||
│ │ │ ├── ca033fb139fdaaaa0d083b9189e67868f0da87d0f954a863d2031c5955075e8e.json
|
||||
@@ -748,12 +857,17 @@ customerTicket
|
||||
│ │ │ ├── cdad1106fd648fc1148b348097481d04bdf5b311be1fe4edd5274162f9348496.json
|
||||
│ │ │ ├── ce52c1ffbf8db0d0b4ba85e0a07055258a8eb179db097f1df874e5be81aae49c.json
|
||||
│ │ │ ├── ce640fd27445864e6f2c38659c24f873a702c7a669a2f0b55df81d62ce26b4ed.json
|
||||
│ │ │ ├── cfd75231baee1115b6e831e2fcc2667125147027bec8da0809c09a3b3aceb380.json
|
||||
│ │ │ ├── d00fe3331efb7013ad62827667fc96174d3262c7feee151ec698f9107a1ed211.json
|
||||
│ │ │ ├── d083dc75811629ffa77ea30c1965e67ef3bc2bf65e5b20a1a35cb0f80a46949c.json
|
||||
│ │ │ ├── d0d623b82da6c3e0f37522e8ddea038c4a1415d50059372915009dcee6628ce1.json
|
||||
│ │ │ ├── d188f9185e71f8910607d275d82fea22199ba0e6009a5b1e34e5a20f041f0d71.json
|
||||
│ │ │ ├── d1d06194e0bbf89054b6372d57be153b1363f032d13be6eb70e35e9e63e42ed0.json
|
||||
│ │ │ ├── d2513d4f5ff7e6088864ff497613f47d4083c1ca378649a3f1db6d45a3f3fb2b.json
|
||||
│ │ │ ├── d26a8fc663e9e5fcf33c87592f8c0456e7fe9ec3390bcb902631144d455c2922.json
|
||||
│ │ │ ├── d2a5e30e7da9c632d50b38210dae96730a0f7185f67de65215aed18208c76d19.json
|
||||
│ │ │ ├── d449c32636f32d9267c66232be6123566cb92d0352de164bd4b01343c1ce75d7.json
|
||||
│ │ │ ├── d45a1d1873fe984cb4e7a8ddaaa8f5385d32a4cc0f8e893d0c12f936d6a91b26.json
|
||||
│ │ │ ├── d54677c4320658676f86c09348933f298192a048b637aef94d607eea8a92b474.json
|
||||
│ │ │ ├── d55e8987ca5ff35748b62290dc14150b1df53163c6f5a5cbe19d03058368f1c6.json
|
||||
│ │ │ ├── d561ececcdd4e7ff59595389fa499591f4e7f3c5324a58dbdd749ccf29e90cd1.json
|
||||
@@ -774,7 +888,11 @@ customerTicket
|
||||
│ │ │ ├── de1655ae1d42e56f0cdb8788c0f6ba1c7c6e6725e6ef5e80969b92ec3e68ac78.json
|
||||
│ │ │ ├── dea57fa4a89ef99a815bd9b1bb71b8475727a3d7d88161a9a696ebf636f29ed0.json
|
||||
│ │ │ ├── df2d803fc69084e91df819bda27a7e9f7303a40ff7a5d30d93fbc16daf5e2d45.json
|
||||
│ │ │ ├── dfa1b188974aa98a6cf45345da9e0a8071b7de5b8a6392494482d6733b19c56e.json
|
||||
│ │ │ ├── dfc9279b0d9023afc1dbe55536fc1cd7a556a6a437775128b9e41615431b4733.json
|
||||
│ │ │ ├── e00e2e63606d8a49d1669a78789e6be1dc4d2aa78365ce58439e1d7c7975e2bf.json
|
||||
│ │ │ ├── e247b821b3662d49d7381b743644858c0d14fd5b7bed224964c292f7ce9daad3.json
|
||||
│ │ │ ├── e2870691d22d4ddbb76733d2050bafd675615cd02645a023b1090f04b499cb11.json
|
||||
│ │ │ ├── e2f4f5c0bcb971ba3cb08a4ba14ed5d1ba6b5899fd36db1f5dcdbd2313cd318e.json
|
||||
│ │ │ ├── e359eb0e19f76b69decc0ec747a7fb4c9843046bda31c5e45841858e01bcb5a7.json
|
||||
│ │ │ ├── e37b6b5144067b3650a3e1045655f367e7027ac2f97a8d8c19ce2e5c5862cea0.json
|
||||
@@ -788,6 +906,7 @@ customerTicket
|
||||
│ │ │ ├── e831be2a985dcf2094a014d182ea02e4ee10b94fbd35b44e3c69a98a7acb3451.json
|
||||
│ │ │ ├── e8f3b285df9ff4d30c78eb1eded3eefdd86cfe325a7c8c29fb8457b6dde5e712.json
|
||||
│ │ │ ├── e90aa709aa08fcf3294352b588ce8c8c902f3ef59a503d4b8d9a292a9c478096.json
|
||||
│ │ │ ├── e9475289c4f92913d5288a655bc50a16c4145e9d0bc9707af427725445dc0049.json
|
||||
│ │ │ ├── e974cd0006d8160a6ef1316083bac9e699adfd87acc7edb404ae7eba1c799b68.json
|
||||
│ │ │ ├── e99791dc7ccf0fde796771290e06e6ea13ecf4d24044945c735f3e9f470934fd.json
|
||||
│ │ │ ├── e9a8155f1a3546e434485a2aca7a04c6857071bfcfd5a7317a62472d90919b9d.json
|
||||
@@ -795,12 +914,14 @@ customerTicket
|
||||
│ │ │ ├── ea3c12254259478e0d71c386203dd7263d9f836d6a6489481bc0deff9b69dc62.json
|
||||
│ │ │ ├── ea56445d1314587e18b1992d9599da03e1419ea7bc4929854b9039d6947de33b.json
|
||||
│ │ │ ├── ea8564b376c7156927c3f412f1a7173248d6b2fd7dcfc3492c4c9d7c44dae889.json
|
||||
│ │ │ ├── eaa257debf517058e8edd49576d57df9f7790772c02a633d99cff4aa966fa9b0.json
|
||||
│ │ │ ├── eb1dd65efa5a99811ccca80ff03a73cb155ad86d13635010a44c09c13953604d.json
|
||||
│ │ │ ├── eb782e7a5b5695997a5bf628a22f4d36f5ddb886e5bdf4b2f4ae53ecc46fbca1.json
|
||||
│ │ │ ├── ebc76bd945a2106c6bbe01b3cc09eb40e65b08aedd7c49709a38ad63511082ad.json
|
||||
│ │ │ ├── ec7939ed122c473ca1a4eee50f1c564e04e95ddced18cca56ed83efe5d4071dd.json
|
||||
│ │ │ ├── ecb32b5c560f5a4cad0801cd49c9a21fe4d19d7638eb5382b73610878c360d00.json
|
||||
│ │ │ ├── ed61ddacfc8c05f7392be053291989b55a01c1bfa5a1dbebbc4bc6e706370f53.json
|
||||
│ │ │ ├── ede449b4cefa55e7ef8bdb457673c28fc3a833184e0e7a0e838e4e658d8ea883.json
|
||||
│ │ │ ├── ee19f3245c11f658fb60d32050a8bc4864ea289071ff5f829717ecacbac1d3e4.json
|
||||
│ │ │ ├── ee89093aa638ec9d38b76ba7adb66c8424d941d79deba2913d3d7085c1a062f7.json
|
||||
│ │ │ ├── effec069fa1e0bba5e3b055368ccbb033f964a968890d7efe7dc3fa9ae4f9c0f.json
|
||||
@@ -1012,7 +1133,8 @@ customerTicket
|
||||
│ │ ├── query_20260917_063554_1824eb56_پس_از_باز_شدن_کیبورد_موبایل__کادر_نوشتن_پاسخ_تیکت.md
|
||||
│ │ ├── query_20260923_131543_e5730a0b_how_do_staff_ticket_sms_status__failed_delivery__a.md
|
||||
│ │ ├── query_20261006_111656_eab3ac96_how_do_payment_approval_policy_and_receipt_approva.md
|
||||
│ │ └── query_20261007_080904_8f4d683c_payment_notice__bank_account_snapshots__short_paym.md
|
||||
│ │ ├── query_20261007_080904_8f4d683c_payment_notice__bank_account_snapshots__short_paym.md
|
||||
│ │ └── query_20261013_122853_ce932abc_how_do_organizational_ticketing_rules_relate_to_su.md
|
||||
│ ├── reflections
|
||||
│ │ └── LESSONS.md
|
||||
│ ├── .graphify_analysis.json
|
||||
@@ -1057,7 +1179,9 @@ customerTicket
|
||||
│ ├── db-before-announcement-fix-20261004-115635.sqlite3
|
||||
│ ├── db-before-deposit-normalization-20261007-113436.sqlite3
|
||||
│ ├── db-before-payment-links-20261006.sqlite3
|
||||
│ ├── db-before-receipt-customer-details-20261011.sqlite3
|
||||
│ ├── db-before-staff-sharing.sqlite3
|
||||
│ ├── db-before-staff-ticket-short-links-20261013.sqlite3
|
||||
│ ├── db-pre-chaharsu.sqlite3
|
||||
│ ├── deposit-sharing-live-1440.png
|
||||
│ ├── deposit-sharing-live-320.png
|
||||
@@ -1155,6 +1279,7 @@ customerTicket
|
||||
│ │ ├── customer-profile.css
|
||||
│ │ ├── customer-ticket-wizard.css
|
||||
│ │ ├── customer-tickets.css
|
||||
│ │ ├── dispatch-workspace.css
|
||||
│ │ ├── dispatches.css
|
||||
│ │ ├── enhancements.css
|
||||
│ │ ├── magic-link-confirmation.css
|
||||
@@ -1182,6 +1307,7 @@ customerTicket
|
||||
│ │ │ ├── deposit-identifier.js
|
||||
│ │ │ ├── payments.js
|
||||
│ │ │ ├── receipt-amount.js
|
||||
│ │ │ ├── receipt-decision.js
|
||||
│ │ │ ├── receipt-form.js
|
||||
│ │ │ └── receipt-upload.js
|
||||
│ │ ├── attachment-uploader.js
|
||||
@@ -1190,6 +1316,7 @@ customerTicket
|
||||
│ │ ├── conversation-read.js
|
||||
│ │ ├── conversation.js
|
||||
│ │ ├── customer-ticket-wizard.js
|
||||
│ │ ├── dispatch-workspace.js
|
||||
│ │ ├── image-viewer.js
|
||||
│ │ ├── location-picker.js
|
||||
│ │ ├── magic-link-confirmation.js
|
||||
@@ -1227,6 +1354,7 @@ customerTicket
|
||||
│ │ ├── _customer_identity.html
|
||||
│ │ ├── _customer_picker.html
|
||||
│ │ ├── _customer_receipt_form.html
|
||||
│ │ ├── _daily_summary.html
|
||||
│ │ ├── _field.html
|
||||
│ │ ├── _icon.html
|
||||
│ │ ├── _link_status.html
|
||||
@@ -1255,6 +1383,7 @@ customerTicket
|
||||
│ ├── tickets
|
||||
│ │ ├── _announcement_status.html
|
||||
│ │ ├── _customer_dock.html
|
||||
│ │ ├── _dispatch_icon.html
|
||||
│ │ ├── _image_viewer.html
|
||||
│ │ ├── _immediate_announcement.html
|
||||
│ │ ├── _messages.html
|
||||
@@ -1273,6 +1402,7 @@ customerTicket
|
||||
│ │ ├── customer_detail.html
|
||||
│ │ ├── customer_list.html
|
||||
│ │ ├── customer_new.html
|
||||
│ │ ├── dispatch_open_confirm.html
|
||||
│ │ ├── staff_contacts.html
|
||||
│ │ ├── staff_create.html
|
||||
│ │ ├── staff_customers.html
|
||||
@@ -1299,6 +1429,36 @@ customerTicket
|
||||
│ │ ├── tickets-1440.png
|
||||
│ │ ├── tickets-320.png
|
||||
│ │ └── tickets-393.png
|
||||
│ ├── dispatch-design
|
||||
│ │ ├── compose-1440.png
|
||||
│ │ ├── compose-320.png
|
||||
│ │ ├── compose-390.png
|
||||
│ │ ├── live-create-1440.png
|
||||
│ │ ├── live-create-320.png
|
||||
│ │ ├── live-create-390.png
|
||||
│ │ ├── live-history-1440.png
|
||||
│ │ ├── live-report-1440.png
|
||||
│ │ ├── live-report-320.png
|
||||
│ │ ├── live-report-390.png
|
||||
│ │ ├── migrate_local_database.py
|
||||
│ │ ├── opened-report-1440.png
|
||||
│ │ ├── opened-report-320.png
|
||||
│ │ ├── opened-report-390.png
|
||||
│ │ ├── opening-confirm-1440.png
|
||||
│ │ ├── opening-confirm-320.png
|
||||
│ │ ├── opening-confirm-390.png
|
||||
│ │ ├── quality-delta.xml
|
||||
│ │ ├── report-1440.png
|
||||
│ │ ├── report-320.png
|
||||
│ │ ├── report-390.png
|
||||
│ │ ├── review-1440.png
|
||||
│ │ ├── review-320.png
|
||||
│ │ ├── review-390.png
|
||||
│ │ ├── review-inquiry-recommendation.png
|
||||
│ │ ├── sealed-report-1440.png
|
||||
│ │ ├── sealed-report-320.png
|
||||
│ │ ├── sealed-report-390.png
|
||||
│ │ └── test-gate.xml
|
||||
│ ├── e2e
|
||||
│ │ ├── design-prototypes_ticket-new-wizard_test_prototype_e2e.py__test_ticket_wizard_progressive_flow_preview_edit_and_attachment_removal
|
||||
│ │ │ ├── failure.png
|
||||
@@ -1336,6 +1496,18 @@ customerTicket
|
||||
│ │ ├── tests_e2e_test_customer_unit_categories_e2e.py__test_customer_switches_unit_and_sees_its_categories
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_dispatch_response_opening_e2e.py__test_sealed_report_requires_confirmation_and_opens_answers_together[1440]
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_dispatch_workspace_e2e.py__test_report_filters_search_and_private_reply_navigation[390]
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_dispatch_workspace_e2e.py__test_review_preserves_draft_and_requires_confirmation_before_group_send[320]
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_dispatch_workspace_e2e.py__test_review_preserves_draft_and_requires_confirmation_before_group_send[390]
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_magic_link_confirmation_e2e.py__test_cancel_returns_to_logged_in_account
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
@@ -1438,6 +1610,12 @@ customerTicket
|
||||
│ │ ├── tests_e2e_test_pwa_versioning.py__test_pwa_registers_versioned_worker_and_matching_cache
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_receipt_customer_details_e2e.py__test_required_payer_on_every_receipt_entry_point[link]
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_receipt_customer_details_e2e.py__test_staff_can_cancel_then_confirm_rejection_sms
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_receipt_upload_e2e.py__test_receipt_upload_progress_processing_and_single_submission[link-320]
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
@@ -1468,15 +1646,18 @@ customerTicket
|
||||
│ │ │ ├── customer_new_ticket.py
|
||||
│ │ │ ├── customer_profile.py
|
||||
│ │ │ ├── customer_tickets.py
|
||||
│ │ │ ├── dispatches.py
|
||||
│ │ │ ├── magic_link.py
|
||||
│ │ │ ├── message_reads.py
|
||||
│ │ │ ├── notice_deletion.py
|
||||
│ │ │ ├── payment_feedback.py
|
||||
│ │ │ ├── payment_link.py
|
||||
│ │ │ ├── payments.py
|
||||
│ │ │ ├── receipt_customer_details.py
|
||||
│ │ │ ├── receipt_upload.py
|
||||
│ │ │ ├── sharing.py
|
||||
│ │ │ ├── staff_payments.py
|
||||
│ │ │ ├── staff_short_link.py
|
||||
│ │ │ ├── ticket_notices.py
|
||||
│ │ │ └── tickets.py
|
||||
│ │ ├── config.py
|
||||
@@ -1486,21 +1667,26 @@ customerTicket
|
||||
│ │ ├── test_customer_profile_design.py
|
||||
│ │ ├── test_customer_tickets_design.py
|
||||
│ │ ├── test_customer_unit_categories_e2e.py
|
||||
│ │ ├── test_dispatch_response_opening_e2e.py
|
||||
│ │ ├── test_dispatch_workspace_e2e.py
|
||||
│ │ ├── test_external_environment_smoke.py
|
||||
│ │ ├── test_magic_link_confirmation_e2e.py
|
||||
│ │ ├── test_message_read_receipts_e2e.py
|
||||
│ │ ├── test_mobile_ticket_viewport.py
|
||||
│ │ ├── test_notice_deletion_e2e.py
|
||||
│ │ ├── test_organization_permissions_e2e.py
|
||||
│ │ ├── test_payment_daily_summary_e2e.py
|
||||
│ │ ├── test_payment_feedback_e2e.py
|
||||
│ │ ├── test_payment_links_e2e.py
|
||||
│ │ ├── test_payments_e2e.py
|
||||
│ │ ├── test_public_ticket_workflows_e2e.py
|
||||
│ │ ├── test_pwa_versioning.py
|
||||
│ │ ├── test_receipt_customer_details_e2e.py
|
||||
│ │ ├── test_receipt_upload_e2e.py
|
||||
│ │ ├── test_staff_operations_ui.py
|
||||
│ │ ├── test_staff_payment_sms_e2e.py
|
||||
│ │ ├── test_staff_sharing_e2e.py
|
||||
│ │ ├── test_staff_ticket_short_links_e2e.py
|
||||
│ │ └── test_ticket_notices_e2e.py
|
||||
│ ├── js
|
||||
│ │ └── chat-coach.test.mjs
|
||||
@@ -1509,6 +1695,9 @@ customerTicket
|
||||
│ ├── test_customer_category_catalog.py
|
||||
│ ├── test_customer_unit_categories.py
|
||||
│ ├── test_deployment.py
|
||||
│ ├── test_dispatch_opening_postgres.py
|
||||
│ ├── test_dispatch_presentation.py
|
||||
│ ├── test_dispatch_response_opening.py
|
||||
│ ├── test_domain.py
|
||||
│ ├── test_env.py
|
||||
│ ├── test_jalali_templates.py
|
||||
@@ -1516,6 +1705,7 @@ customerTicket
|
||||
│ ├── test_message_read_receipts.py
|
||||
│ ├── test_organizational_ticketing.py
|
||||
│ ├── test_payment_concurrency.py
|
||||
│ ├── test_payment_daily_summary.py
|
||||
│ ├── test_payment_links.py
|
||||
│ ├── test_payment_notice_concurrency.py
|
||||
│ ├── test_payment_notice_deletion.py
|
||||
@@ -1524,11 +1714,13 @@ customerTicket
|
||||
│ ├── test_permission_architecture.py
|
||||
│ ├── test_persian_text.py
|
||||
│ ├── test_production_seed.py
|
||||
│ ├── test_receipt_customer_details.py
|
||||
│ ├── test_role_workflow_regression.py
|
||||
│ ├── test_staff_inline_sharing.py
|
||||
│ ├── test_staff_payment_sms.py
|
||||
│ ├── test_staff_shared_queue.py
|
||||
│ ├── test_staff_sharing.py
|
||||
│ ├── test_staff_ticket_short_links.py
|
||||
│ ├── test_views.py
|
||||
│ └── test_voip_calls.py
|
||||
├── .dockerignore
|
||||
|
||||
@@ -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,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"),
|
||||
|
||||
+31
-3
@@ -14,6 +14,7 @@ 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
|
||||
|
||||
@@ -46,7 +47,11 @@ 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)
|
||||
@@ -62,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})
|
||||
@@ -86,12 +96,16 @@ 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(_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"])
|
||||
@@ -105,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})
|
||||
|
||||
|
||||
@@ -54,4 +54,5 @@ class AttachmentPolicyAdmin(admin.ModelAdmin):
|
||||
form = AttachmentPolicyAdminForm
|
||||
|
||||
|
||||
admin.site.register(Attachment)
|
||||
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,6 +5,10 @@ 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)
|
||||
|
||||
@@ -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)
|
||||
@@ -64,7 +64,7 @@ def staff_operations_access(request):
|
||||
"create_ticket": user.has_perm("tickets.add_ticket") or allowed("tickets.add_ticket"),
|
||||
"contacts": True,
|
||||
"contact_groups": allowed("customers.view_contactgroup") or allowed("customers.add_contactgroup"),
|
||||
"dispatches": allowed("tickets.create_bulk_dispatch") or allowed("tickets.create_inquiry") or allowed("tickets.dispatch_group_tickets"),
|
||||
"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.
|
||||
|
||||
@@ -16,6 +16,7 @@ SEED_MODEL_LABELS = (
|
||||
"tickets.SLAConfig",
|
||||
"tickets.TicketTag",
|
||||
"tickets.GroupDispatch",
|
||||
"tickets.DispatchResponseControl",
|
||||
"tickets.Ticket",
|
||||
"tickets.TicketAccessGrant",
|
||||
"tickets.TicketEvent",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -11,8 +11,8 @@ 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",)
|
||||
|
||||
|
||||
@@ -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,6 +53,34 @@ 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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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")})
|
||||
@@ -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)
|
||||
@@ -58,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=[
|
||||
@@ -85,6 +88,9 @@ def _prepare_sms(job):
|
||||
|
||||
|
||||
def _send_sms(job):
|
||||
if job.event_type == "DISPATCH_RESPONSES_OPENED":
|
||||
from apps.notifications.dispatch_opening import send_opening_sms
|
||||
return send_opening_sms(job, get_sms_provider), None
|
||||
if job.event_type == 'PAYMENT_RECEIPT_RECEIVED':
|
||||
from apps.payments.receipt_alerts import send_receipt_alert
|
||||
return send_receipt_alert(job, sms_provider_factory=get_sms_provider), None
|
||||
@@ -93,10 +99,12 @@ def _send_sms(job):
|
||||
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")
|
||||
@@ -109,6 +117,19 @@ 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
|
||||
@@ -117,9 +138,7 @@ def _send_bale(job):
|
||||
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
|
||||
|
||||
@@ -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,6 +10,7 @@ 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","پاسخ کارشناس"
|
||||
@@ -29,6 +30,8 @@ class SMSTemplate(PersianTextNormalizationMixin, models.Model):
|
||||
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)
|
||||
|
||||
@@ -28,7 +28,9 @@ def receipt_alert_context(revision):
|
||||
|
||||
|
||||
def queue_receipt_alerts(revision, actor):
|
||||
if actor.account_type != 'CUSTOMER' or actor.pk != revision.receipt.customer_id:
|
||||
# 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):
|
||||
|
||||
@@ -1,8 +1,46 @@
|
||||
"""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, F, OuterRef, Subquery, Sum, Value
|
||||
from django.db.models import BigIntegerField, Count, F, OuterRef, Q, Subquery, Sum, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from .models import PaymentReceiptRevision
|
||||
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):
|
||||
|
||||
@@ -279,6 +279,7 @@ def desk(request):
|
||||
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', '')
|
||||
@@ -318,7 +319,8 @@ def desk(request):
|
||||
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()))
|
||||
can_create=policies.can_create(request.user), banks=CompanyBankAccount.objects.all(), query_string=query.urlencode(),
|
||||
daily_summary=daily_summary))
|
||||
|
||||
|
||||
@financial_required(staff=True)
|
||||
|
||||
+2
-2
@@ -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")
|
||||
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", "supervisor_mobile_5")}),
|
||||
("اطلاعرسانی بله و سرپرستان", {"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,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='افزودن لینک تیکت به پیامک سرپرستان'),
|
||||
),
|
||||
]
|
||||
@@ -38,6 +38,11 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
|
||||
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"]
|
||||
|
||||
+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):
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Read-only option metadata for the staff dispatch composer."""
|
||||
|
||||
from django.db.models import Count, Q
|
||||
|
||||
from .models import GroupDispatch, TicketCategory
|
||||
from .policies import DispatchPolicy
|
||||
|
||||
|
||||
def dispatch_form_options(user, units, groups):
|
||||
"""Expose only permitted units; recipient counts follow the sending service."""
|
||||
units = list(units)
|
||||
eligible = (
|
||||
Q(contacts__user__is_active=True, contacts__user__mobile__isnull=False)
|
||||
& ~Q(contacts__user__mobile="")
|
||||
)
|
||||
return {
|
||||
"units": [
|
||||
{
|
||||
"id": unit.pk,
|
||||
"name": unit.name,
|
||||
"operations": [
|
||||
kind for kind in GroupDispatch.OperationType.values
|
||||
if DispatchPolicy.can_create(user, unit, kind)
|
||||
],
|
||||
}
|
||||
for unit in units
|
||||
],
|
||||
"groups": list(groups.annotate(recipient_count=Count("contacts", filter=eligible)).values(
|
||||
"id", "name", "unit_id", "recipient_count",
|
||||
)),
|
||||
"categories": list(TicketCategory.objects.filter(
|
||||
Q(unit_id__in=[unit.pk for unit in units]) | Q(unit__isnull=True),
|
||||
is_active=True,
|
||||
).order_by("title").values("id", "title", "unit_id")),
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
from django.core import signing
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from .dispatch_responses import can_open_responses, open_dispatch_responses, render_opening_sms, response_control
|
||||
from .models import GroupDispatch
|
||||
from apps.notifications.models import SMSTemplate
|
||||
from .views import staff_required
|
||||
|
||||
|
||||
@staff_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
@never_cache
|
||||
def open_responses(request, public_id):
|
||||
dispatch = get_object_or_404(GroupDispatch.objects.select_related("unit"), public_id=public_id)
|
||||
control = response_control(dispatch)
|
||||
if not control or control.mode != "SEALED" or not can_open_responses(request.user, dispatch):
|
||||
raise PermissionDenied
|
||||
if control.opened_at:
|
||||
return redirect("staff_tickets:dispatch_detail", public_id=public_id)
|
||||
if request.method == "POST":
|
||||
try:
|
||||
proof = signing.loads(request.POST.get("confirmation", ""), salt="dispatch-opening", max_age=600)
|
||||
if proof["user"] != request.user.pk or proof["dispatch"] != str(public_id):
|
||||
raise signing.BadSignature
|
||||
open_dispatch_responses(dispatch, request.user, proof["sms"])
|
||||
except (signing.BadSignature, KeyError):
|
||||
messages.error(request, "تأیید معتبر نیست یا مهلت آن پایان یافته؛ دوباره بررسی کنید.")
|
||||
except (ValidationError, ValueError, SMSTemplate.DoesNotExist) as error:
|
||||
messages.error(request, str(error))
|
||||
else:
|
||||
messages.success(request, "پاسخها همزمان بازگشایی شدند؛ وضعیت پیامک را در گزارش بررسی کنید.")
|
||||
return redirect("staff_tickets:dispatch_detail", public_id=public_id)
|
||||
try:
|
||||
sms = render_opening_sms(dispatch) if control.notify_sms else ""
|
||||
except (SMSTemplate.DoesNotExist, ValueError):
|
||||
messages.error(request, "قالب پیامک بازگشایی معتبر یا فعال نیست؛ تنظیمات قالب را بررسی کنید.")
|
||||
return redirect("staff_tickets:dispatch_detail", public_id=public_id)
|
||||
from .dispatch_responses import _respondents
|
||||
proof = signing.dumps({"user": request.user.pk, "dispatch": str(public_id), "sms": sms}, salt="dispatch-opening")
|
||||
return render(request, "tickets/dispatch_open_confirm.html", {"dispatch": dispatch, "control": control,
|
||||
"opening_sms": sms, "respondent_count": len(_respondents(dispatch)), "confirmation": proof})
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Response confidentiality is independent of ordinary ticket permissions."""
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, F, Max, Min, Q
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import DispatchResponseControl, GroupDispatch
|
||||
from .policies import DispatchPolicy, TicketPolicy
|
||||
|
||||
|
||||
OPENING_EVENT = "DISPATCH_RESPONSES_OPENED"
|
||||
|
||||
|
||||
def response_control(dispatch):
|
||||
if dispatch is None:
|
||||
return None
|
||||
return DispatchResponseControl.objects.filter(dispatch_id=dispatch.pk).first()
|
||||
|
||||
|
||||
def responses_locked(ticket):
|
||||
control = response_control(ticket.group_dispatch) if ticket.group_dispatch_id else None
|
||||
return bool(control and control.mode == control.Mode.SEALED and control.opened_at is None)
|
||||
|
||||
|
||||
def sealed_message_q(prefix=""):
|
||||
return Q(**{
|
||||
prefix + "ticket__group_dispatch__response_control__mode": "SEALED",
|
||||
prefix + "ticket__group_dispatch__response_control__opened_at__isnull": True,
|
||||
prefix + "author_id": F(prefix + "ticket__customer_id"),
|
||||
})
|
||||
|
||||
|
||||
def can_read_response(user, message):
|
||||
if message.author_id != message.ticket.customer_id:
|
||||
return True
|
||||
if getattr(user, "account_type", None) == "CUSTOMER" and user.pk == message.author_id:
|
||||
return True
|
||||
return not responses_locked(message.ticket)
|
||||
|
||||
|
||||
def filter_response_messages(query, user):
|
||||
if getattr(user, "account_type", None) == "CUSTOMER":
|
||||
return query.exclude(sealed_message_q() & ~Q(author_id=user.pk))
|
||||
return query.exclude(sealed_message_q())
|
||||
|
||||
|
||||
def can_record_ticket_read(ticket, user):
|
||||
if not TicketPolicy.can_view(user, ticket):
|
||||
return False
|
||||
if not responses_locked(ticket):
|
||||
return True
|
||||
return not ticket.ticket_messages.filter(author_id=ticket.customer_id, deleted_at__isnull=True).exists()
|
||||
|
||||
|
||||
def can_open_responses(user, dispatch):
|
||||
if not user.is_authenticated or not user.is_active or not DispatchPolicy.can_view(user, dispatch):
|
||||
return False
|
||||
profile = getattr(user, "staff_profile", None)
|
||||
if getattr(user, "account_type", None) != "STAFF" or (profile and not profile.is_active):
|
||||
return False
|
||||
return bool(user.is_superuser or dispatch.created_by_id == user.pk or user.has_perm("tickets.open_all_dispatch_responses"))
|
||||
|
||||
|
||||
def render_opening_sms(dispatch):
|
||||
from apps.notifications.management.commands.process_notifications import _render_sms
|
||||
return _render_sms(OPENING_EVENT, {}, dispatch.unit)
|
||||
|
||||
|
||||
def _respondents(dispatch):
|
||||
# A withdrawn/deleted message is not a current response; attachment-only replies count.
|
||||
return list(dispatch.tickets.filter(ticket_messages__author_id=F("customer_id"),
|
||||
ticket_messages__deleted_at__isnull=True).distinct().order_by("pk"))
|
||||
|
||||
|
||||
def _queue_opening_sms(control, tickets):
|
||||
from apps.notifications.services import enqueue
|
||||
from apps.notifications.models import NotificationJob
|
||||
for ticket in tickets:
|
||||
enqueue(NotificationJob.Channel.SMS, OPENING_EVENT, ticket,
|
||||
f"dispatch:{control.dispatch_id}:opened:{ticket.customer_id}",
|
||||
customer=ticket.customer,
|
||||
payload={"dispatch_id": control.dispatch_id, "body": control.sms_body_snapshot})
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def open_dispatch_responses(dispatch, user, expected_sms=""):
|
||||
# Replies take the same control lock before locking their ticket.
|
||||
control = DispatchResponseControl.objects.select_for_update(of=("self",)).select_related("dispatch__unit").get(dispatch=dispatch)
|
||||
dispatch = control.dispatch
|
||||
if not can_open_responses(user, dispatch):
|
||||
raise PermissionDenied
|
||||
if control.mode != control.Mode.SEALED:
|
||||
raise ValidationError("این ارسال در حالت مشاهده جداگانه است.")
|
||||
if control.opened_at:
|
||||
return control, False
|
||||
text = render_opening_sms(dispatch) if control.notify_sms else ""
|
||||
if text != expected_sms:
|
||||
raise ValidationError("قالب پیامک تغییر کرده است؛ متن جدید را بررسی و دوباره تأیید کنید.")
|
||||
tickets = _respondents(dispatch)
|
||||
control.opened_at = timezone.now()
|
||||
control.opened_by = user
|
||||
control.sms_body_snapshot = text
|
||||
control.respondents_snapshot = [str(ticket.customer.public_id) for ticket in tickets]
|
||||
control.save(update_fields=["opened_at", "opened_by", "sms_body_snapshot", "respondents_snapshot"])
|
||||
if control.notify_sms:
|
||||
_queue_opening_sms(control, tickets)
|
||||
return control, True
|
||||
|
||||
|
||||
def lock_dispatch_for_ticket(ticket_id):
|
||||
"""Must be called inside the caller's transaction, before its ticket lock."""
|
||||
return DispatchResponseControl.objects.select_for_update(of=("self",)).filter(dispatch__tickets__pk=ticket_id).first()
|
||||
|
||||
|
||||
def managed_ticket_q(user):
|
||||
"""Explicitly related tickets or dispatch visibility; unit-wide scope alone is insufficient."""
|
||||
dispatches = DispatchPolicy.visible_dispatches(user)
|
||||
direct = Q(created_by=user) | Q(owner=user) | Q(selected_expert=user)
|
||||
direct |= Q(access_grants__user=user, access_grants__revoked_at__isnull=True)
|
||||
return Q(group_dispatch__response_control__isnull=True) | direct | Q(group_dispatch__in=dispatches)
|
||||
|
||||
|
||||
def populate_report_responses(dispatch, tickets, locked):
|
||||
"""Load report responses in batches; before opening, select metadata only."""
|
||||
from apps.messaging.models import Message
|
||||
responses = Message.objects.filter(ticket__group_dispatch=dispatch, author_id=F("ticket__customer_id"), deleted_at__isnull=True)
|
||||
metadata = {row["ticket_id"]: row for row in responses.values("ticket_id").annotate(
|
||||
count=Count("pk"), first=Min("created_at"), last=Max("created_at"))}
|
||||
by_ticket = {}
|
||||
if not locked:
|
||||
for message in responses.order_by("created_at", "pk").prefetch_related("attachments"):
|
||||
by_ticket.setdefault(message.ticket_id, []).append(message)
|
||||
for ticket in tickets:
|
||||
row = metadata.get(ticket.pk, {})
|
||||
ticket.response_count = row.get("count", 0)
|
||||
ticket.first_response_at = row.get("first")
|
||||
ticket.last_response_at = row.get("last")
|
||||
ticket.report_customer_responses = by_ticket.get(ticket.pk, [])
|
||||
ticket.latest_customer_response = ticket.report_customer_responses[-1] if ticket.report_customer_responses else None
|
||||
@@ -140,6 +140,8 @@ class StaffCreateTicketForm(forms.Form):
|
||||
|
||||
|
||||
class GroupDispatchForm(forms.Form):
|
||||
response_mode = forms.ChoiceField(label="نحوه مشاهده پاسخها", choices=[("SEALED", "بازگشایی یکجا"), ("INDIVIDUAL", "مشاهده جداگانه")], initial="SEALED", required=False, widget=forms.RadioSelect)
|
||||
notify_opening_sms = forms.BooleanField(label="اطلاعرسانی بازگشایی با پیامک", initial=True, required=False)
|
||||
operation_type = forms.ChoiceField(label="نوع عملیات", choices=(), required=False)
|
||||
unit = forms.ModelChoiceField(label="واحد ارسالکننده", queryset=OrganizationalUnit.objects.none())
|
||||
contact_group = forms.ModelChoiceField(label="گروه مخاطبان", queryset=ContactGroup.objects.none())
|
||||
@@ -177,6 +179,11 @@ class GroupDispatchForm(forms.Form):
|
||||
data["operation_type"] = GroupDispatch.OperationType.BULK
|
||||
unit = data.get("unit")
|
||||
group = data.get("contact_group")
|
||||
data["response_mode"] = data.get("response_mode") or "SEALED"
|
||||
if data["response_mode"] == "INDIVIDUAL":
|
||||
data["notify_opening_sms"] = False
|
||||
if group and group.contacts.filter(user__is_active=True, user__mobile__isnull=False).exclude(user__mobile="").count() > 15:
|
||||
data["notify_opening_sms"] = False
|
||||
if unit and group and group.unit_id != unit.pk:
|
||||
self.add_error("contact_group", "این گروه متعلق به واحد انتخابشده نیست.")
|
||||
category=data.get("category")
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.1.1 on 2026-10-13 09:53
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def preserve_used_response_controls(apps, schema_editor):
|
||||
from django.db.migrations.exceptions import IrreversibleError
|
||||
if apps.get_model('tickets', 'DispatchResponseControl').objects.using(schema_editor.connection.alias).exists():
|
||||
raise IrreversibleError('Response controls are in use; refusing to delete confidentiality and opening history.')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tickets', '0017_ticketaccessgrant_first_viewed_at_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DispatchResponseControl',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('mode', models.CharField(choices=[('SEALED', 'بازگشایی یکجا'), ('INDIVIDUAL', 'مشاهده جداگانه')], default='SEALED', max_length=12)),
|
||||
('notify_sms', models.BooleanField(default=True)),
|
||||
('recipient_count_snapshot', models.PositiveIntegerField()),
|
||||
('opened_at', models.DateTimeField(blank=True, null=True)),
|
||||
('sms_body_snapshot', models.TextField(blank=True)),
|
||||
('respondents_snapshot', models.JSONField(blank=True, default=list)),
|
||||
('dispatch', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='response_control', to='tickets.groupdispatch')),
|
||||
('opened_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'تنظیمات و سابقه بازگشایی ارسال',
|
||||
'verbose_name_plural': 'تنظیمات و سوابق بازگشایی ارسالها',
|
||||
'permissions': [('open_all_dispatch_responses', 'Can open responses of all authorized dispatches')],
|
||||
'constraints': [models.CheckConstraint(condition=models.Q(('notify_sms', False), models.Q(('mode', 'SEALED'), ('recipient_count_snapshot__lte', 15)), _connector='OR'), name='dispatch_opening_sms_limit'), models.CheckConstraint(condition=models.Q(('mode__in', ['SEALED', 'INDIVIDUAL'])), name='dispatch_response_mode_valid'), models.CheckConstraint(condition=models.Q(models.Q(('opened_at__isnull', True), ('opened_by__isnull', True)), models.Q(('mode', 'SEALED'), ('opened_at__isnull', False), ('opened_by__isnull', False)), _connector='OR'), name='dispatch_opening_audit_complete')],
|
||||
},
|
||||
),
|
||||
migrations.RunPython(migrations.RunPython.noop, preserve_used_response_controls),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 5.1.1 on 2026-10-13 12:22
|
||||
|
||||
import apps.tickets.models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tickets', '0018_dispatch_response_opening'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='StaffTicketShortLink',
|
||||
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)),
|
||||
('token', models.CharField(default=apps.tickets.models.staff_ticket_link_token, editable=False, max_length=10, unique=True)),
|
||||
('expires_at', models.DateTimeField(default=apps.tickets.models.staff_ticket_link_expiry)),
|
||||
('ticket', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='staff_short_link', to='tickets.ticket')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'لینک کوتاه تیکت کارشناسان',
|
||||
'verbose_name_plural': 'لینک\u200cهای کوتاه تیکت کارشناسان',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,10 +1,35 @@
|
||||
import uuid
|
||||
import secrets
|
||||
import string
|
||||
from datetime import timedelta
|
||||
from django.conf import settings
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from apps.core.models import PersianTextNormalizationMixin, PublicIdModel, TimeStampedModel
|
||||
|
||||
|
||||
def staff_ticket_link_token():
|
||||
return "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(10))
|
||||
|
||||
|
||||
def staff_ticket_link_expiry():
|
||||
return timezone.now() + timedelta(days=7)
|
||||
|
||||
|
||||
class StaffTicketShortLink(TimeStampedModel):
|
||||
"""A short navigation URL; it never authenticates or grants ticket access."""
|
||||
|
||||
ticket = models.OneToOneField("Ticket", on_delete=models.CASCADE, related_name="staff_short_link")
|
||||
token = models.CharField(max_length=10, unique=True, default=staff_ticket_link_token, editable=False)
|
||||
expires_at = models.DateTimeField(default=staff_ticket_link_expiry)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "لینک کوتاه تیکت کارشناسان"
|
||||
verbose_name_plural = "لینکهای کوتاه تیکت کارشناسان"
|
||||
|
||||
|
||||
class TicketCategory(PersianTextNormalizationMixin, TimeStampedModel):
|
||||
persian_text_fields = {"title": False, "description": True, "search_keywords": True}
|
||||
unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.PROTECT,null=True,blank=True,related_name="ticket_categories",verbose_name="واحد سازمانی",help_text="برای دستههای قدیمی عمومی میتواند خالی باشد؛ دستههای جدید باید واحد داشته باشند.")
|
||||
@@ -176,3 +201,30 @@ class MessageShare(PublicIdModel, models.Model):
|
||||
class TicketReadState(models.Model):
|
||||
ticket=models.ForeignKey(Ticket,on_delete=models.CASCADE,related_name="read_states"); user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE); last_read_at=models.DateTimeField(); last_read_message=models.ForeignKey("messaging.Message",on_delete=models.SET_NULL,null=True,blank=True)
|
||||
class Meta: constraints=[models.UniqueConstraint(fields=["ticket","user"],name="unique_ticket_read_state")]
|
||||
|
||||
|
||||
class DispatchResponseControl(models.Model):
|
||||
"""Absent for legacy dispatches: their existing visibility is preserved."""
|
||||
|
||||
class Mode(models.TextChoices):
|
||||
SEALED = "SEALED", "بازگشایی یکجا"
|
||||
INDIVIDUAL = "INDIVIDUAL", "مشاهده جداگانه"
|
||||
|
||||
dispatch = models.OneToOneField(GroupDispatch, on_delete=models.PROTECT, related_name="response_control")
|
||||
mode = models.CharField(max_length=12, choices=Mode.choices, default=Mode.SEALED)
|
||||
notify_sms = models.BooleanField(default=True)
|
||||
recipient_count_snapshot = models.PositiveIntegerField()
|
||||
opened_at = models.DateTimeField(null=True, blank=True)
|
||||
opened_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True)
|
||||
sms_body_snapshot = models.TextField(blank=True)
|
||||
respondents_snapshot = models.JSONField(default=list, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "تنظیمات و سابقه بازگشایی ارسال"
|
||||
verbose_name_plural = "تنظیمات و سوابق بازگشایی ارسالها"
|
||||
permissions = [("open_all_dispatch_responses", "Can open responses of all authorized dispatches")]
|
||||
constraints = [
|
||||
models.CheckConstraint(condition=Q(notify_sms=False) | Q(mode="SEALED", recipient_count_snapshot__lte=15), name="dispatch_opening_sms_limit"),
|
||||
models.CheckConstraint(condition=Q(mode__in=["SEALED", "INDIVIDUAL"]), name="dispatch_response_mode_valid"),
|
||||
models.CheckConstraint(condition=Q(opened_at__isnull=True, opened_by__isnull=True) | Q(mode="SEALED", opened_at__isnull=False, opened_by__isnull=False), name="dispatch_opening_audit_complete"),
|
||||
]
|
||||
@@ -70,6 +70,20 @@ class TicketPolicy:
|
||||
}
|
||||
@classmethod
|
||||
def can_view(cls,user,ticket):
|
||||
from .models import DispatchResponseControl
|
||||
if ticket.group_dispatch_id and DispatchResponseControl.objects.filter(dispatch_id=ticket.group_dispatch_id).exists():
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
if cls.is_customer(user):
|
||||
return ticket.customer_id == user.pk
|
||||
if DispatchPolicy.can_view(user, ticket.group_dispatch):
|
||||
return True
|
||||
return bool(ticket.created_by_id == user.pk or ticket.owner_id == user.pk or ticket.selected_expert_id == user.pk
|
||||
or ticket.access_grants.filter(user=user, revoked_at__isnull=True).exists())
|
||||
return cls._legacy_can_view(user, ticket)
|
||||
|
||||
@classmethod
|
||||
def _legacy_can_view(cls,user,ticket):
|
||||
if not user.is_authenticated: return False
|
||||
if cls.is_customer(user): return ticket.customer_id==user.id
|
||||
if user.is_superuser or user.has_perm("tickets.view_all_tickets"): return True
|
||||
@@ -83,6 +97,11 @@ class TicketPolicy:
|
||||
return False
|
||||
@classmethod
|
||||
def viewable_q(cls, user):
|
||||
from .dispatch_responses import managed_ticket_q
|
||||
return (cls._legacy_viewable_q(user) | Q(group_dispatch__in=DispatchPolicy.visible_dispatches(user))) & managed_ticket_q(user)
|
||||
|
||||
@classmethod
|
||||
def _legacy_viewable_q(cls, user):
|
||||
"""Database equivalent of can_view; use it for every staff ticket collection."""
|
||||
if user.is_superuser or user.has_perm("tickets.view_all_tickets"):
|
||||
return Q()
|
||||
@@ -169,5 +188,32 @@ class DispatchPolicy:
|
||||
@classmethod
|
||||
def can_view(cls, user, dispatch):
|
||||
if dispatch.created_by_id == user.id:
|
||||
from .models import DispatchResponseControl
|
||||
if DispatchResponseControl.objects.filter(dispatch_id=dispatch.pk).exists():
|
||||
return bool(user.is_authenticated and user.is_active and getattr(user, "account_type", None) == "STAFF")
|
||||
return cls.can_create(user, dispatch.unit, dispatch.operation_type)
|
||||
return cls.can_view_all(user, dispatch.operation_type, dispatch.unit)
|
||||
|
||||
@classmethod
|
||||
def visible_dispatches(cls, user):
|
||||
from .models import GroupDispatch
|
||||
if user.is_superuser:
|
||||
return GroupDispatch.objects.all()
|
||||
visibility = Q(created_by=user, response_control__isnull=False)
|
||||
memberships = list(user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit"))
|
||||
for operation_type in GroupDispatch.OperationType.values:
|
||||
if cls.can_view_all(user, operation_type):
|
||||
visibility |= Q(operation_type=operation_type)
|
||||
continue
|
||||
view_units = [item.unit_id for item in memberships if cls.can_view_all(user, operation_type, item.unit)]
|
||||
create_units = [item.unit_id for item in memberships if cls.can_create(user, item.unit, operation_type)]
|
||||
visibility |= Q(operation_type=operation_type, unit_id__in=view_units)
|
||||
visibility |= Q(operation_type=operation_type, unit_id__in=create_units, created_by=user)
|
||||
return GroupDispatch.objects.filter(visibility)
|
||||
|
||||
@classmethod
|
||||
def can_list(cls, user):
|
||||
if any(cls.can_view_all(user, kind) for kind in cls.VIEW_ALL_PERMISSIONS):
|
||||
return True
|
||||
units = user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit")
|
||||
return any(cls.can_view_all(user, kind, item.unit) for item in units for kind in cls.VIEW_ALL_PERMISSIONS)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Even superusers use the response-content gate in the application admin."""
|
||||
from django.contrib import admin
|
||||
from django.db.models import Q
|
||||
|
||||
from .dispatch_responses import sealed_message_q
|
||||
from .models import Ticket
|
||||
from .policies import TicketPolicy
|
||||
|
||||
|
||||
class ResponseContentAdmin(admin.ModelAdmin):
|
||||
message_prefix = ""
|
||||
|
||||
def get_queryset(self, request):
|
||||
query = super().get_queryset(request).exclude(sealed_message_q(self.message_prefix))
|
||||
ticket_path = self.message_prefix + "ticket"
|
||||
allowed = Ticket.objects.filter(TicketPolicy.viewable_q(request.user)).values("pk")
|
||||
return query.filter(Q(**{ticket_path + "__group_dispatch__response_control__isnull": True})
|
||||
| Q(**{ticket_path + "__in": allowed})).distinct()
|
||||
|
||||
|
||||
class RelatedResponseContentAdmin(ResponseContentAdmin):
|
||||
message_prefix = "message__"
|
||||
@@ -63,6 +63,7 @@ def dispatch_group_tickets(*, user, unit, contact_group, category, subject, body
|
||||
)
|
||||
if not contacts:
|
||||
raise ValidationError("گروه انتخابشده مخاطب فعال دارای شماره همراه ندارد.")
|
||||
from .models import DispatchResponseControl
|
||||
dispatch = GroupDispatch.objects.create(
|
||||
unit=unit,
|
||||
operation_type=operation_type,
|
||||
@@ -74,6 +75,9 @@ def dispatch_group_tickets(*, user, unit, contact_group, category, subject, body
|
||||
status=GroupDispatch.Status.QUEUED,
|
||||
recipient_count=len(contacts),
|
||||
)
|
||||
DispatchResponseControl.objects.create(dispatch=dispatch, mode="SEALED",
|
||||
notify_sms=len(contacts) <= 15,
|
||||
recipient_count_snapshot=len(contacts))
|
||||
tickets=[]
|
||||
for contact in contacts:
|
||||
for uploaded in files:
|
||||
@@ -97,6 +101,20 @@ def dispatch_group_tickets(*, user, unit, contact_group, category, subject, body
|
||||
return dispatch, tickets
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_group_dispatch(*, response_mode="SEALED", notify_opening_sms=True, **dispatch_fields):
|
||||
"""Configure responses without changing the existing creation API."""
|
||||
from .models import DispatchResponseControl
|
||||
if response_mode not in DispatchResponseControl.Mode.values:
|
||||
raise ValidationError("روش مشاهده پاسخها معتبر نیست.")
|
||||
dispatch, tickets = dispatch_group_tickets(**dispatch_fields)
|
||||
control = DispatchResponseControl.objects.get(dispatch=dispatch)
|
||||
control.mode = response_mode
|
||||
control.notify_sms = bool(notify_opening_sms and response_mode == "SEALED" and control.recipient_count_snapshot <= 15)
|
||||
control.save(update_fields=["mode", "notify_sms"])
|
||||
return dispatch, tickets
|
||||
|
||||
|
||||
def refresh_group_dispatch_status(dispatch_id):
|
||||
"""Persist delivery state independently of whether a report page is opened."""
|
||||
dispatch=GroupDispatch.objects.filter(pk=dispatch_id).first()
|
||||
@@ -194,6 +212,8 @@ def revoke_access(ticket,user,target):
|
||||
|
||||
@transaction.atomic
|
||||
def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_users=()):
|
||||
from .dispatch_responses import lock_dispatch_for_ticket
|
||||
control = lock_dispatch_for_ticket(ticket_id)
|
||||
ticket=Ticket.objects.select_for_update().get(pk=ticket_id)
|
||||
if not TicketPolicy.can_reply(user,ticket): raise PermissionDenied
|
||||
if internal and (TicketPolicy.is_customer(user) or not user.has_perm("messaging.add_internal_note")): raise PermissionDenied
|
||||
@@ -204,7 +224,8 @@ def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_user
|
||||
if not TicketPolicy.can_view(target,ticket):
|
||||
if TicketPolicy.can_share(user,ticket): share_ticket(ticket,user,target)
|
||||
else: raise PermissionDenied("کارشناس Mentionشده به تیکت دسترسی ندارد.")
|
||||
MessageMention.objects.get_or_create(message=msg,mentioned_user=target); enqueue(NotificationJob.Channel.BALE,"MENTION",ticket,f"message:{msg.pk}:mention:{target.pk}",staff_user=target,payload={"excerpt":body[:150]})
|
||||
excerpt = "پاسخ محرمانه ثبت شد." if control and control.mode == "SEALED" and not control.opened_at and user.pk == ticket.customer_id else body[:150]
|
||||
MessageMention.objects.get_or_create(message=msg,mentioned_user=target); enqueue(NotificationJob.Channel.BALE,"MENTION",ticket,f"message:{msg.pk}:mention:{target.pk}",staff_user=target,payload={"excerpt":excerpt})
|
||||
now=timezone.now(); ticket.last_activity_at=now
|
||||
if not internal:
|
||||
if TicketPolicy.is_customer(user):
|
||||
|
||||
@@ -66,6 +66,9 @@ def share_staff_ticket(ticket, actor, target, level, note=""):
|
||||
|
||||
@transaction.atomic
|
||||
def share_staff_message(message, actor, target, note=""):
|
||||
from .dispatch_responses import can_read_response
|
||||
if not can_read_response(actor, message):
|
||||
raise PermissionDenied("پاسخ تا بازگشایی قابل اشتراک نیست.")
|
||||
ticket = Ticket.objects.select_for_update().get(pk=message.ticket_id)
|
||||
require_share_authority(actor, ticket)
|
||||
validate_recipient(actor, target)
|
||||
@@ -147,11 +150,12 @@ def staff_message_context(ticket, actor, conversation=()):
|
||||
|
||||
|
||||
def sharing_context(ticket, actor):
|
||||
from .dispatch_responses import sealed_message_q
|
||||
if not TicketPolicy.can_share(actor, ticket):
|
||||
return {}
|
||||
return {
|
||||
"grants": ticket.access_grants.select_related("user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile").order_by("-created_at", "-pk"),
|
||||
"message_shares": MessageShare.objects.filter(message__ticket=ticket).select_related("message", "user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile"),
|
||||
"message_shares": MessageShare.objects.filter(message__ticket=ticket).exclude(sealed_message_q("message__")).select_related("message", "user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile"),
|
||||
"sharing_events": sharing_events(ticket),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
"""Routes available only to staff, including isolated message attachment delivery."""
|
||||
import mimetypes
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.http import FileResponse, JsonResponse
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
|
||||
from apps.attachments.delivery import signed_media_redirect
|
||||
from apps.attachments.delivery import attachment_response
|
||||
from apps.attachments.models import Attachment
|
||||
from .models import MessageShare, Ticket, TicketReadState
|
||||
from .policies import TicketPolicy
|
||||
@@ -22,8 +20,11 @@ from .views import staff_required, mark_messages_read
|
||||
|
||||
|
||||
def received_message_share(request, share_id):
|
||||
from .dispatch_responses import can_read_response
|
||||
grant = get_object_or_404(MessageShare.objects.select_related("message__author__staff_profile", "granted_by__staff_profile"), public_id=share_id)
|
||||
profile = getattr(request.user, "staff_profile", None)
|
||||
if not can_read_response(request.user, grant.message):
|
||||
raise PermissionDenied
|
||||
if (grant.user_id != request.user.pk or grant.revoked_at or grant.message.deleted_at
|
||||
or not request.user.is_active or not profile or not profile.is_active):
|
||||
raise PermissionDenied
|
||||
@@ -72,6 +73,7 @@ def shared_message(request, share_id):
|
||||
@never_cache
|
||||
@transaction.atomic
|
||||
def read_shared_messages(request, public_id):
|
||||
from .dispatch_responses import can_record_ticket_read
|
||||
ticket = get_object_or_404(Ticket.objects.select_for_update(), public_id=public_id)
|
||||
allowed = visible_messages(ticket, request.user)
|
||||
try:
|
||||
@@ -86,7 +88,7 @@ def read_shared_messages(request, public_id):
|
||||
if grant.first_viewed_at is None:
|
||||
mark_share_opened(grant, request.user)
|
||||
latest = allowed.filter(deleted_at__isnull=True).order_by('-created_at', '-pk').first()
|
||||
if latest and latest.public_id in accepted and TicketPolicy.can_view(request.user, ticket):
|
||||
if latest and latest.public_id in accepted and can_record_ticket_read(ticket, request.user):
|
||||
TicketReadState.objects.update_or_create(ticket=ticket, user=request.user,
|
||||
defaults={'last_read_at': timezone.now(), 'last_read_message': latest})
|
||||
return JsonResponse({"unread_shares": unread_shares(request.user), "read_ids": [str(value) for value in accepted]})
|
||||
@@ -105,9 +107,4 @@ def unread_count(request):
|
||||
def shared_attachment(request, share_id, attachment_id, download=False):
|
||||
grant = received_message_share(request, share_id)
|
||||
attachment = get_object_or_404(Attachment, public_id=attachment_id, message_id=grant.message_id)
|
||||
if settings.USE_NGINX_MEDIA:
|
||||
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
|
||||
return attachment_response(attachment, download=download)
|
||||
@@ -0,0 +1,38 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.decorators.http import require_GET
|
||||
|
||||
from apps.accounts.models import User
|
||||
from .models import StaffTicketShortLink
|
||||
from .policies import TicketPolicy
|
||||
|
||||
|
||||
def append_supervisor_ticket_link(ticket, message):
|
||||
unit = ticket.owning_unit
|
||||
if not unit or not unit.supervisor_sms_ticket_link_enabled:
|
||||
return message
|
||||
link, _ = StaffTicketShortLink.objects.get_or_create(ticket=ticket)
|
||||
if link.expires_at <= timezone.now():
|
||||
return message
|
||||
url = settings.PUBLIC_BASE_URL.rstrip("/") + reverse("staff_ticket_short_link", args=[link.token])
|
||||
return f"{message}\nمشاهده و پاسخ: {url}"
|
||||
|
||||
|
||||
@never_cache
|
||||
@require_GET
|
||||
@login_required(login_url="/staff/login/")
|
||||
def open_staff_ticket_link(request, token):
|
||||
if not request.user.is_active or request.user.account_type != User.AccountType.STAFF:
|
||||
raise PermissionDenied
|
||||
link = get_object_or_404(
|
||||
StaffTicketShortLink.objects.select_related("ticket"),
|
||||
token=token, expires_at__gt=timezone.now(),
|
||||
)
|
||||
if not TicketPolicy.can_view(request.user, link.ticket):
|
||||
raise PermissionDenied
|
||||
return redirect("staff_tickets:detail", public_id=link.ticket.public_id)
|
||||
@@ -12,7 +12,9 @@ from .sharing import staff_message_context
|
||||
|
||||
|
||||
def received_messages(user, ticket=None):
|
||||
from .dispatch_responses import sealed_message_q
|
||||
grants = MessageShare.objects.filter(user=user, revoked_at__isnull=True, message__deleted_at__isnull=True)
|
||||
grants = grants.exclude(sealed_message_q("message__"))
|
||||
return grants.filter(message__ticket=ticket) if ticket is not None else grants
|
||||
|
||||
|
||||
@@ -28,7 +30,8 @@ def visible_messages(ticket, user):
|
||||
profile = getattr(user, "staff_profile", None)
|
||||
if not user.is_active or (profile is not None and not profile.is_active):
|
||||
raise PermissionDenied
|
||||
query = ticket.ticket_messages.all()
|
||||
from .dispatch_responses import filter_response_messages
|
||||
query = filter_response_messages(ticket.ticket_messages.all(), user)
|
||||
if TicketPolicy.can_view(user, ticket):
|
||||
return query
|
||||
grants = received_messages(user, ticket)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from django.urls import path
|
||||
from . import views, sharing_views
|
||||
from . import views, sharing_views, dispatch_response_views
|
||||
app_name="staff_tickets"
|
||||
urlpatterns=[path("",views.staff_list,name="list"),path("customers/",views.staff_customers,name="customers"),path("customers/search/",views.staff_customer_search,name="customer_search"),path("contacts/",views.staff_contacts,name="contacts"),path("contacts/new/",views.staff_contact_create,name="contact_create"),path("contacts/<uuid:public_id>/edit/",views.staff_contact_edit,name="contact_edit"),path("contacts/<uuid:public_id>/call/",views.staff_contact_call,name="contact_call"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups/<int:group_id>/",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links/<int:link_id>/revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates/<int:template_id>/",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches/<uuid:public_id>/",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches/<uuid:public_id>/retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("dispatches/<uuid:public_id>/resolve/",views.staff_dispatch_resolve,name="dispatch_resolve"),path("experts/<uuid:public_id>/avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets/<uuid:public_id>/messages/older/",views.staff_messages,name="messages"),path("tickets/<uuid:public_id>/messages/poll/",views.staff_poll,name="poll"),path("tickets/<uuid:public_id>/sms/<int:job_id>/retry/",views.staff_sms_retry,name="sms_retry"),path("tickets/<uuid:public_id>/announcement/now/",views.staff_announcement_now,name="announcement_now"),path("tickets/<uuid:public_id>/",views.staff_detail,name="detail"),path("tickets/<uuid:public_id>/claim/",views.staff_claim,name="claim"),path("tickets/<uuid:public_id>/reply/",views.staff_reply,name="reply"),path("tickets/<uuid:public_id>/note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets/<uuid:public_id>/transfer/",views.staff_transfer,name="transfer"),path("tickets/<uuid:public_id>/share/",views.staff_share,name="share"),path("tickets/<uuid:public_id>/revoke/<uuid:user_id>/",views.staff_revoke,name="revoke"),path("tickets/<uuid:public_id>/status/",views.staff_status,name="status"),path("tickets/<uuid:public_id>/priority/",views.staff_priority,name="priority"),path("tickets/<uuid:public_id>/delete/",views.staff_delete,name="delete")]
|
||||
|
||||
urlpatterns += [
|
||||
path("dispatches/<uuid:public_id>/open-responses/", dispatch_response_views.open_responses, name="dispatch_open_responses"),
|
||||
path("shares/count/", sharing_views.unread_count, name="shares_count"),
|
||||
path("tickets/<uuid:public_id>/shares/read/", sharing_views.read_shared_messages, name="read_shares"),
|
||||
path("shares/", sharing_views.inbox, name="shares"),
|
||||
|
||||
+37
-33
@@ -38,7 +38,7 @@ from .sharing import mark_ticket_share_opened, require_share_authority, revoke_s
|
||||
from .policies import DispatchPolicy, TicketPolicy
|
||||
from .staff_conversation import limited_poll, message_context, shared_ticket_rows, visible_messages
|
||||
from .selectors import customer_tickets,dashboard_counts,filter_staff_tickets,staff_ticket_rows
|
||||
from .services import change_priority,change_status,claim_ticket,create_ticket,dispatch_group_tickets,refresh_group_dispatch_status,reply_ticket,resolve_group_dispatch_tickets,transfer_ticket
|
||||
from .services import change_priority,change_status,claim_ticket,create_ticket,create_group_dispatch,refresh_group_dispatch_status,reply_ticket,resolve_group_dispatch_tickets,transfer_ticket
|
||||
MESSAGE_PAGE_SIZE=50
|
||||
SMS_RETRYABLE_DELIVERY_STATUSES=(
|
||||
NotificationJob.DeliveryStatus.UNDELIVERED,
|
||||
@@ -122,11 +122,14 @@ def conversation_version(ticket,latest,include_internal=False):
|
||||
query=ticket.ticket_messages.all()
|
||||
if not include_internal: query=query.filter(is_internal=False)
|
||||
edits=query.aggregate(edited=Max('edited_at'),deleted=Max('deleted_at'))
|
||||
revision=hashlib.sha256(repr(edits).encode()).hexdigest()[:20]
|
||||
from .dispatch_responses import response_control
|
||||
control = response_control(ticket.group_dispatch) if ticket.group_dispatch_id else None
|
||||
revision=hashlib.sha256(repr((edits, control.opened_at if control else None)).encode()).hexdigest()[:20]
|
||||
source=f"{ticket.updated_at.isoformat()}:{latest.public_id if latest else ''}:{latest.updated_at.isoformat() if latest else ''}"
|
||||
return hashlib.sha256(source.encode()).hexdigest()[:20]+':'+revision
|
||||
def conversation_poll_response(request,ticket,actor,include_internal=False):
|
||||
qs=ticket.ticket_messages.all()
|
||||
from .dispatch_responses import filter_response_messages
|
||||
qs=filter_response_messages(ticket.ticket_messages.all(), actor)
|
||||
if not include_internal: qs=qs.filter(is_internal=False)
|
||||
latest=qs.order_by("-created_at","-id").only("id","public_id","updated_at").first()
|
||||
version=conversation_version(ticket,latest,include_internal=include_internal)
|
||||
@@ -458,7 +461,7 @@ def staff_detail(request,public_id):
|
||||
conversation,has_older,older_cursor=conversation_page(visible.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"))
|
||||
mark_ticket_share_opened(ticket,request.user)
|
||||
message_sharing=message_context(ticket,request.user,conversation)
|
||||
history=Ticket.objects.filter(customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at")[:5]
|
||||
history=Ticket.objects.filter(TicketPolicy.viewable_q(request.user), customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at").distinct()[:5]
|
||||
sms_jobs=list(ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at"))
|
||||
announcement_jobs=list(AnnouncementJob.objects.filter(Q(message_id__in=visible.values("pk")) | Q(ticket=ticket)).select_related("message").order_by("-created_at", "-pk"))
|
||||
can_retry_sms=TicketPolicy.can_reply(request.user,ticket)
|
||||
@@ -469,6 +472,7 @@ def staff_detail(request,public_id):
|
||||
sms_job.sms_retryable=can_retry_sms and _sms_job_is_retryable(sms_job)
|
||||
latest=conversation[-1] if conversation else None
|
||||
group_dispatch = ticket.group_dispatch
|
||||
message_sharing["can_view_dispatch"] = bool(group_dispatch and DispatchPolicy.can_view(request.user, group_dispatch))
|
||||
can_resolve_dispatch = bool(group_dispatch and DispatchPolicy.can_view(request.user, group_dispatch) and (request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, group_dispatch.unit, "tickets.change_ticket")))
|
||||
group_open_count = group_dispatch.tickets.exclude(status__in=(Ticket.Status.RESOLVED, Ticket.Status.CLOSED)).count() if group_dispatch else 0
|
||||
customer_notices = payment_selectors.notices_with_totals(
|
||||
@@ -613,10 +617,19 @@ def staff_delete(request, public_id):
|
||||
@staff_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def staff_dispatch_create(request):
|
||||
from .dispatch_presentation import dispatch_form_options
|
||||
|
||||
create_unit_ids = {
|
||||
unit.pk for unit in OrganizationalUnit.objects.filter(pk__in=TicketPolicy.unit_ids(request.user), is_active=True)
|
||||
if any(DispatchPolicy.can_create(request.user, unit, kind) for kind in GroupDispatch.OperationType.values)
|
||||
}
|
||||
base_dispatches = _visible_dispatches(request.user)
|
||||
if not create_unit_ids and (request.method == "POST" or not (DispatchPolicy.can_list(request.user) or base_dispatches.exists())):
|
||||
raise PermissionDenied
|
||||
form = GroupDispatchForm(request.POST or None, request.FILES or None, user=request.user)
|
||||
if request.method == "POST" and form.is_valid():
|
||||
try:
|
||||
dispatch, _ = dispatch_group_tickets(
|
||||
dispatch, _ = create_group_dispatch(
|
||||
user=request.user,
|
||||
unit=form.cleaned_data["unit"],
|
||||
contact_group=form.cleaned_data["contact_group"],
|
||||
@@ -624,6 +637,8 @@ def staff_dispatch_create(request):
|
||||
subject=form.cleaned_data["subject"],
|
||||
body=form.cleaned_data["body"],
|
||||
operation_type=form.cleaned_data["operation_type"],
|
||||
response_mode=form.cleaned_data["response_mode"],
|
||||
notify_opening_sms=form.cleaned_data["notify_opening_sms"],
|
||||
tags=form.cleaned_data["tags"],
|
||||
files=request.FILES.getlist("files"),
|
||||
)
|
||||
@@ -631,13 +646,6 @@ def staff_dispatch_create(request):
|
||||
return redirect("staff_tickets:dispatch_detail", public_id=dispatch.public_id)
|
||||
except (PermissionDenied, ValidationError) as exc:
|
||||
form.add_error(None, str(exc))
|
||||
create_unit_ids = {
|
||||
unit.pk for unit in OrganizationalUnit.objects.filter(pk__in=TicketPolicy.unit_ids(request.user), is_active=True)
|
||||
if any(DispatchPolicy.can_create(request.user, unit, kind) for kind in GroupDispatch.OperationType.values)
|
||||
}
|
||||
if not create_unit_ids:
|
||||
raise PermissionDenied
|
||||
base_dispatches = _visible_dispatches(request.user)
|
||||
metrics_row = base_dispatches.aggregate(total=Count("id"), recipients=Sum("recipient_count"))
|
||||
metrics = {
|
||||
"total": metrics_row["total"] or 0,
|
||||
@@ -672,19 +680,27 @@ def staff_dispatch_create(request):
|
||||
return render(request, "tickets/staff_dispatch_create.html", {
|
||||
"form": form, "dispatches": page.object_list, "page": page, "metrics": metrics,
|
||||
"units": units, "groups": groups, "dispatch_statuses": GroupDispatch.Status.choices,
|
||||
"dispatch_options": dispatch_form_options(request.user, units, groups),
|
||||
"can_create_dispatch": bool(create_unit_ids),
|
||||
"history_units": OrganizationalUnit.objects.filter(pk__in=base_dispatches.values("unit_id")),
|
||||
"history_groups": ContactGroup.objects.filter(pk__in=base_dispatches.values("contact_group_id")),
|
||||
"active_staff_section": "dispatches", "active_tab_title": "ارسال گروهی و ردیابی",
|
||||
})
|
||||
|
||||
|
||||
@staff_required
|
||||
def staff_dispatch_detail(request, public_id):
|
||||
from .dispatch_responses import response_control, can_open_responses, populate_report_responses
|
||||
dispatch = get_object_or_404(
|
||||
GroupDispatch.objects.select_related("unit", "contact_group", "created_by"),
|
||||
public_id=public_id,
|
||||
)
|
||||
if not DispatchPolicy.can_view(request.user, dispatch):
|
||||
raise PermissionDenied
|
||||
tickets = list(dispatch.tickets.select_related("customer__customer_profile").prefetch_related("magic_links", "ticket_messages", "notificationjob_set").order_by("customer_id"))
|
||||
control = response_control(dispatch)
|
||||
locked = bool(control and control.mode == "SEALED" and not control.opened_at)
|
||||
tickets = list(dispatch.tickets.select_related("customer__customer_profile").prefetch_related("magic_links", "notificationjob_set").order_by("customer_id"))
|
||||
populate_report_responses(dispatch, tickets, locked)
|
||||
delivery_failures = {
|
||||
NotificationJob.DeliveryStatus.UNDELIVERED,
|
||||
NotificationJob.DeliveryStatus.EXPIRED,
|
||||
@@ -696,7 +712,6 @@ def staff_dispatch_detail(request, public_id):
|
||||
for ticket in tickets:
|
||||
ticket.sms_job = next((job for job in ticket.notificationjob_set.all() if job.channel == NotificationJob.Channel.SMS and job.event_type == SMSTemplate.Event.STAFF_TICKET_CREATED), None)
|
||||
ticket.sms_retryable = bool(ticket.sms_job and (ticket.sms_job.status == NotificationJob.Status.FAILED or (ticket.sms_job.delivery_status in delivery_failures and not ticket.sms_job.delivery_next_check_at)))
|
||||
ticket.latest_customer_response = next((message for message in reversed(list(ticket.ticket_messages.all())) if message.author_id == ticket.customer_id), None)
|
||||
links = list(ticket.magic_links.all())
|
||||
ticket.magic_total_views = sum(link.view_count for link in links)
|
||||
ticket.magic_link_count = len(links)
|
||||
@@ -704,17 +719,17 @@ def staff_dispatch_detail(request, public_id):
|
||||
ticket.magic_last_used_at = max((link.used_at for link in links if link.used_at), default=None)
|
||||
job = ticket.sms_job
|
||||
if not job:
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ثبت نشده", "برای این مخاطب job پیامک پیدا نشد.", "neutral"
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ثبت نشده", "برای این مخاطب سابقه ارسال پیامک ثبت نشده است.", "neutral"
|
||||
elif job.status == NotificationJob.Status.FAILED:
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "ارسال ناموفق", f"ارسال خودکار پس از {1 + settings.SMS_MAX_RETRIES} تلاش متوقف شده است.", "danger"
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "ارسال ناموفق", "ارسال خودکار متوقف شده است؛ علت خطا و امکان تلاش مجدد را در ادامه بررسی کنید.", "danger"
|
||||
elif job.status == NotificationJob.Status.RETRYING:
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در انتظار تلاش مجدد", "worker پیامک را در موعد بعدی دوباره ارسال میکند.", "warning"
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در انتظار تلاش مجدد", "سامانه پیامک را در موعد بعدی دوباره ارسال میکند.", "warning"
|
||||
elif job.status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING):
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ارسال", "پیامک هنوز به درگاه تحویل نشده است.", "info"
|
||||
elif job.delivery_status == NotificationJob.DeliveryStatus.DELIVERED:
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "تحویل به گوشی تأیید شد", "اپراتور دریافت پیامک توسط مخاطب را تأیید کرده است.", "success"
|
||||
elif job.delivery_status in (NotificationJob.DeliveryStatus.PENDING, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.CHECK_FAILED) and job.delivery_next_check_at:
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در حال پیگیری تحویل", "نتیجه فعلی قطعی نیست و worker استعلام را ادامه میدهد.", "warning"
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در حال پیگیری تحویل", "نتیجه فعلی قطعی نیست؛ پیگیری خودکار تحویل ادامه دارد.", "warning"
|
||||
elif job.delivery_status == NotificationJob.DeliveryStatus.BLOCKED:
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "دریافت پیامک مسدود است", "شماره مخاطب دریافت پیامک تبلیغاتی را بسته است.", "danger"
|
||||
elif job.delivery_status == NotificationJob.DeliveryStatus.EXPIRED:
|
||||
@@ -732,14 +747,15 @@ def staff_dispatch_detail(request, public_id):
|
||||
tracking = sms_jobs.filter(delivery_next_check_at__isnull=False).count()
|
||||
opened = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch, view_count__gt=0).values("ticket_id").distinct().count()
|
||||
total_views = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch).aggregate(total=Sum("view_count"))["total"] or 0
|
||||
replied = dispatch.tickets.filter(last_customer_message_at__isnull=False).count()
|
||||
replied = sum(bool(ticket.response_count) for ticket in tickets)
|
||||
refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db(fields=["status"])
|
||||
pending = sum(sms_counts.get(status, 0) for status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING, NotificationJob.Status.RETRYING))
|
||||
delivery_percent = round((delivered / dispatch.recipient_count) * 100) if dispatch.recipient_count else 0
|
||||
metrics = {"sent": sent, "delivered": delivered, "failed": failed, "retrying": retrying, "tracking": tracking, "pending": pending, "opened": opened, "not_opened": max(dispatch.recipient_count-opened,0), "total_views": total_views, "replied": replied, "delivery_percent": delivery_percent}
|
||||
open_ticket_count = sum(ticket.status not in (Ticket.Status.RESOLVED, Ticket.Status.CLOSED) for ticket in tickets)
|
||||
can_resolve_dispatch = request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, dispatch.unit, "tickets.change_ticket")
|
||||
return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "open_ticket_count": open_ticket_count, "can_resolve_dispatch": can_resolve_dispatch, "sms_max_attempts": 1 + settings.SMS_MAX_RETRIES, "active_staff_section": "dispatches"})
|
||||
opening_jobs = NotificationJob.objects.filter(ticket__group_dispatch=dispatch, event_type="DISPATCH_RESPONSES_OPENED").values("status").annotate(count=Count("pk"))
|
||||
return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "open_ticket_count": open_ticket_count, "can_resolve_dispatch": can_resolve_dispatch, "sms_max_attempts": 1 + settings.SMS_MAX_RETRIES, "active_staff_section": "dispatches", "response_control": control, "responses_locked": locked, "can_open_responses": can_open_responses(request.user, dispatch), "opening_jobs": list(opening_jobs)})
|
||||
|
||||
|
||||
@staff_required
|
||||
@@ -782,19 +798,7 @@ def _unit_ids_with_permission(user, permission):
|
||||
|
||||
|
||||
def _visible_dispatches(user):
|
||||
if user.is_superuser:
|
||||
return GroupDispatch.objects.all()
|
||||
visibility = Q(pk__in=[])
|
||||
memberships = list(user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit"))
|
||||
for operation_type in GroupDispatch.OperationType.values:
|
||||
if DispatchPolicy.can_view_all(user, operation_type):
|
||||
visibility |= Q(operation_type=operation_type)
|
||||
continue
|
||||
view_all_units = [item.unit_id for item in memberships if DispatchPolicy.can_view_all(user, operation_type, item.unit)]
|
||||
create_units = [item.unit_id for item in memberships if DispatchPolicy.can_create(user, item.unit, operation_type)]
|
||||
visibility |= Q(operation_type=operation_type, unit_id__in=view_all_units)
|
||||
visibility |= Q(operation_type=operation_type, unit_id__in=create_units, created_by=user)
|
||||
return GroupDispatch.objects.filter(visibility)
|
||||
return DispatchPolicy.visible_dispatches(user)
|
||||
|
||||
|
||||
CONTACT_AUDIT_FIELDS = {
|
||||
|
||||
@@ -29,7 +29,8 @@ MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.locale.LocaleMiddleware", "django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware", "apps.payments.middleware.FinancialGrantAuditMiddleware", "apps.core.middleware.NoStoreHTMLMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware", "apps.accounts.customer_access.CustomerLoginAccessMiddleware",
|
||||
"apps.payments.middleware.FinancialGrantAuditMiddleware", "apps.core.middleware.NoStoreHTMLMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
ROOT_URLCONF = "config.urls"
|
||||
|
||||
+3
-1
@@ -10,6 +10,7 @@ from django.views.static import serve
|
||||
|
||||
from apps.core.models import SiteBranding
|
||||
from apps.payments import link_views as payment_link_views
|
||||
from apps.tickets.short_links import open_staff_ticket_link
|
||||
|
||||
|
||||
def healthz(request):
|
||||
@@ -49,6 +50,7 @@ def web_manifest(request):
|
||||
return response
|
||||
|
||||
urlpatterns = [
|
||||
path("vt/<str:token>", open_staff_ticket_link, name="staff_ticket_short_link"),
|
||||
path("p/<str:token>/", payment_link_views.open_link, name="payment_short_link"),
|
||||
path("healthz/", healthz, name="healthz"),
|
||||
path("media/branding/<path:path>", public_branding, name="public_branding"),
|
||||
@@ -67,7 +69,7 @@ urlpatterns = [
|
||||
|
||||
def debug_media(request, path):
|
||||
import posixpath
|
||||
if posixpath.normpath(path.replace("\\", "/")).lstrip("/").startswith("payments/"):
|
||||
if posixpath.normpath(path.replace("\\", "/")).lstrip("/").startswith(("payments/", "dispatch-responses/")):
|
||||
from django.http import Http404
|
||||
raise Http404
|
||||
return serve(request, path, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
@@ -0,0 +1,71 @@
|
||||
<!doctype html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>پیشنمایش اقدام ارسال فیش · چهارسو</title>
|
||||
<style>
|
||||
@font-face{font-family:Vazirmatn;src:url("../../static/fonts/vazirmatn-arabic-wght-normal.woff2?v=receipt-preview-1") format("woff2");font-weight:100 900;font-display:swap}
|
||||
*{box-sizing:border-box}body{margin:0;background:#eee;color:#08376b;font-family:Vazirmatn,sans-serif;font-size:14px;line-height:1.8}
|
||||
button{font:inherit;cursor:pointer}a{color:inherit}svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}
|
||||
.preview-note{padding:9px 16px;background:#fff3da;color:#805300;text-align:center;font-size:12px}
|
||||
.header{background:white;border-bottom:1px solid #dce5ed}.header-inner{max-width:1000px;margin:auto;padding:16px 24px;display:flex;align-items:center;justify-content:space-between}
|
||||
.brand{display:flex;align-items:center;gap:16px}.brand img{width:85px;height:auto}.brand strong{display:block;font-size:20px}.brand small{color:#526c86}
|
||||
.main{max-width:900px;padding:28px 24px 48px;margin:auto}.breadcrumb{font-size:12px;color:#526c86;margin-bottom:20px}
|
||||
.title{display:flex;align-items:center;gap:16px;margin-bottom:20px}.page-icon{display:grid;place-items:center;width:56px;height:56px;border-radius:18px;background:#e5f4ff;border:1px solid #cde6f7}.page-icon svg{width:28px;height:28px}h1{font-size:28px;line-height:1.5;margin:0;font-weight:900}.title p{margin:5px 0 0;color:#526c86;font-size:14px}
|
||||
.notice{background:white;border:1px solid #dce5ed;border-radius:23px;overflow:hidden}
|
||||
.notice-head{padding:20px 22px;border-bottom:1px solid #e7edf2;display:flex;justify-content:space-between;gap:12px}.notice-head strong{font-size:16px}.notice-head small{display:block;color:#526c86;font-size:12px;margin-top:4px}.amount{font-size:20px;font-weight:900;text-align:left}.amount span{font-size:12px;font-weight:400}.state{font-size:12px;color:#526c86;text-align:left}
|
||||
.details{padding:20px 22px}.bank{display:flex;justify-content:space-between;gap:8px;margin-bottom:16px}.bank span{color:#526c86}
|
||||
.values{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:0}.value{border:1px solid #dce5ed;background:#fafcfe;border-radius:12px;padding:12px 14px;display:grid;grid-template-columns:1fr auto;align-items:center;gap:4px 12px}.value dt{font-size:12px;color:#526c86}.value dd{margin:0;grid-column:1/-1;direction:ltr;text-align:right;font-size:16px;overflow-wrap:anywhere}.copy{min-height:44px;border:1px solid #dce5ed;background:white;border-radius:10px;color:#08376b;display:flex;align-items:center;gap:6px;padding:4px 10px;font-size:12px}.copy svg{width:16px;height:16px}
|
||||
.warning{padding:12px 14px;border-right:3px solid #fa9f00;background:#fff8eb;border-radius:10px;margin:16px 0 12px;line-height:1.9}
|
||||
.copy-all{background:transparent;border:0;color:#08376b;display:inline-flex;align-items:center;gap:8px;min-height:44px;padding:0;text-decoration:underline;text-underline-offset:4px}
|
||||
.next-step{display:flex;align-items:center;justify-content:space-between;gap:24px;background:#f0f8fd;border:1px solid #cde6f7;border-radius:16px;padding:20px;margin-top:20px}
|
||||
.next-step h2{font-size:18px;font-weight:900;margin:0 0 4px}.next-step p{font-size:14px;color:#526c86;margin:0;max-width:390px}
|
||||
.send{display:flex;align-items:center;justify-content:center;gap:10px;min-height:52px;padding:12px 20px;border:1px solid #08376b;border-radius:12px;background:#08376b;color:white;font-size:16px;font-weight:800;white-space:nowrap;transition:background .15s,box-shadow .15s}.send:hover{background:#0b4c8c;box-shadow:0 3px 10px #08376b1a}.send:active{background:#08376b}
|
||||
button:focus-visible{outline:3px solid #1977ce;outline-offset:3px}
|
||||
.extra{margin-top:24px;padding-top:20px;border-top:1px solid #e7edf2}.extra h3{font-size:14px;font-weight:800;margin:0 0 8px}.extra p{margin:4px 0;font-size:14px}.extra .muted{color:#526c86}
|
||||
.bottom{margin-top:20px;padding-top:16px;border-top:1px solid #e7edf2;display:flex;justify-content:space-between;align-items:center;gap:12px;color:#526c86}.bottom button{color:#08376b;background:transparent;border:0;min-height:44px;padding:0;display:flex;align-items:center;gap:8px;text-decoration:underline;text-underline-offset:4px}
|
||||
.feedback{margin-top:20px;background:white;border:1px solid #dce5ed;border-radius:12px;padding:12px 16px;color:#526c86}.feedback:empty{display:none}
|
||||
.footer{text-align:center;font-size:12px;color:#526c86;margin-top:24px}
|
||||
@media(max-width:639px){.header-inner{padding:12px 16px}.brand{gap:10px}.brand img{width:65px}.header-label{display:none}.main{padding:20px 12px 32px}.title{gap:12px;align-items:flex-start}h1{font-size:24px}.page-icon{width:48px;height:48px;flex-shrink:0}.notice-head{padding:16px;flex-direction:column;gap:8px}.amount,.state{text-align:right}.details{padding:16px}.bank{flex-direction:column;gap:0}.values{grid-template-columns:1fr}.next-step{flex-direction:column;align-items:stretch;gap:16px;padding:18px 16px}.send{width:100%}.bottom{flex-direction:column;align-items:flex-start;gap:0}.extra{margin-top:20px}}
|
||||
@media(prefers-reduced-motion:reduce){.send{transition:none}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="preview-note">پیشنمایش پیشنهادی · تمام دادهها ساختگیاند · پرداخت یا ارسال واقعی انجام نمیشود</div>
|
||||
<svg style="position:absolute;width:0;height:0;overflow:hidden" aria-hidden="true">
|
||||
<symbol id="bank" viewBox="0 0 24 24"><path d="m3 7 9-4 9 4v3H3ZM5 10v7M10 10v7M14 10v7M19 10v7M3 17h18v4H3"/></symbol>
|
||||
<symbol id="copy" viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="13" rx="2"/><path d="M16 8V3H3v13h5"/></symbol>
|
||||
<symbol id="receipt" viewBox="0 0 24 24"><path d="M5 3h14v18l-3-2-4 2-4-2-3 2ZM9 8h6M9 12h6"/></symbol>
|
||||
</svg>
|
||||
<header class="header"><div class="header-inner"><div class="brand"><img src="../../static/images/company-logo.png?v=receipt-preview-1" alt="لوله گستر گلپایگان"><div><strong>چهارسو</strong><small>مرکز خدمات مشتریان</small></div></div><span class="header-label">خدمات مشتریان</span></div></header>
|
||||
<main class="main">
|
||||
<nav class="breadcrumb" aria-label="مسیر صفحه">شماره حسابها / جزئیات واریز</nav>
|
||||
<div class="title"><span class="page-icon"><svg aria-hidden="true"><use href="#bank"/></svg></span><div><h1>جزئیات واریز</h1><p>اطلاعات حساب را بردارید؛ پس از واریز، فیش را از همین صفحه ارسال کنید.</p></div></div>
|
||||
<article class="notice">
|
||||
<header class="notice-head"><div><strong>واریز بابت سفارش لوله و اتصالات</strong><small>اعلامیهٔ نمونه ۱۰۴۲ · ۲۰ مهر ۱۴۰۵</small></div><div><div class="amount">۲۵۰٬۰۰۰٬۰۰۰ <span>ریال</span></div><div class="state">هنوز فیشی ارسال نشده است</div></div></header>
|
||||
<div class="details">
|
||||
<div class="bank"><strong>حساب نمونهٔ شرکت · بانک نمونه</strong><span>صاحب حساب: شرکت نمونه</span></div>
|
||||
<dl class="values">
|
||||
<div class="value"><dt>مبلغ (ریال)</dt><button class="copy" type="button"><svg aria-hidden="true"><use href="#copy"/></svg>کپی</button><dd>۲۵۰٬۰۰۰٬۰۰۰</dd></div>
|
||||
<div class="value"><dt>شماره حساب · ساختگی</dt><button class="copy" type="button"><svg aria-hidden="true"><use href="#copy"/></svg>کپی</button><dd>0000 0000 0000</dd></div>
|
||||
<div class="value"><dt>شبا · ساختگی</dt><button class="copy" type="button"><svg aria-hidden="true"><use href="#copy"/></svg>کپی</button><dd>IR00 0000 0000 0000 0000 0000 00</dd></div>
|
||||
<div class="value"><dt>شناسه واریز · ضروری</dt><button class="copy" type="button"><svg aria-hidden="true"><use href="#copy"/></svg>کپی</button><dd>000000000</dd></div>
|
||||
</dl>
|
||||
<p class="warning">هنگام واریز، حتماً شناسهٔ واریز را در فرم بانکی وارد کنید؛ واریز بدون این شناسه معتبر نخواهد بود.</p>
|
||||
<button class="copy-all" type="button"><svg aria-hidden="true"><use href="#copy"/></svg>کپی کامل اطلاعات واریز</button>
|
||||
<section class="next-step" aria-labelledby="next-step-title"><div><h2 id="next-step-title">پس از واریز</h2><p>تصویر فیش را برای بررسی این واریز ارسال کنید.</p></div><button class="send" type="button" id="send-preview"><svg aria-hidden="true"><use href="#receipt"/></svg>ارسال فیش این واریز</button></section>
|
||||
<section class="extra"><h3>اطلاعات تکمیلی و توضیحات</h3><p>فاکتور: نمونه ۱۲۳۴</p><p class="muted">این توضیح نمونه برای نمایش جایگاه اطلاعات تکمیلی پس از بخش ارسال فیش است.</p></section>
|
||||
<div class="bottom"><span>تأییدشده: ۰ ریال · باقیمانده: ۲۵۰٬۰۰۰٬۰۰۰ ریال</span><button type="button"><svg aria-hidden="true"><use href="#copy"/></svg>کپی لینک این صفحه</button></div>
|
||||
</div>
|
||||
</article>
|
||||
<p class="feedback" id="feedback" role="status" aria-live="polite"></p>
|
||||
<p class="footer">پیشنمایش مستقل برای بررسی چیدمان · کد اصلی برنامه تغییر نکرده است</p>
|
||||
</main>
|
||||
<script>
|
||||
document.querySelectorAll('button').forEach(button=>button.addEventListener('click',()=>{
|
||||
document.getElementById('feedback').textContent=button.id==='send-preview'?'در نسخهٔ اصلی، این دکمه فرم ارسال فیش مرتبط با همین اعلامیه را باز میکند. این پیشنمایش ارسال واقعی ندارد.':'این کنترل در پیشنمایش فقط نمایشی است و اطلاعاتی کپی نمیکند.';
|
||||
}));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -27,6 +27,11 @@ server {
|
||||
return 404;
|
||||
}
|
||||
|
||||
# These files require an authenticated application check on every request.
|
||||
location ^~ /protected-media/dispatch-responses/ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location /protected-media/ {
|
||||
alias /app/media/;
|
||||
secure_link $arg_md5,$arg_expires;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# مسدودی ورود مشتریان و مخاطبان
|
||||
|
||||
در Django Admin، پروفایل مشتری / مخاطب را با شماره موبایل جستوجو کنید.
|
||||
گزینهٔ «مسدود کردن ورود مشتری / مخاطب» را فعال کنید، «دلیل مسدودی ورود» را
|
||||
بنویسید و ذخیره کنید. ثبت مسدودی بدون دلیل در ادمین پذیرفته نمیشود.
|
||||
برای رفع مسدودی، همان گزینه را غیرفعال کنید و ذخیره کنید.
|
||||
|
||||
این تنظیم ورود با موبایل و OTP در `/login/` و ورود با رمز عبور در
|
||||
`/login/password/` را کنترل میکند. دلیل با متن قرمز، فونت برنامه و کادر
|
||||
دارای `role="alert"` نمایش داده میشود. متن دلیل HTML اجرا نمیکند.
|
||||
تنظیمات مسدودی در فرم مشتری و فرم مدیریت مخاطبان کارشناسان قابل تغییر نیست.
|
||||
|
||||
هر بار مسدود کردن، نسخهٔ دسترسی پروفایل را افزایش میدهد. نشستهای ورود
|
||||
قدیمی در نخستین درخواست بعدی خارج میشوند و حتی پس از رفع مسدودی قابل
|
||||
استفاده نیستند؛ ورود مجدد لازم است. نشستهای قبل از استقرار این قابلیت
|
||||
نسخهٔ صفر دارند و تا زمانی که حساب مسدود نشود معتبر میمانند.
|
||||
|
||||
صفحات مشتری هر ۱۰ ثانیه وضعیت نشست را از `/login/access/` بررسی میکنند.
|
||||
در مرورگر باز و متصل، مسدودی بدون کلیک کاربر نیز باعث انتقال به صفحهٔ
|
||||
ورود و نمایش دلیل میشود. هنگام بازگشت به تب، باز شدن مجدد صفحه یا اتصال
|
||||
دوبارهٔ شبکه هم بررسی انجام میشود. کنترل سمت سرور به جاوااسکریپت وابسته نیست.
|
||||
|
||||
حسابهای `STAFF`، کاربران دارای `is_staff` و ابرکاربران از این محدودیت
|
||||
مستثنا هستند. لینکهای امن با دسترسی محدود به تیکت یا پرداخت، نشست ورود
|
||||
کامل مشتری ایجاد نمیکنند و قواعد مستقل قبلی خود را حفظ میکنند.
|
||||
این قابلیت مقدار `is_active` حساب را تغییر نمیدهد.
|
||||
|
||||
Migration مورد نیاز: `customers.0007_customerprofile_login_access_version_and_more`.
|
||||
برای استقرار با پایتون محیط پروژه، `manage.py migrate` را اجرا کنید و سرویس
|
||||
Django را دوباره راهاندازی کنید.
|
||||
|
||||
تستها در `tests/test_customer_login_access.py` و
|
||||
`tests/e2e/test_customer_login_access_e2e.py` قرار دارند. تست مرورگر از
|
||||
`live_server` و Chromium مدیریتشدهٔ پروژه استفاده میکند.
|
||||
|
||||
## نتیجهٔ بررسی
|
||||
|
||||
- مجموعهٔ گستردهٔ ورود، نقشها، مخاطبان، پرداختها، لینکهای امن و انتقال
|
||||
داده: ۲۶۰ تست موفق.
|
||||
- آخرین اجرای پس از اصلاح ترتیب middlewareها: ۸۵ تست موفق، شامل
|
||||
`test_customer_login_access.py`، `test_views.py`، `test_role_workflow_regression.py`،
|
||||
تستهای مرورگر مسدودی و `test_pwa_versioning.py`.
|
||||
- کنترلهای Django، نبود migration جاافتاده، بررسی نحو Python و JavaScript
|
||||
و `git diff --check` برای فایلهای این قابلیت موفق بودند.
|
||||
- صفحهٔ ورود، CSS قرمز نسخهدار و endpoint بدون کش نشست از سرور توسعه
|
||||
روی `0.0.0.0:8000` با پاسخ HTTP 200 بررسی شدند.
|
||||
|
||||
Graphify برای ارتباط مفهومی پروفایل و دسترسیها و Ripwire برای بررسی
|
||||
نمادها، اثر تغییر، محل استفاده و گیتهای پس از تغییر اجرا شدند.
|
||||
`test-gate` فهرست تستهای لازم را ارائه میکند و خود تستها را اجرا نمیکند؛
|
||||
تستهای معرفیشده جداگانه اجرا و موفق شدند. `quality-delta` سبز نشد:
|
||||
هشدارهایی دربارهٔ callbackهای Django، تستهای کشفشده توسط pytest و شباهت
|
||||
ساختاری توابع بازیابی رکورد باقی مانده است. برای نمونه، بازیابی
|
||||
`CustomerProfile` با بازیابی `DispatchResponseControl` شبیه تشخیص داده شد؛
|
||||
این دو به مدلها و قواعد مستقل مربوطاند و ادغام آنها مناسب نیست.
|
||||
این هشدارهای تحلیل ایستا به معنی شکست تستهای اجرایی نیستند و بهعنوان
|
||||
نتیجهٔ سبز گیت کیفیت گزارش نشدهاند.
|
||||
@@ -0,0 +1,69 @@
|
||||
# بازگشایی پاسخهای ارسال گروهی
|
||||
|
||||
ارسالهای جدید پیشفرض بازگشایی یکجا دارند. ارسالهای قدیمی بدون رکورد
|
||||
`DispatchResponseControl` رفتار قبلی را حفظ میکنند و پیامک بازگشایی ندارند.
|
||||
در حالت یکجا، کارکنان و superuser تا بازگشایی متن، پیوست و تاریخچهٔ پاسخ
|
||||
مخاطب را نمیبینند. مخاطب به پاسخ خودش دسترسی دارد.
|
||||
|
||||
## مجوزها در Django Admin
|
||||
|
||||
- مشاهدهٔ همه ارسالهای گروهی: `tickets.view_all_bulk_dispatches`
|
||||
- مشاهدهٔ همه استعلامها: `tickets.view_all_inquiries`
|
||||
- بازگشایی همه ارسالهای قابل مشاهده: `tickets.open_all_dispatch_responses`
|
||||
|
||||
مجوز بازگشایی بهتنهایی مجوز مشاهده ایجاد نمیکند. ایجادکنندهٔ ارسال خودش
|
||||
میتواند بازگشایی کند. حساب باید فعال باشد؛ کاربران پنل staff با پروفایل
|
||||
غیرفعال مجاز نیستند. عنوان شغلی یا نقش جدیدی بهصورت خودکار مجوز نمیگیرد.
|
||||
|
||||
## پیامک
|
||||
|
||||
رویداد `DISPATCH_RESPONSES_OPENED` در SMSTemplate قابل ویرایش است؛ قالب
|
||||
اختصاصی واحد بر قالب عمومی اولویت دارد. این رویداد تگ جایگزین ندارد.
|
||||
متن دقیق هنگام تأیید ثبت میشود و تغییر بعدی قالب بر پیامکهای صفشده اثر
|
||||
نمیگذارد. تغییر قالب میان پیشنمایش و تأیید، نیاز به بررسی دوباره دارد.
|
||||
|
||||
فقط پاسخدهندگان با پاسخ حذفنشده در لحظهٔ بازگشایی در صف پیامک قرار
|
||||
میگیرند. پاسخ صرفاً دارای پیوست نیز معتبر است. سقف ۱۵ بر اساس گیرندگان
|
||||
واجد شرایط هنگام ایجاد است و با تغییر بعدی گروه عوض نمیشود. حالت
|
||||
جداگانه و ارسال بیش از ۱۵ گیرنده پیامک بازگشایی ندارند.
|
||||
|
||||
بازگشایی یکبار است. پاسخ و ویرایش بعدی فوراً برای کاربران مجاز قابل مشاهده
|
||||
است و پیامک بازگشایی دیگری ایجاد نمیکند. بازگشایی با رسیدِ خواندن پیام
|
||||
تفاوت دارد. صف و تحویل پیامک نیز جدا از بازگشایی گزارش میشوند.
|
||||
|
||||
اعلان پاسخ ارسالهای جدید در بله، با اطلاعات وضعیت و لینک پنل، به مقصد
|
||||
خصوصی بلهٔ ایجادکننده ارسال میشود؛ تنظیم مقصد و فعال بودن اعلان در پروفایل
|
||||
کارشناس لازم است. متن پیشنهاد در اعلان قرار نمیگیرد. اعلانهای تیکتهای
|
||||
عادی و ارسالهای قدیمی مسیر موجود خود را حفظ میکنند.
|
||||
|
||||
## انتشار و migration
|
||||
|
||||
1. از دیتابیس production پشتیبان قابل بازیابی بگیرید و migrationها را ابتدا
|
||||
روی کپی آزمایشی بررسی کنید. این تغییر production را خودکار migrate نمیکند.
|
||||
2. migrationهای جدید: `tickets.0018_dispatch_response_opening` و
|
||||
`notifications.0021_dispatch_response_opening`. اولی جدول جدید میسازد؛
|
||||
دومی choices را فقط در وضعیت Django تغییر میدهد و قالب مفقود را با
|
||||
`get_or_create` اضافه میکند. رکورد یا قالب قبلی بازنویسی نمیشود.
|
||||
3. کد برنامه و worker را هماهنگ منتشر و worker پیامک را با نسخهٔ جدید
|
||||
راهاندازی مجدد کنید؛ worker قدیمی رویداد تازه را نمیشناسد.
|
||||
4. تنظیم جدید Nginx برای `/protected-media/dispatch-responses/` را نیز منتشر
|
||||
کنید. فایلهای این مسیر فقط از endpoint مجاز برنامه تحویل داده میشوند.
|
||||
فایلهای قبلی جابهجا نمیشوند.
|
||||
5. مجوز مشاهده و بازگشایی را فقط به کاربران منتخب بدهید. هیچ مجوزی قفل
|
||||
پاسخ را دور نمیزند. تنظیمات و سابقه بازگشایی در Admin فقط خواندنیاند.
|
||||
|
||||
پس از استفادهٔ قابلیت، rollback جدول کنترل پاسخ ممنوع است تا محرمانگی و
|
||||
سابقه بازگشایی حذف نشود. بازگشت به کد قدیمی نیز قفل جدید را نمیشناسد؛ برای
|
||||
بازگشت از نسخه، از برنامهٔ بازیابی هماهنگ کد و دیتابیس استفاده کنید.
|
||||
|
||||
این محدودیت در برنامه و Django Admin اعمال میشود؛ دسترسی مستقیم مدیر
|
||||
زیرساخت به دیتابیس یا فایل سرور، رمزنگاری نشده است.
|
||||
|
||||
## تستها
|
||||
|
||||
`tests/test_dispatch_response_opening.py` دسترسی، پیوست، Admin، قالب، سقف
|
||||
مخاطب، تراکنش صف و worker را بررسی میکند.
|
||||
`tests/test_dispatch_opening_postgres.py` همزمانی واقعی و حفظ داده در
|
||||
migration را بررسی میکند. تستهای همزمانی باید روی PostgreSQL اجرا شوند.
|
||||
سناریوهای رابط کاربری در `tests/e2e/test_dispatch_response_opening_e2e.py`
|
||||
با fixture مرورگر پروژه، در عرضهای ۱۴۴۰، ۳۹۰ و ۳۲۰ اجرا میشوند.
|
||||
@@ -1,6 +1,8 @@
|
||||
# اطلاعرسانی فیش مشتری
|
||||
|
||||
پس از ثبت موفق فیش مشتری، اپ `payments` اعلانها را در صف `NotificationJob` قرار میدهد. ثبت از حساب مشتری و لینک کوتاه پرداخت هر دو پشتیبانی میشوند. فیشی که کارشناس به نمایندگی از مشتری ثبت میکند اعلان ندارد. تصویر هر نسخه مستقل است؛ گزینه «اطلاعرسانی فیش اصلاحشده مشتری» تعیین میکند نسخههای اصلاحی نیز اعلان داشته باشند.
|
||||
پس از ثبت موفق فیش مشتری، اپ `payments` اعلانها را در صف `NotificationJob` قرار میدهد. ثبت از حساب مشتری، لینک کوتاه پرداخت و «ثبت فیش برای مشتری» در پنل کارشناس همگی پشتیبانی میشوند. ثبت دستی کارشناس نیز با همان تنظیمات به مدیران فعال پیامک میفرستد و تصویر فیش را به همان گروه بله ارسال میکند؛ اطلاعات پیام مربوط به مشتری و واریزکننده است. تصویر هر نسخه مستقل است؛ گزینه «اطلاعرسانی فیش اصلاحشده مشتری» تعیین میکند نسخههای اصلاحی نیز، چه توسط مشتری و چه کارشناس مجاز ثبت شوند، اعلان داشته باشند.
|
||||
|
||||
این اصلاح migration یا تنظیمات جداگانه نمیخواهد. برای اعمال آن، سرور برنامه و Worker موجود `process_notifications` را با نسخه جدید کد بازراهاندازی کنید. اعلانهای مربوط به ثبتهای قبلی که صفی برای آنها ساخته نشده، بهصورت خودکار ارسال نمیشوند.
|
||||
|
||||
## راهاندازی
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# لینک کوتاه تیکت در پیامک سرپرستان
|
||||
|
||||
در ادمین Django، بخش واحدهای سازمانی، گزینه «افزودن لینک تیکت به پیامک
|
||||
سرپرستان» را در قسمت «اطلاعرسانی بله و سرپرستان» فعال کنید. پیشفرض خاموش
|
||||
است و تنظیم هر واحد مستقل است. پیامک به شمارههای سرپرست اول تا پنجم آن
|
||||
واحد ارسال میشود؛ در صورت فعال بودن گزینه، لینک به انتهای الگوی فعلی
|
||||
پیامک اضافه میشود.
|
||||
|
||||
لینک به شکل `https://your-domain/vt/Ab12Cd34Ef` است. دامنه از تنظیم موجود
|
||||
`PUBLIC_BASE_URL` خوانده میشود و باید دامنه عمومی و قابل دسترس سامانه باشد.
|
||||
لینک هنگام اولین ارسال پیامک ساخته میشود و دقیقاً ۷ روز اعتبار دارد. تمام
|
||||
سرپرستان یک تیکت و تلاشهای مجدد ارسال، همان لینک و همان تاریخ انقضا را
|
||||
استفاده میکنند؛ ارسال مجدد اعتبار را تمدید نمیکند. پس از انقضا، پیامک
|
||||
فقط متن اعلان را دارد.
|
||||
|
||||
این لینک صرفاً میانبر صفحه واقعی `/staff/tickets/<uuid>/` است. هیچ ورود
|
||||
خودکار، مجوز جدید، نشست magic link یا دسترسی اشتراکی ایجاد نمیکند. کاربران
|
||||
بدون ورود به `/staff/login/?next=/vt/...` هدایت میشوند و پس از ورود معتبر
|
||||
staff به تیکت بازمیگردند. مجوز مشاهده و پاسخ همان مجوزهای عادی تیکت است؛
|
||||
حساب مشتری و کارشناس بدون دسترسی با خطای ۴۰۳ مواجه میشوند. لینک نامعتبر،
|
||||
منقضی یا متعلق به تیکت حذفشده پس از ورود خطای ۴۰۴ دارد. پاسخهای میانبر
|
||||
قابل کش کردن نیستند.
|
||||
|
||||
خاموش کردن گزینه، افزودن لینک به پیامکهای بعدی را متوقف میکند؛ لینکهای
|
||||
قبلاً ارسالشده تا پایان اعتبار خود معتبرند و همچنان ورود و مجوز لازم دارند.
|
||||
|
||||
برای اعمال ساختار پایگاه داده:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe manage.py migrate
|
||||
```
|
||||
@@ -35,6 +35,13 @@
|
||||
.chaharsu-dock a.active{background:#0b4c8c}.chaharsu-dock a{min-height:52px;color:#cadbea}
|
||||
|
||||
/* Staff payment workspace: one reading order across desktop and mobile. */
|
||||
.pay-day-summary{--day-ink:var(--color-ink,#08376b);--day-line:#dce5ed;--day-muted:#526c86;margin-block:0 16px;padding:12px;border:1px solid var(--day-line);border-radius:20px;background:var(--color-paper,#fff);color:var(--day-ink)}
|
||||
.pay-day-head{display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:4px 12px;margin-bottom:8px}.pay-day-head h2{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px 12px;margin:0;font-size:1rem;font-weight:800}.pay-day-head time,.pay-day-head>span{font-size:.75rem;font-weight:400;color:var(--day-muted)}
|
||||
.pay-day-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:0}.pay-day-metric{min-width:0;padding:8px 12px;border-radius:12px;background:#f6f8fa}.pay-day-metric dt{font-size:.875rem;font-weight:700;line-height:1.7}.pay-day-metric dd{margin:0;overflow-wrap:anywhere}.pay-day-count{font-size:1.375rem;font-weight:900;line-height:1.6;font-variant-numeric:tabular-nums}.pay-day-count span{font-size:.75rem;font-weight:400;color:var(--day-muted)}.pay-day-amount{font-size:.875rem;font-weight:700;line-height:1.8;font-variant-numeric:tabular-nums}.pay-day-amount bdi{overflow-wrap:anywhere}.pay-day-amount span{font-weight:400}.pay-day-note{font-size:.75rem;line-height:1.8;color:var(--day-muted)}
|
||||
.pay-day-approved dt{color:#216345}.pay-day-rejected dt{color:#9f1f1f}.pay-day-pending{background:#fff8eb}.pay-day-pending dt{color:#805300}.pay-day-notices{grid-column:1/-1;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;column-gap:12px}.pay-day-notices dt{grid-column:1;grid-row:1}.pay-day-notices .pay-day-count{grid-column:2;grid-row:1/3}.pay-day-notices .pay-day-note{grid-column:1}
|
||||
.pay-day-definitions{margin-top:4px}.pay-day-definitions summary{width:fit-content;min-height:44px;display:flex;align-items:center;gap:8px;cursor:pointer;font-size:.875rem;font-weight:700;list-style:none}.pay-day-definitions summary::-webkit-details-marker{display:none}.pay-day-definitions summary::after{content:'+'}.pay-day-definitions[open] summary::after{content:'−'}.pay-day-definitions summary:focus-visible{outline:3px solid #1977ce;outline-offset:3px;border-radius:4px}.pay-day-definitions p{margin:0 0 8px;font-size:.875rem;line-height:1.8;color:var(--day-muted)}
|
||||
@media(min-width:640px){.pay-day-summary{padding:16px}.pay-day-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.pay-day-notices{grid-column:auto;display:block}}
|
||||
@media(min-width:1000px){.pay-day-grid{grid-template-columns:repeat(5,minmax(0,1fr))}}
|
||||
.pay-workspace{max-width:1200px}.pay-page-head{display:flex;justify-content:space-between;align-items:center;gap:24px;flex-wrap:wrap;margin-bottom:24px}
|
||||
.pay-page-title{display:flex;align-items:center;gap:14px;min-width:0;flex:1}.pay-page-title>div{min-width:0}.pay-page-title h1{font-size:24px;line-height:1.6;margin:0}.pay-page-title p{margin:4px 0 0;color:#526c86;font-size:14px}
|
||||
.pay-page-icon{display:grid;place-items:center;width:52px;height:52px;flex-shrink:0;border:1px solid #cde6f7;border-radius:16px;background:#e5f4ff;color:#08376b}.pay-page-icon .cs-icon{width:26px;height:26px}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
.customer-login-block {
|
||||
margin-block: 1.25rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid #fca5a5;
|
||||
border-inline-start: 4px solid #b91c1c;
|
||||
border-radius: 12px;
|
||||
background: #fff1f2;
|
||||
color: #991b1b;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
line-height: 1.9;
|
||||
text-align: start;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.customer-login-block strong { display: block; font-size: 1.125rem; font-weight: 800; }
|
||||
.customer-login-block .customer-login-block-label { margin: .5rem 0 0; color: #991b1b; }
|
||||
.customer-login-block .customer-login-block-reason { margin: .25rem 0 0; color: #991b1b; font-weight: 700; white-space: pre-wrap; }
|
||||
@@ -0,0 +1,263 @@
|
||||
/* Scoped to group dispatch pages; the staff shell and ticket conversation keep their own styles. */
|
||||
.dispatch-response-banner { padding: 24px; margin: 16px 0; background: #fff; border: 1px solid #dce5ed; border-radius: 16px; color: #08376b; }
|
||||
.dispatch-response-banner p, .dispatch-response-options p, .opening-sms-preview { font-size: 14px; line-height: 1.9; overflow-wrap: anywhere; }
|
||||
.dispatch-response-options fieldset { margin: 16px; padding: 16px; border: 1px solid #dce5ed; border-radius: 12px; }
|
||||
.dispatch-response-options fieldset label, .dispatch-sms-choice { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 8px; }
|
||||
.dispatch-response-options input[type="radio"], .dispatch-response-options input[type="checkbox"] { width: 20px; height: 20px; min-height: 20px; flex: 0 0 auto; }
|
||||
.dispatch-response-options > p, .dispatch-response-options > small, .dispatch-sms-choice { margin: 12px 16px; }
|
||||
.dispatch-open-confirm { max-width: 900px; margin: auto; }
|
||||
.opening-sms-preview { padding: 20px; margin: 16px 0; background: #f6fbff; border-inline-start: 3px solid #1977ce; }
|
||||
.dispatch-opening-actions { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.dispatch-workspace {
|
||||
--dispatch-ink: #08376b;
|
||||
--dispatch-muted: #526c86;
|
||||
--dispatch-line: #dce5ed;
|
||||
--dispatch-soft: #f6f9fc;
|
||||
color: var(--dispatch-ink);
|
||||
font-size: 1rem;
|
||||
gap: 1.25rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
.dispatch-workspace *, .dispatch-workspace *::before, .dispatch-workspace *::after { box-sizing: border-box; }
|
||||
.dispatch-workspace [hidden] { display: none !important; }
|
||||
.dispatch-workspace h1, .dispatch-workspace h2, .dispatch-workspace h3, .dispatch-workspace p { overflow-wrap: anywhere; }
|
||||
.dispatch-workspace :is(span, small, dt, dd) { overflow-wrap: anywhere; }
|
||||
.dispatch-workspace .dispatch-icon { width: 1.25rem; height: 1.25rem; flex: none; vertical-align: middle; }
|
||||
.dispatch-workspace a, .dispatch-workspace button, .dispatch-workspace input, .dispatch-workspace select, .dispatch-workspace textarea, .dispatch-workspace summary { -webkit-tap-highlight-color: transparent; }
|
||||
.dispatch-workspace :is(a, button, input, select, textarea, summary):focus-visible { outline: 3px solid #1977ce; outline-offset: 3px; }
|
||||
.dispatch-workspace .dispatch-eyebrow { margin: 0 0 .35rem; color: var(--dispatch-muted); font-size: .75rem; font-weight: 700; }
|
||||
.dispatch-page-heading { display: flex; align-items: center; justify-content: space-between; gap: 1.5rem; }
|
||||
.dispatch-page-heading h1 { margin: 0; font-size: 1.75rem; font-weight: 900; line-height: 1.5; }
|
||||
.dispatch-page-heading p:not(.dispatch-eyebrow) { margin: .45rem 0 0; color: var(--dispatch-muted); font-size: .875rem; line-height: 1.8; }
|
||||
.dispatch-privacy { display: flex; align-items: center; gap: .6rem; max-width: 18rem; border: 1px solid #c9e2d5; border-radius: .75rem; padding: .75rem; color: #166347; background: #f2faf6; font-size: .75rem; line-height: 1.8; }
|
||||
.dispatch-summary-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid var(--dispatch-line); border-radius: 1.25rem; background: #fff; padding: 1rem 0; }
|
||||
.dispatch-summary-strip > div { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .5rem; padding: 0 1.25rem; }
|
||||
.dispatch-summary-strip > div + div { border-inline-start: 1px solid var(--dispatch-line); }
|
||||
.dispatch-summary-strip span { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.7; }
|
||||
.dispatch-summary-strip strong { font-size: 1.5rem; font-weight: 900; }
|
||||
.dispatch-view-switch { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; border-bottom: 1px solid #c6d4e0; padding-bottom: .75rem; }
|
||||
.dispatch-view-switch a { display: inline-flex; align-items: center; gap: .5rem; min-height: 2.75rem; border: 1px solid transparent; border-radius: .75rem; padding: .6rem 1rem; color: var(--dispatch-ink); font-size: .875rem; font-weight: 800; text-decoration: none; }
|
||||
.dispatch-view-switch a[aria-current="true"] { border-color: var(--dispatch-ink); background: var(--dispatch-ink); color: #fff; }
|
||||
.dispatch-view-switch a:not([aria-current="true"]):hover { background: #fff; border-color: var(--dispatch-line); }
|
||||
.dispatch-view-switch a > span { border-radius: 1rem; padding: .1rem .5rem; background: #e5f4ff; color: var(--dispatch-ink); font-size: .75rem; }
|
||||
.dispatch-compose-layout { display: grid; grid-template-columns: minmax(0, 1fr) 17rem; align-items: start; gap: 1.25rem; }
|
||||
.dispatch-workspace .dispatch-composer { gap: 1rem; min-width: 0; }
|
||||
.dispatch-workspace .dispatch-composer > section { border-color: var(--dispatch-line); border-radius: 1.25rem; box-shadow: none; }
|
||||
.dispatch-workspace .dispatch-composer > section > header { padding: 1.25rem; background: #fff; }
|
||||
.dispatch-workspace .dispatch-composer > section > header > span { width: 2.25rem; height: 2.25rem; background: #e5f4ff; color: var(--dispatch-ink); font-size: .875rem; }
|
||||
.dispatch-workspace .dispatch-composer h2 { font-size: 1.125rem; font-weight: 900; }
|
||||
.dispatch-workspace .dispatch-composer header p { margin: .3rem 0 0; color: var(--dispatch-muted); font-size: .875rem; line-height: 1.7; }
|
||||
.dispatch-workspace .editor-grid { gap: 1rem; padding: 1.25rem; }
|
||||
.dispatch-workspace .editor-grid label, .dispatch-workspace .message-editor { gap: .5rem; font-size: .875rem; font-weight: 700; color: var(--dispatch-ink); }
|
||||
.dispatch-workspace .editor-grid em, .dispatch-workspace .message-editor em { margin-inline-start: .4rem; color: var(--dispatch-muted); font-size: .75rem; font-weight: 400; }
|
||||
.dispatch-workspace :is(.editor-grid input, .editor-grid select, .message-editor textarea, .dispatch-tags select, .dispatch-filter input, .dispatch-filter select, .dispatch-recipient-tools input, .dispatch-recipient-tools select) { width: 100%; min-height: 2.875rem; border: 1px solid #aabed0; border-radius: .75rem; background: #fff; padding: .65rem .75rem; color: var(--dispatch-ink); font: inherit; font-size: 1rem; box-shadow: none; }
|
||||
.dispatch-workspace select { cursor: pointer; }
|
||||
.dispatch-workspace select:disabled { background: #f2f5f8; color: var(--dispatch-muted); cursor: default; }
|
||||
.dispatch-workspace .editor-grid small, .dispatch-workspace .message-editor small { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; font-weight: 400; }
|
||||
.dispatch-workspace .message-editor { margin: 0 1.25rem 1.25rem; }
|
||||
.dispatch-workspace .message-editor textarea { min-height: 9rem; height: 10rem; resize: vertical; line-height: 1.9; }
|
||||
.dispatch-workspace .dispatch-tags { margin: 0 1.25rem 1.25rem; border: 1px solid var(--dispatch-line); border-radius: .75rem; }
|
||||
.dispatch-workspace summary { min-height: 2.75rem; padding: .65rem .8rem; color: var(--dispatch-ink); font-size: .875rem; font-weight: 700; cursor: pointer; }
|
||||
.dispatch-tags summary span { color: var(--dispatch-muted); font-size: .75rem; font-weight: 400; margin-inline-start: .5rem; }
|
||||
.dispatch-tags label { display: grid; gap: .5rem; padding: 0 .8rem .8rem; font-size: .875rem; }
|
||||
.dispatch-tags small { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; }
|
||||
.dispatch-workspace .dispatch-tags select { min-height: 5rem; }
|
||||
.dispatch-workspace .dispatch-attachment-picker { margin: 0 1.25rem 1.25rem; padding: 1rem; border-radius: 1rem; background: #f6fbff; }
|
||||
.dispatch-workspace .dispatch-attachment-picker b { color: var(--dispatch-ink); font-size: .875rem; }
|
||||
.dispatch-workspace .dispatch-attachment-picker b small { display: inline; margin-inline-start: .5rem; }
|
||||
.dispatch-workspace .dispatch-attachment-picker small { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; }
|
||||
.dispatch-workspace .media-picker-button { display: flex; justify-content: center; align-items: center; min-height: 2.75rem; border: 1px solid #9ebbd1; border-radius: .75rem; padding: .6rem .8rem; background: #fff; color: var(--dispatch-ink); font-size: .875rem; font-weight: 800; cursor: pointer; }
|
||||
.dispatch-workspace .media-picker-button:has(+ input:focus-visible) { outline: 3px solid #1977ce; outline-offset: 3px; }
|
||||
.dispatch-workspace .media-picker-button:focus-visible { outline: 3px solid #1977ce; outline-offset: 3px; }
|
||||
.dispatch-workspace .file-chip { max-width: 100%; box-shadow: none; }
|
||||
.dispatch-workspace .file-chip small { overflow-wrap: anywhere; }
|
||||
.dispatch-workspace .file-chip button { min-width: 2.75rem; min-height: 2.75rem; font-size: 1.25rem; }
|
||||
.dispatch-workspace .dispatch-composer > footer { padding: 1rem 1.25rem; border-radius: 1.25rem; box-shadow: none; }
|
||||
.dispatch-workspace .dispatch-composer footer b { font-size: .875rem; }
|
||||
.dispatch-workspace .dispatch-composer footer small { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; }
|
||||
.dispatch-workspace .dispatch-primary, .dispatch-workspace .dispatch-secondary, .dispatch-workspace .dispatch-composer footer button, .dispatch-workspace .dispatch-filter button, .dispatch-workspace .dispatch-reply-link { display: inline-flex; align-items: center; justify-content: center; gap: .5rem; min-height: 2.75rem; border: 1px solid var(--dispatch-ink); border-radius: .75rem; padding: .65rem 1rem; background: var(--dispatch-ink); color: #fff; font: inherit; font-size: .875rem; font-weight: 800; text-decoration: none; cursor: pointer; box-shadow: none; line-height: 1.7; }
|
||||
.dispatch-workspace .dispatch-primary:hover, .dispatch-workspace .dispatch-composer footer button:hover { background: #0b4c8c; transform: none; }
|
||||
.dispatch-workspace .dispatch-secondary { border-color: #aabed0; background: #fff; color: var(--dispatch-ink); }
|
||||
.dispatch-workspace .dispatch-secondary:hover { border-color: var(--dispatch-ink); background: var(--dispatch-soft); transform: none; }
|
||||
.dispatch-workspace button:disabled { cursor: wait; opacity: .65; }
|
||||
.dispatch-compose-guide { border: 1px solid var(--dispatch-line); border-radius: 1.25rem; background: #f8fbfe; padding: 1.25rem; }
|
||||
.dispatch-guide-icon { display: grid; place-items: center; width: 2.75rem; height: 2.75rem; border-radius: .875rem; background: #e5f4ff; }
|
||||
.dispatch-compose-guide h2 { margin: .85rem 0 .4rem; font-size: 1.125rem; font-weight: 900; }
|
||||
.dispatch-compose-guide p { font-size: .875rem; line-height: 1.9; color: var(--dispatch-muted); }
|
||||
.dispatch-compose-guide dl { display: grid; gap: 1rem; margin: 1.25rem 0; }
|
||||
.dispatch-compose-guide dt, .dispatch-review-meta dt, .dispatch-operation-details dt { color: var(--dispatch-muted); font-size: .75rem; }
|
||||
.dispatch-compose-guide dd, .dispatch-review-meta dd, .dispatch-operation-details dd { margin: .25rem 0 0; font-size: .875rem; font-weight: 800; overflow-wrap: anywhere; }
|
||||
.dispatch-compose-guide .dispatch-guide-note { display: flex; align-items: flex-start; gap: .5rem; margin: 0; border-top: 1px solid var(--dispatch-line); padding-top: 1rem; font-size: .75rem; }
|
||||
.dispatch-workspace .dispatch-history { position: static; border-color: var(--dispatch-line); border-radius: 1.25rem; }
|
||||
.dispatch-workspace .dispatch-history > header { gap: 1rem; padding: 1.25rem; }
|
||||
.dispatch-workspace .dispatch-history h2, .dispatch-workspace .stream-head h2 { margin: 0; font-size: 1.125rem; font-weight: 900; }
|
||||
.dispatch-workspace .dispatch-history header p, .dispatch-workspace .stream-head p { color: var(--dispatch-muted); margin: .35rem 0 0; font-size: .875rem; line-height: 1.8; }
|
||||
.dispatch-workspace .dispatch-history header > span { display: block; width: auto; height: auto; flex: none; padding: .3rem .75rem; background: #e5f4ff; font-size: .75rem; border-radius: 1rem; }
|
||||
.dispatch-workspace .dispatch-filter { grid-template-columns: minmax(12rem, 2fr) minmax(10rem, 1fr); align-items: end; gap: 1rem; padding: 1.25rem; background: var(--dispatch-soft); }
|
||||
.dispatch-workspace .dispatch-filter label, .dispatch-workspace .dispatch-recipient-tools label { display: grid; gap: .4rem; font-size: .875rem; font-weight: 700; color: var(--dispatch-ink); }
|
||||
.dispatch-workspace .dispatch-filter > footer { justify-content: flex-start; grid-column: 1 / -1; }
|
||||
.dispatch-workspace .dispatch-filter button { flex: none; }
|
||||
.dispatch-workspace .dispatch-filter footer a { display: inline-flex; align-items: center; min-height: 2.75rem; padding: .5rem; color: var(--dispatch-ink); font-size: .875rem; }
|
||||
.dispatch-more-filters { grid-column: 1 / -1; }
|
||||
.dispatch-more-filters > div { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1rem; margin-top: .5rem; }
|
||||
.dispatch-workspace .dispatch-results { max-height: none; overflow: visible; }
|
||||
.dispatch-workspace .dispatch-history-card { grid-template-columns: minmax(0, 2fr) auto auto minmax(10rem, 1fr) auto; gap: 1rem; padding: 1.25rem; text-decoration: none; }
|
||||
.dispatch-workspace .dispatch-history-card b { white-space: normal; overflow: visible; font-size: 1rem; color: var(--dispatch-ink); }
|
||||
.dispatch-workspace .dispatch-history-card small, .dispatch-workspace .dispatch-history-card time, .dispatch-history-count { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; }
|
||||
.dispatch-history-open { display: flex; align-items: center; gap: .4rem; color: var(--dispatch-ink); font-size: .875rem; font-weight: 800; }
|
||||
.dispatch-workspace .operations-pagination { padding: 1rem 1.25rem; font-size: .875rem; }
|
||||
.dispatch-workspace .operations-pagination a { display: inline-flex; min-height: 2.75rem; align-items: center; padding: .5rem .85rem; color: var(--dispatch-ink); }
|
||||
.dispatch-empty { padding: 2rem 1rem; text-align: center; }
|
||||
.dispatch-empty > svg { width: 2rem; height: 2rem; color: var(--dispatch-muted); }
|
||||
.dispatch-empty h3 { margin: .65rem 0; font-size: 1.125rem; }
|
||||
.dispatch-empty p { color: var(--dispatch-muted); font-size: .875rem; line-height: 1.8; }
|
||||
.dispatch-error-summary { border: 1px solid #dfa5a5; border-radius: .875rem; padding: 1rem; background: #fff6f6; color: #9f1f1f; font-size: .875rem; line-height: 1.9; }
|
||||
.dispatch-error-summary a { color: #9f1f1f; text-decoration: underline; }
|
||||
.dispatch-workspace .errorlist { color: #9f1f1f; font-size: .875rem; line-height: 1.8; }
|
||||
.dispatch-workspace [aria-invalid="true"] { border-color: #9f1f1f; }
|
||||
.dispatch-review-dialog { width: min(42rem, calc(100% - 1.5rem)); max-height: calc(100dvh - 2rem); margin: auto; border: 1px solid var(--dispatch-line); border-radius: 1.25rem; padding: 1.5rem; color: var(--dispatch-ink); background: #fff; overflow-y: auto; box-shadow: 0 1.5rem 5rem #08376b33; }
|
||||
.dispatch-review-dialog::backdrop { background: #08284880; }
|
||||
.dispatch-review-dialog header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; }
|
||||
.dispatch-review-dialog h2 { margin: 0; font-size: 1.25rem; font-weight: 900; }
|
||||
.dispatch-workspace .dispatch-dialog-close { display: grid; place-items: center; flex: none; min-width: 2.75rem; height: 2.75rem; border: 1px solid var(--dispatch-line); border-radius: .75rem; background: #fff; color: var(--dispatch-ink); font-size: 1.5rem; padding: 0; box-shadow: none; }
|
||||
.dispatch-review-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 1.5rem 0; padding: 1rem; background: var(--dispatch-soft); border-radius: .875rem; }
|
||||
.dispatch-review-dialog h3 { margin: 1rem 0 .5rem; font-size: 1.125rem; }
|
||||
.dispatch-review-body { white-space: pre-wrap; font-size: 1rem; line-height: 1.9; }
|
||||
.dispatch-review-warning { padding: 1rem; border: 1px solid #efd8ad; border-radius: .75rem; background: #fff8eb; color: #805300; font-size: .875rem; line-height: 1.9; }
|
||||
.dispatch-inquiry-recommendation { padding: 1rem; border: 1px solid #f2b5bb; border-inline-start: 4px solid #b42332; border-radius: .75rem; background: #fff2f3; color: #a11727; font-size: .875rem; line-height: 1.9; }
|
||||
.dispatch-inquiry-recommendation p { margin: .5rem 0 0; }
|
||||
.dispatch-review-dialog footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .75rem; margin-top: 1.25rem; }
|
||||
.dispatch-breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; font-size: .75rem; color: var(--dispatch-muted); }
|
||||
.dispatch-breadcrumb a { display: inline-flex; align-items: center; gap: .4rem; min-height: 2.75rem; color: var(--dispatch-ink); font-size: .875rem; }
|
||||
.dispatch-workspace .dispatch-report-hero { align-items: center; border: 0; border-radius: 0; padding: 0; background: transparent; box-shadow: none; }
|
||||
.dispatch-workspace .dispatch-report-title { min-width: 0; }
|
||||
.dispatch-workspace .dispatch-report-title h1 { color: var(--dispatch-ink); font-size: 1.75rem; line-height: 1.6; font-weight: 900; }
|
||||
.dispatch-workspace .dispatch-title-line { flex-wrap: wrap; }
|
||||
.dispatch-workspace .report-status { padding: .35rem .7rem; font-size: .75rem; font-weight: 800; background: #eaf0f5; color: #405d75; }
|
||||
.dispatch-workspace .report-status.status-sent { background: #e2f5ea; color: #166347; }
|
||||
.dispatch-workspace .report-status.status-partial { background: #fff0e1; color: #805300; }
|
||||
.dispatch-workspace .dispatch-meta-list span { border: 0; background: transparent; padding: 0; color: var(--dispatch-muted); font-size: .75rem; flex-wrap: wrap; }
|
||||
.dispatch-workspace .dispatch-meta-list span + span { border-inline-start: 1px solid #c6d4e0; padding-inline-start: .75rem; }
|
||||
.dispatch-workspace .dispatch-kpi-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: .75rem; overflow: visible; }
|
||||
.dispatch-workspace .dispatch-kpi { min-width: 0; align-items: flex-start; gap: .5rem; border-radius: 1rem; padding: 1rem; box-shadow: none; }
|
||||
.dispatch-workspace .dispatch-kpi > svg { color: var(--dispatch-ink); margin-top: .25rem; }
|
||||
.dispatch-workspace .dispatch-kpi small { color: var(--dispatch-ink); font-size: .875rem; }
|
||||
.dispatch-workspace .dispatch-kpi strong { color: var(--dispatch-ink); font-size: 1.5rem; font-weight: 900; }
|
||||
.dispatch-workspace .dispatch-kpi p { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; }
|
||||
.dispatch-workspace .delivery-overview { border-color: var(--dispatch-line); border-radius: 1rem; padding: 1rem 1.25rem; }
|
||||
.dispatch-workspace .delivery-progress-copy { flex-wrap: wrap; }
|
||||
.dispatch-workspace .delivery-progress-copy strong { color: var(--dispatch-ink); font-size: 1rem; }
|
||||
.dispatch-workspace .delivery-progress-copy p, .dispatch-workspace .delivery-overview-note span { color: var(--dispatch-muted); font-size: .75rem; }
|
||||
.dispatch-workspace .delivery-progress-copy span, .dispatch-workspace .delivery-overview-note b { font-size: .875rem; color: var(--dispatch-ink); }
|
||||
.dispatch-workspace .delivery-progress-track { height: .375rem; background: #e7edf2; box-shadow: none; }
|
||||
.dispatch-workspace .delivery-progress-track span { background: #16815c; }
|
||||
.dispatch-workspace .dispatch-original-message { gap: .85rem; padding: 1.25rem; border-color: var(--dispatch-line); border-radius: 1.25rem; background: #fff; box-shadow: none; }
|
||||
.dispatch-workspace .dispatch-original-message h2 { color: var(--dispatch-ink); font-size: 1.125rem; }
|
||||
.dispatch-workspace .dispatch-original-message header > span { direction: rtl; color: var(--dispatch-muted); font-size: .75rem; }
|
||||
.dispatch-workspace .dispatch-original-message blockquote { border-inline-start: 3px solid #1977ce; border-right: 0; background: var(--dispatch-soft); color: var(--dispatch-ink); font-size: 1rem; line-height: 1.9; padding: .75rem 1rem; overflow-wrap: anywhere; }
|
||||
.dispatch-operation-details { border-top: 1px solid var(--dispatch-line); }
|
||||
.dispatch-operation-details dl { display: flex; flex-wrap: wrap; gap: 1rem 2rem; padding: 0 .8rem; margin: .4rem 0; }
|
||||
.dispatch-workspace .dispatch-recipient-report { border: 1px solid var(--dispatch-line); border-radius: 1.25rem; background: #fff; overflow: hidden; }
|
||||
.dispatch-workspace .stream-head { align-items: flex-start; gap: 1rem; padding: 1.25rem; background: #fff; }
|
||||
.dispatch-workspace .stream-head > span { flex: none; color: var(--dispatch-muted); font-size: .75rem; }
|
||||
.dispatch-recipient-tools { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr) auto; gap: .75rem; align-items: end; border-block: 1px solid var(--dispatch-line); padding: 1rem 1.25rem; background: var(--dispatch-soft); }
|
||||
.dispatch-recipient-tools > p { grid-column: 1 / -1; margin: 0; color: var(--dispatch-muted); font-size: .75rem; }
|
||||
.dispatch-workspace .table-scroll { overflow: visible; }
|
||||
.dispatch-workspace .dispatch-detail-table { width: 100%; min-width: 0; table-layout: fixed; font-size: .875rem; }
|
||||
.dispatch-workspace .dispatch-detail-table th { padding: .8rem 1rem; color: var(--dispatch-muted); font-size: .75rem; background: var(--dispatch-soft); text-align: right; }
|
||||
.dispatch-detail-table th:nth-child(1) { width: 20%; }
|
||||
.dispatch-detail-table th:nth-child(2) { width: 26%; }
|
||||
.dispatch-detail-table th:nth-child(3) { width: 14%; }
|
||||
.dispatch-detail-table th:nth-child(4) { width: 40%; }
|
||||
.dispatch-workspace .dispatch-detail-table td { padding: 1rem; min-width: 0; overflow-wrap: anywhere; }
|
||||
.dispatch-workspace .recipient-cell { min-width: 0; }
|
||||
.dispatch-workspace .recipient-cell strong, .dispatch-workspace .ticket-reference strong, .dispatch-workspace .sms-state-card strong, .dispatch-workspace .engagement-state strong { color: var(--dispatch-ink); font-size: .875rem; }
|
||||
.dispatch-workspace .recipient-cell small, .dispatch-workspace .sms-state-card p, .dispatch-workspace .sms-state-card small, .dispatch-workspace .engagement-state small { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; }
|
||||
.dispatch-workspace .ticket-reference { min-height: 2.75rem; align-content: center; margin-top: .25rem; }
|
||||
.dispatch-workspace .ticket-reference strong { white-space: normal; overflow-wrap: anywhere; font-weight: 800; }
|
||||
.dispatch-workspace .ticket-state-pill { color: var(--dispatch-ink); font-size: .75rem; white-space: normal; }
|
||||
.dispatch-workspace .dispatch-sms-state { min-width: 0; }
|
||||
.dispatch-workspace .sms-state-card { padding: .75rem; }
|
||||
.dispatch-workspace .sms-state-card > div { min-width: 0; }
|
||||
.dispatch-workspace .sms-state-icon { width: 1.5rem; height: 1.5rem; background: transparent; }
|
||||
.dispatch-workspace .sms-technical-details, .dispatch-workspace .sms-error-panel { font-size: .875rem; }
|
||||
.dispatch-workspace .sms-technical-details dt { color: var(--dispatch-muted); font-size: .75rem; }
|
||||
.dispatch-workspace .sms-technical-details dd { font-size: .875rem; }
|
||||
.dispatch-workspace .inline-retry-form button { min-height: 2.75rem; border-radius: .75rem; padding: .5rem; color: #9f1f1f; font-size: .875rem; }
|
||||
.dispatch-workspace .engagement-state { min-width: 0; gap: .5rem; }
|
||||
.dispatch-workspace .engagement-state > svg { color: #526c86; }
|
||||
.dispatch-workspace .engagement-state.has-engagement > svg { color: #5c369d; }
|
||||
.dispatch-workspace .recipient-reply { min-width: 0; margin-top: .75rem; padding: .875rem; border: 1px solid var(--dispatch-line); border-radius: .75rem; background: #f6fbff; }
|
||||
.dispatch-workspace .recipient-reply p { color: var(--dispatch-ink); font-size: .875rem; line-height: 1.9; overflow-wrap: anywhere; }
|
||||
.dispatch-workspace .recipient-reply time { color: var(--dispatch-muted); font-size: .75rem; }
|
||||
.dispatch-response-files { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .5rem; }
|
||||
.dispatch-response-files a { font-size: .8rem; overflow-wrap: anywhere; text-decoration: underline; }
|
||||
.dispatch-workspace .recipient-reply small, .dispatch-workspace .no-reply-state { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; }
|
||||
.dispatch-workspace .no-reply-state { margin: 0; }
|
||||
.dispatch-workspace .dispatch-reply-link { margin-top: .75rem; width: 2.25rem; min-height: 2.25rem; padding: .4rem; border-color: var(--dispatch-line); background: #f6fbff; color: var(--dispatch-ink); }
|
||||
.dispatch-management { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; gap: 1.25rem; border: 1px solid var(--dispatch-line); border-radius: 1.25rem; padding: 1.25rem; background: #fff; }
|
||||
.dispatch-management h2 { margin: 0; font-size: 1.125rem; font-weight: 900; }
|
||||
.dispatch-management p { margin: .4rem 0 0; color: var(--dispatch-muted); font-size: .875rem; line-height: 1.9; }
|
||||
.dispatch-management-actions { display: grid; gap: .75rem; }
|
||||
.dispatch-management form { margin: 0; }
|
||||
.dispatch-management button { width: 100%; }
|
||||
.dispatch-management small { display: block; margin-top: .4rem; color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; }
|
||||
.dispatch-management .dispatch-complete { display: flex; align-items: flex-start; gap: .5rem; margin: 0; color: #166347; }
|
||||
.dispatch-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
||||
@media (max-width: 1200px) {
|
||||
.dispatch-workspace .dispatch-history-card { grid-template-columns: minmax(0, 1fr) auto; gap: .6rem 1rem; }
|
||||
.dispatch-history-main { grid-column: 1; }
|
||||
.dispatch-history-card time { grid-column: 1; }
|
||||
.dispatch-workspace .dispatch-kpi-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.dispatch-workspace .dispatch-detail-table thead { display: none; }
|
||||
.dispatch-workspace .dispatch-detail-table, .dispatch-workspace .dispatch-detail-table tbody { display: block; }
|
||||
.dispatch-workspace .dispatch-detail-table tr { display: grid; grid-template-columns: 1fr 1fr; border-bottom: .5rem solid var(--dispatch-soft); }
|
||||
.dispatch-workspace .dispatch-detail-table td { display: block; width: auto; border-bottom: 1px solid var(--dispatch-line); }
|
||||
.dispatch-workspace .dispatch-detail-table td::before { display: block; margin-bottom: .6rem; content: attr(data-label); color: var(--dispatch-muted); font-size: .75rem; font-weight: 700; }
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.dispatch-compose-layout { grid-template-columns: minmax(0, 1fr); }
|
||||
.dispatch-compose-guide { padding: 1rem; }
|
||||
.dispatch-compose-guide dl { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.dispatch-more-filters > div { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.dispatch-workspace { gap: 1rem; }
|
||||
.dispatch-page-heading { align-items: stretch; flex-direction: column; gap: .75rem; }
|
||||
.dispatch-page-heading h1, .dispatch-workspace .dispatch-report-title h1 { font-size: 1.5rem; }
|
||||
.dispatch-privacy { max-width: none; }
|
||||
.dispatch-summary-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 0; }
|
||||
.dispatch-summary-strip > div { min-height: 4rem; padding: .75rem; }
|
||||
.dispatch-summary-strip > div:nth-child(3) { border-inline-start: 0; }
|
||||
.dispatch-summary-strip > div:nth-child(n+3) { border-top: 1px solid var(--dispatch-line); }
|
||||
.dispatch-view-switch a { flex: 1; justify-content: center; padding-inline: .5rem; }
|
||||
.dispatch-workspace .dispatch-composer > section > header, .dispatch-workspace .editor-grid { padding: 1rem; }
|
||||
.dispatch-workspace .editor-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.dispatch-workspace .message-editor, .dispatch-workspace .dispatch-tags, .dispatch-workspace .dispatch-attachment-picker { margin-inline: 1rem; }
|
||||
.dispatch-workspace .dispatch-composer > footer { align-items: stretch; flex-direction: column; gap: .75rem; padding: 1rem; }
|
||||
.dispatch-workspace .dispatch-composer footer button { width: 100%; }
|
||||
.dispatch-compose-guide dl { grid-template-columns: 1fr; }
|
||||
.dispatch-workspace .dispatch-filter { grid-template-columns: minmax(0, 1fr); padding: 1rem; }
|
||||
.dispatch-workspace .dispatch-history > header { flex-wrap: wrap; padding: 1rem; }
|
||||
.dispatch-workspace .dispatch-history-card { padding: 1rem; }
|
||||
.dispatch-more-filters > div { grid-template-columns: minmax(0, 1fr); }
|
||||
.dispatch-review-dialog { padding: 1rem; }
|
||||
.dispatch-review-dialog footer { flex-direction: column-reverse; }
|
||||
.dispatch-review-meta { grid-template-columns: minmax(0, 1fr); }
|
||||
.dispatch-workspace .dispatch-report-hero { display: grid; }
|
||||
.dispatch-workspace .dispatch-meta-list span + span { border: 0; padding: 0; }
|
||||
.dispatch-workspace .dispatch-meta-list { display: grid; gap: .4rem; }
|
||||
.dispatch-workspace .dispatch-kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.dispatch-workspace .dispatch-kpi { padding: .85rem; }
|
||||
.dispatch-workspace .dispatch-kpi > svg { display: none; }
|
||||
.dispatch-workspace .dispatch-original-message { padding: 1rem; }
|
||||
.dispatch-workspace .dispatch-original-message header { display: grid; gap: .5rem; }
|
||||
.dispatch-workspace .stream-head { flex-wrap: wrap; padding: 1rem; }
|
||||
.dispatch-recipient-tools { grid-template-columns: minmax(0, 1fr); padding: 1rem; }
|
||||
.dispatch-workspace .dispatch-detail-table tr { grid-template-columns: minmax(0, 1fr); }
|
||||
.dispatch-workspace .dispatch-detail-table td { padding: 1rem; }
|
||||
.dispatch-management { grid-template-columns: minmax(0, 1fr); padding: 1rem; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dispatch-workspace *, .dispatch-workspace *::before, .dispatch-workspace *::after { transition: none; animation: none; scroll-behavior: auto; }
|
||||
}
|
||||
@@ -9,3 +9,13 @@
|
||||
.customer-receipt-form #selected-payment .pay-deposit-warning{margin-top:12px;color:#9f1f1f;font-size:14px}.customer-receipt-form #id_deposit_id.pay-deposit-emphasis{color:#9f1f1f;border-color:#d39090;font-weight:700}
|
||||
.pay-link-message{max-width:620px;margin:48px auto}.pay-link-message .pay-card{padding:32px;border-radius:23px}.pay-link-message h1{margin:20px 0 12px;font-size:24px}.pay-link-message p{color:#526c86;font-size:16px;line-height:1.9;margin-bottom:24px}.pay-link-message .pay-actions{gap:12px;flex-wrap:wrap}
|
||||
@media(max-width:639px){.pay-link-message{margin:24px auto}.pay-link-message .pay-card{padding:24px 18px}.pay-link-status{padding:12px 14px;margin-bottom:18px}.pay-link-session-label{display:none}.pay-link-message .pay-button{width:100%}}
|
||||
|
||||
/* Receipt action is emphasized only on the customer's individual notice page. */
|
||||
.customer-notice-detail .pay-receipt-guidance{margin:0 0 20px;font-size:14px;line-height:1.8;color:inherit}
|
||||
.customer-notice-detail .pay-receipt-next-step{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-block:20px;padding:18px 20px;border:1px solid #cde6f7;border-radius:16px;background:#f6fbff}
|
||||
.customer-notice-detail .pay-receipt-next-step>div{min-width:0}
|
||||
.customer-notice-detail .pay-receipt-next-step h2{margin:0 0 4px;font-size:18px;font-weight:800}
|
||||
.customer-notice-detail .pay-receipt-next-step p{margin:0;color:#526c86;font-size:14px;line-height:1.8}
|
||||
.customer-notice-detail .pay-receipt-action{gap:10px;min-height:52px;padding:12px 20px;font-size:16px;flex-shrink:0}
|
||||
.customer-notice-detail .pay-receipt-action .cs-icon{width:22px;height:22px}
|
||||
@media(max-width:639px){.customer-notice-detail .pay-receipt-next-step{flex-direction:column;align-items:stretch;gap:16px;padding:16px}.customer-notice-detail .pay-receipt-action{width:100%}}
|
||||
@@ -0,0 +1,25 @@
|
||||
(() => {
|
||||
const script = document.querySelector('script[data-customer-login-access]');
|
||||
if (!script) return;
|
||||
let pending = false;
|
||||
async function checkAccess() {
|
||||
if (pending || document.hidden) return;
|
||||
pending = true;
|
||||
try {
|
||||
const response = await fetch(script.dataset.customerLoginAccess, {
|
||||
credentials: 'same-origin', cache: 'no-store', headers: {Accept: 'application/json'}
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const result = await response.json();
|
||||
if (!result.authenticated) window.location.replace(result.login_url);
|
||||
} catch (_) {
|
||||
// Retry on the next heartbeat or when the browser returns online.
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
window.setInterval(checkAccess, 10000);
|
||||
document.addEventListener('visibilitychange', checkAccess);
|
||||
window.addEventListener('pageshow', checkAccess);
|
||||
window.addEventListener('online', checkAccess);
|
||||
})();
|
||||
@@ -0,0 +1,254 @@
|
||||
/* Progressive enhancement: native forms and private-ticket links work without this script. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const number = new Intl.NumberFormat('fa-IR');
|
||||
const displayCount = count => `${number.format(count)} نفر`;
|
||||
|
||||
function initSections(root) {
|
||||
const tabs = Array.from(root.querySelectorAll('[data-dispatch-tab]'));
|
||||
const panels = Array.from(root.querySelectorAll('[data-dispatch-panel]'));
|
||||
const hasErrors = Boolean(root.querySelector('.dispatch-error-summary'));
|
||||
const filters = new URLSearchParams(location.search);
|
||||
const hasFilters = ['q', 'status', 'unit', 'group', 'created_from', 'created_to', 'page'].some(key => filters.get(key));
|
||||
const selectedSection = () => {
|
||||
if (hasErrors) return 'compose';
|
||||
if (location.hash === '#dispatch-history-panel') return 'history';
|
||||
if (location.hash === '#dispatch-compose-panel') return 'compose';
|
||||
return hasFilters ? 'history' : 'compose';
|
||||
};
|
||||
const show = section => {
|
||||
tabs.forEach(tab => {
|
||||
const active = tab.dataset.dispatchTab === section;
|
||||
tab.setAttribute('aria-selected', String(active));
|
||||
tab.setAttribute('aria-current', String(active));
|
||||
tab.tabIndex = active ? 0 : -1;
|
||||
});
|
||||
panels.forEach(panel => { panel.hidden = panel.dataset.dispatchPanel !== section; });
|
||||
};
|
||||
root.querySelector('.dispatch-view-switch').setAttribute('role', 'tablist');
|
||||
tabs.forEach((tab, index) => {
|
||||
tab.setAttribute('role', 'tab');
|
||||
tab.setAttribute('aria-controls', `dispatch-${tab.dataset.dispatchTab}-panel`);
|
||||
tab.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
history.pushState(null, '', tab.getAttribute('href'));
|
||||
show(tab.dataset.dispatchTab);
|
||||
});
|
||||
tab.addEventListener('keydown', event => {
|
||||
let next;
|
||||
if (event.key === 'ArrowLeft') next = (index + 1) % tabs.length;
|
||||
if (event.key === 'ArrowRight') next = (index + tabs.length - 1) % tabs.length;
|
||||
if (event.key === 'Home') next = 0;
|
||||
if (event.key === 'End') next = tabs.length - 1;
|
||||
if (next === undefined) return;
|
||||
event.preventDefault();
|
||||
tabs[next].click();
|
||||
tabs[next].focus();
|
||||
});
|
||||
});
|
||||
panels.forEach(panel => panel.setAttribute('role', 'tabpanel'));
|
||||
window.addEventListener('popstate', () => show(selectedSection()));
|
||||
show(selectedSection());
|
||||
}
|
||||
|
||||
function fillOptions(select, items, labelKey, placeholder, autoSelect = false) {
|
||||
const previous = select.value;
|
||||
select.replaceChildren(new Option(placeholder, ''));
|
||||
items.forEach(item => select.add(new Option(item[labelKey], String(item.id))));
|
||||
if (items.some(item => String(item.id) === previous)) select.value = previous;
|
||||
else if (autoSelect && items.length === 1) select.value = String(items[0].id);
|
||||
}
|
||||
|
||||
function initRecipients(root, form, data) {
|
||||
const operation = form.elements.operation_type;
|
||||
const unit = form.elements.unit;
|
||||
const group = form.elements.contact_group;
|
||||
const category = form.elements.category;
|
||||
const groupHint = root.querySelector('[data-dispatch-group-hint]');
|
||||
const text = (selector, value) => { root.querySelector(selector).textContent = value; };
|
||||
const updateSummary = () => {
|
||||
const selected = data.groups.find(item => String(item.id) === group.value);
|
||||
updateResponseOptions(root, form, selected);
|
||||
text('[data-dispatch-unit-summary]', unit.value ? unit.selectedOptions[0].textContent : 'انتخاب نشده');
|
||||
text('[data-dispatch-group-summary]', selected ? selected.name : 'انتخاب نشده');
|
||||
text('[data-dispatch-count-summary]', selected ? displayCount(selected.recipient_count) : 'پس از انتخاب گروه');
|
||||
group.setCustomValidity(selected && !selected.recipient_count ? 'این گروه مخاطب فعال دارای شماره همراه ندارد؛ گروه دیگری انتخاب کنید.' : '');
|
||||
groupHint.textContent = selected ? `${displayCount(selected.recipient_count)} فعال دارای شماره همراه؛ تعداد نهایی هنگام ثبت بررسی میشود.` : 'فقط گروههای واحد انتخابشده نمایش داده میشوند.';
|
||||
};
|
||||
const updateGroupOptions = () => {
|
||||
const hasUnit = Boolean(unit.value);
|
||||
const groups = data.groups.filter(item => String(item.unit_id) === unit.value);
|
||||
const categories = data.categories.filter(item => String(item.unit_id) === unit.value);
|
||||
const applicableCategories = categories.length ? categories : data.categories.filter(item => item.unit_id === null);
|
||||
fillOptions(group, hasUnit ? groups : [], 'name', hasUnit ? 'گروه مخاطبان را انتخاب کنید' : 'ابتدا واحد را انتخاب کنید');
|
||||
fillOptions(category, hasUnit ? applicableCategories : [], 'title', hasUnit ? 'دستهبندی را انتخاب کنید' : 'ابتدا واحد را انتخاب کنید');
|
||||
group.disabled = !hasUnit;
|
||||
category.disabled = !hasUnit;
|
||||
updateSummary();
|
||||
};
|
||||
const updateUnitOptions = () => {
|
||||
const units = data.units.filter(item => item.operations.includes(operation.value));
|
||||
fillOptions(unit, units, 'name', 'واحد ارسالکننده را انتخاب کنید', !root.querySelector('.dispatch-error-summary'));
|
||||
updateGroupOptions();
|
||||
};
|
||||
operation.addEventListener('change', updateUnitOptions);
|
||||
unit.addEventListener('change', updateGroupOptions);
|
||||
group.addEventListener('change', updateSummary);
|
||||
updateUnitOptions();
|
||||
return () => data.groups.find(item => String(item.id) === group.value);
|
||||
}
|
||||
|
||||
function updateResponseOptions(root, form, group) {
|
||||
const sms = form.elements.notify_opening_sms;
|
||||
const individual = form.elements.response_mode.value === 'INDIVIDUAL';
|
||||
const oversized = Boolean(group && group.recipient_count > 15);
|
||||
if (sms.dataset.openingPreference === undefined) sms.dataset.openingPreference = String(sms.checked);
|
||||
sms.disabled = individual || oversized;
|
||||
sms.checked = !sms.disabled && sms.dataset.openingPreference === 'true';
|
||||
root.querySelector('[data-opening-sms-hint]').textContent = individual
|
||||
? 'پیامک بازگشایی فقط در حالت یکجا مجاز است.'
|
||||
: oversized ? 'بیش از ۱۵ گیرندهٔ واجد شرایط؛ اطلاعرسانی بازگشایی با پیامک مجاز نیست.'
|
||||
: 'پس از بازگشایی فقط پاسخدهندگان پیامک دریافت میکنند؛ حداکثر ۱۵ گیرنده مجاز است.';
|
||||
}
|
||||
|
||||
function initReview(root, form, selectedGroup) {
|
||||
const dialog = root.querySelector('[data-dispatch-review]');
|
||||
if (typeof dialog.showModal !== 'function') return;
|
||||
const send = form.querySelector('[data-dispatch-send]');
|
||||
const confirm = dialog.querySelector('[data-dispatch-confirm]');
|
||||
let confirmed = false;
|
||||
send.textContent = 'بررسی و ادامه';
|
||||
const setText = (selector, value) => { dialog.querySelector(selector).textContent = value; };
|
||||
const review = () => {
|
||||
const group = selectedGroup();
|
||||
if (!form.reportValidity() || !group) return;
|
||||
// The shared uploader empties picker inputs only after image preparation completes.
|
||||
if (Array.from(form.querySelectorAll('[data-gallery-input], [data-document-input]')).some(input => input.files.length)) {
|
||||
root.querySelector('[data-compression-status]').textContent = 'آمادهسازی پیوستها ادامه دارد؛ پس از پایان دوباره بررسی و ادامه را بزنید.';
|
||||
return;
|
||||
}
|
||||
setText('[data-review-unit]', form.elements.unit.selectedOptions[0].textContent);
|
||||
setText('[data-review-group]', group.name);
|
||||
setText('[data-review-count]', displayCount(group.recipient_count));
|
||||
setText('[data-review-response-policy]', `${form.elements.response_mode.value === 'SEALED' ? 'بازگشایی یکجا' : 'مشاهده جداگانه'}؛ پیامک بازگشایی: ${form.elements.notify_opening_sms.checked && !form.elements.notify_opening_sms.disabled ? 'فعال' : 'غیرفعال'}`);
|
||||
dialog.querySelector('[data-review-inquiry-recommendation]').hidden = form.elements.response_mode.value === 'SEALED' && form.elements.notify_opening_sms.checked && !form.elements.notify_opening_sms.disabled;
|
||||
dialog.querySelector('[data-review-inquiry-limit]').hidden = group.recipient_count <= 15;
|
||||
const files = Array.from(form.elements.files.files);
|
||||
setText('[data-review-files]', files.length ? files.map(file => file.name).join('، ') : 'بدون پیوست');
|
||||
setText('[data-review-subject]', form.elements.subject.value);
|
||||
setText('[data-review-body]', form.elements.body.value);
|
||||
dialog.showModal();
|
||||
dialog.querySelector('[data-dispatch-review-close]').focus();
|
||||
};
|
||||
// Capture before shared uploader/UI listeners: reviewing must not disable submission controls.
|
||||
form.addEventListener('submit', event => {
|
||||
if (confirmed) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
review();
|
||||
}, true);
|
||||
dialog.querySelectorAll('[data-dispatch-review-close]').forEach(button => button.addEventListener('click', () => dialog.close()));
|
||||
dialog.addEventListener('close', () => { if (!confirmed) send.focus(); });
|
||||
confirm.addEventListener('click', () => {
|
||||
if (confirmed || !form.reportValidity()) return;
|
||||
confirmed = true;
|
||||
confirm.disabled = true;
|
||||
confirm.textContent = 'در حال ثبت…';
|
||||
dialog.close();
|
||||
form.requestSubmit(send);
|
||||
});
|
||||
}
|
||||
|
||||
function initErrors(root, form) {
|
||||
form.querySelectorAll('.errorlist').forEach((list, index) => {
|
||||
const control = list.parentElement.querySelector('input, select, textarea');
|
||||
if (!control) return;
|
||||
list.id = `dispatch-error-${index}`;
|
||||
control.setAttribute('aria-invalid', 'true');
|
||||
control.setAttribute('aria-describedby', list.id);
|
||||
});
|
||||
const summary = root.querySelector('.dispatch-error-summary');
|
||||
if (!summary) return;
|
||||
summary.querySelectorAll('a').forEach(link => link.addEventListener('click', event => {
|
||||
const control = document.getElementById(link.hash.slice(1));
|
||||
if (!control) return;
|
||||
event.preventDefault();
|
||||
const disclosure = control.closest('details');
|
||||
if (disclosure) disclosure.open = true;
|
||||
control.focus();
|
||||
}));
|
||||
summary.focus();
|
||||
}
|
||||
|
||||
function initComposer(root) {
|
||||
const form = root.querySelector('#dispatch-compose-form');
|
||||
const data = JSON.parse(root.querySelector('#dispatch-options').textContent);
|
||||
initSections(root);
|
||||
const selectedGroup = initRecipients(root, form, data);
|
||||
form.elements.notify_opening_sms.addEventListener('change', event => {
|
||||
event.target.dataset.openingPreference = String(event.target.checked);
|
||||
});
|
||||
form.querySelectorAll('[name="response_mode"]').forEach(input => input.addEventListener('change', () => updateResponseOptions(root, form, selectedGroup())));
|
||||
initReview(root, form, selectedGroup);
|
||||
initErrors(root, form);
|
||||
// Visible, keyboard-operable file labels retain the shared uploader's original inputs.
|
||||
root.querySelectorAll('.media-picker-button').forEach(label => {
|
||||
label.tabIndex = 0;
|
||||
label.setAttribute('role', 'button');
|
||||
label.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
document.getElementById(label.htmlFor).click();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSearch(value) {
|
||||
return value.toLowerCase().replace(/[يى]/g, 'ی').replace(/ك/g, 'ک')
|
||||
.replace(/[۰-۹]/g, digit => String(digit.charCodeAt(0) - 1776))
|
||||
.replace(/[٠-٩]/g, digit => String(digit.charCodeAt(0) - 1632))
|
||||
.replace(/[\s\u200c-]+/g, '');
|
||||
}
|
||||
|
||||
function initReport(root) {
|
||||
const rows = Array.from(root.querySelectorAll('[data-recipient-row]'));
|
||||
if (!rows.length) return;
|
||||
const tools = root.querySelector('[data-recipient-tools]');
|
||||
const search = tools.querySelector('[data-recipient-search]');
|
||||
const filter = tools.querySelector('[data-recipient-filter]');
|
||||
const status = tools.querySelector('[data-recipient-result-count]');
|
||||
const empty = root.querySelector('[data-recipient-empty]');
|
||||
const matches = {
|
||||
all: () => true,
|
||||
answered: row => row.dataset.answered === 'true',
|
||||
unanswered: row => row.dataset.answered === 'false',
|
||||
opened: row => row.dataset.opened === 'true',
|
||||
unopened: row => row.dataset.opened === 'false',
|
||||
retryable: row => row.dataset.retryable === 'true',
|
||||
active: row => row.dataset.active === 'true',
|
||||
};
|
||||
const update = () => {
|
||||
const query = normalizeSearch(search.value);
|
||||
let visible = 0;
|
||||
rows.forEach(row => {
|
||||
row.hidden = !normalizeSearch(row.dataset.search).includes(query) || !matches[filter.value](row);
|
||||
if (!row.hidden) visible += 1;
|
||||
});
|
||||
status.textContent = `${number.format(visible)} از ${number.format(rows.length)} مخاطب`;
|
||||
empty.hidden = visible > 0;
|
||||
};
|
||||
search.addEventListener('input', update);
|
||||
filter.addEventListener('change', update);
|
||||
root.querySelectorAll('[data-recipient-reset]').forEach(button => button.addEventListener('click', () => {
|
||||
search.value = '';
|
||||
filter.value = 'all';
|
||||
update();
|
||||
search.focus();
|
||||
}));
|
||||
tools.hidden = false;
|
||||
update();
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-dispatch-workspace]').forEach(initComposer);
|
||||
document.querySelectorAll('[data-dispatch-report]').forEach(initReport);
|
||||
})();
|
||||
@@ -4,7 +4,7 @@
|
||||
{% block content %}
|
||||
<div class="auth-shell">
|
||||
{% if site_branding.login_intro_enabled %}<section class="auth-intro"><div class="auth-intro-icon">{{ site_branding.login_intro_icon }}</div><p class="eyebrow">{{ site_branding.login_intro_eyebrow }}</p><h2>{{ site_branding.login_intro_heading|linebreaksbr }}</h2><p>{{ site_branding.login_intro_body|linebreaksbr }}</p></section>{% endif %}
|
||||
<div class="auth-panel"><section class="auth card">{% include 'includes/auth_brand.html' with hide_copy=True %}<p class="eyebrow">ورود مشتریان</p><h1>خوش آمدید</h1><p class="muted">شماره موبایل خود را وارد کنید؛ نیازی به ثبتنام یا رمز عبور نیست.</p><form method="post" data-disable-on-submit>{% csrf_token %}{{ form.as_p }}<button type="submit">دریافت کد یکبارمصرف <span aria-hidden="true">←</span></button></form><div class="auth-links"><a href="{% url 'accounts:password_login' %}">ورود با رمز عبور</a><a href="{% url 'accounts:staff_login' %}">ورود کارشناسان</a></div></section>{% include 'includes/auth_footer.html' %}</div>
|
||||
<div class="auth-panel"><section class="auth card">{% include 'includes/auth_brand.html' with hide_copy=True %}<p class="eyebrow">ورود مشتریان</p><h1>خوش آمدید</h1><p class="muted">شماره موبایل خود را وارد کنید؛ نیازی به ثبتنام یا رمز عبور نیست.</p>{% include 'includes/customer_login_block.html' %}<form method="post" data-disable-on-submit>{% csrf_token %}{{ form.as_p }}<button type="submit">دریافت کد یکبارمصرف <span aria-hidden="true">←</span></button></form><div class="auth-links"><a href="{% url 'accounts:password_login' %}">ورود با رمز عبور</a><a href="{% url 'accounts:staff_login' %}">ورود کارشناسان</a></div></section>{% include 'includes/auth_footer.html' %}</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block footer %}{% endblock %}
|
||||
@@ -4,7 +4,7 @@
|
||||
{% block content %}
|
||||
<div class="auth-shell">
|
||||
<section class="auth-intro"><div class="auth-intro-icon">●</div><p class="eyebrow">ورود سریع</p><h2>دوباره خوش آمدید.</h2><p>اگر قبلاً در پروفایل خود رمز تعیین کردهاید، بدون انتظار برای پیامک وارد شوید.</p></section>
|
||||
<div class="auth-panel"><section class="auth card">{% include 'includes/auth_brand.html' %}<p class="eyebrow">حساب مشتری</p><h1>ورود با رمز عبور</h1><p class="muted">شماره موبایل و رمز انتخابی خود را وارد کنید.</p><form method="post" data-disable-on-submit>{% csrf_token %}{{ form.as_p }}<button type="submit">ورود به حساب</button></form><div class="auth-links"><a href="{% url 'accounts:login' %}">ورود با کد یکبارمصرف</a></div></section>{% include 'includes/auth_footer.html' %}</div>
|
||||
<div class="auth-panel"><section class="auth card">{% include 'includes/auth_brand.html' %}<p class="eyebrow">حساب مشتری</p><h1>ورود با رمز عبور</h1><p class="muted">شماره موبایل و رمز انتخابی خود را وارد کنید.</p>{% include 'includes/customer_login_block.html' %}<form method="post" data-disable-on-submit>{% csrf_token %}{{ form.as_p }}<button type="submit">ورود به حساب</button></form><div class="auth-links"><a href="{% url 'accounts:login' %}">ورود با کد یکبارمصرف</a></div></section>{% include 'includes/auth_footer.html' %}</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block footer %}{% endblock %}
|
||||
@@ -12,6 +12,7 @@
|
||||
<link rel="stylesheet" href="{% static 'css/tailwind.css' %}?v={{ static_asset_version }}">
|
||||
<link rel="stylesheet" href="{% static 'css/operations.css' %}?v={{ static_asset_version }}">
|
||||
<link rel="stylesheet" href="{% static 'css/dispatches.css' %}?v={{ static_asset_version }}">
|
||||
{% if request.resolver_match.namespace == 'accounts' %}<link rel="stylesheet" href="{% static 'css/customer-login-access.css' %}?v={{ static_asset_version }}">{% endif %}
|
||||
{% if request.resolver_match.namespace == 'accounts' and request.resolver_match.url_name == 'profile' or financial_page or request.resolver_match.namespace == 'tickets' and request.resolver_match.url_name == 'list' or request.resolver_match.namespace == 'tickets' and request.resolver_match.url_name == 'new' %}<link rel="stylesheet" href="{% static 'css/customer-dock.css' %}?v={{ static_asset_version }}">{% endif %}
|
||||
<script defer src="{% static 'vendor/htmx-2.0.3.min.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'vendor/alpine-3.14.8.min.js' %}?v={{ static_asset_version }}"></script>
|
||||
{% block extra_head %}{% endblock %}
|
||||
@@ -73,6 +74,7 @@
|
||||
{% if user.is_authenticated %}<script defer src="{% static 'js/pwa.js' %}?v={{ static_asset_version }}"></script>{% endif %}
|
||||
{% if user.is_authenticated and user.account_type == 'STAFF' %}<link rel="stylesheet" href="{% static 'css/staff-sharing.css' %}?v={{ static_asset_version }}"><script defer src="{% static 'js/staff-sharing.js' %}?v={{ static_asset_version }}"></script>{% endif %}
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
{% if user.is_authenticated and user.account_type == 'CUSTOMER' and not user.is_staff and not user.is_superuser %}<script defer src="{% static 'js/customer-login-access.js' %}?v={{ static_asset_version }}" data-customer-login-access="{% url 'accounts:login_access' %}"></script>{% endif %}
|
||||
{% block otp_script %}{% endblock %}
|
||||
{% endblock %}
|
||||
</body></html>
|
||||
@@ -0,0 +1,7 @@
|
||||
{% if block_reason %}
|
||||
<div class="customer-login-block" role="alert" aria-labelledby="login-block-title" tabindex="-1">
|
||||
<strong id="login-block-title">ورود به حساب شما مسدود است</strong>
|
||||
<p class="customer-login-block-label">دلیل مسدودی:</p>
|
||||
<p class="customer-login-block-reason">{{ block_reason }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% load jalali humanize %}
|
||||
<section class="pay-day-summary" aria-labelledby="pay-day-title">
|
||||
<header class="pay-day-head">
|
||||
<h2 id="pay-day-title">وضعیت امروز <time datetime="{{ daily_summary.date|date:'Y-m-d' }}">{{ daily_summary.date|jalali_date }}</time></h2>
|
||||
<span>در محدوده دسترسی شما · مستقل از فیلترها</span>
|
||||
</header>
|
||||
<dl class="pay-day-grid">
|
||||
<div class="pay-day-metric" data-summary="submitted">
|
||||
<dt>ارسالی امروز</dt>
|
||||
<dd class="pay-day-count"><bdi>{{ daily_summary.submitted_count|intcomma:False }}</bdi> <span>فیش</span></dd>
|
||||
<dd class="pay-day-amount"><bdi dir="ltr">{{ daily_summary.submitted_amount|intcomma:False }}</bdi> <span>ریال</span></dd>
|
||||
</div>
|
||||
<div class="pay-day-metric pay-day-approved" data-summary="approved">
|
||||
<dt>تأیید امروز</dt>
|
||||
<dd class="pay-day-count"><bdi>{{ daily_summary.approved_count|intcomma:False }}</bdi> <span>فیش</span></dd>
|
||||
<dd class="pay-day-amount"><bdi dir="ltr">{{ daily_summary.approved_amount|intcomma:False }}</bdi> <span>ریال</span></dd>
|
||||
</div>
|
||||
<div class="pay-day-metric pay-day-rejected" data-summary="rejected">
|
||||
<dt>رد امروز</dt>
|
||||
<dd class="pay-day-count"><bdi>{{ daily_summary.rejected_count|intcomma:False }}</bdi> <span>فیش</span></dd>
|
||||
<dd class="pay-day-amount"><bdi dir="ltr">{{ daily_summary.rejected_amount|intcomma:False }}</bdi> <span>ریال</span></dd>
|
||||
</div>
|
||||
<div class="pay-day-metric pay-day-pending" data-summary="pending">
|
||||
<dt>در انتظار بررسی</dt>
|
||||
<dd class="pay-day-count"><bdi>{{ daily_summary.pending_count|intcomma:False }}</bdi> <span>فیش</span></dd>
|
||||
<dd class="pay-day-amount"><bdi dir="ltr">{{ daily_summary.pending_amount|intcomma:False }}</bdi> <span>ریال</span></dd>
|
||||
<dd class="pay-day-note">همه روزها · شامل تأیید ناقص</dd>
|
||||
</div>
|
||||
<div class="pay-day-metric pay-day-notices" data-summary="notices">
|
||||
<dt>اعلامیه امروز</dt>
|
||||
<dd class="pay-day-count"><bdi>{{ daily_summary.notice_count|intcomma:False }}</bdi> <span>اعلامیه</span></dd>
|
||||
<dd class="pay-day-note">ثبتشده امروز</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<details class="pay-day-definitions">
|
||||
<summary>مبنای آمار</summary>
|
||||
<p><strong>ارسالی امروز:</strong> تعداد فیشهایی که اولین بار امروز ثبت شدهاند و جمع مبلغ آنها، با هر وضعیتی. تاریخ واریز درجشده روی فیش ملاک نیست؛ ارسال نسخه اصلاحیِ فیشی که روز دیگری ثبت شده، به این تعداد اضافه نمیشود.</p>
|
||||
<p><strong>تأیید امروز:</strong> تعداد و جمع مبلغ فیشهایی که نسخه جاری آنها امروز به تأیید نهایی رسیده و اکنون تأییدشده است؛ حتی اگر روز دیگری ارسال شده باشند. رأی اولیه یا تأیید ناقص، تأیید نهایی محسوب نمیشود.</p>
|
||||
<p><strong>رد امروز:</strong> تعداد و جمع مبلغ فیشهایی که بررسی نسخه جاری آنها امروز با رد پایان یافته و اکنون ردشده است. اگر فیش اصلاح و دوباره برای بررسی ارسال شود، دیگر در این گروه محاسبه نمیشود.</p>
|
||||
<p><strong>در انتظار بررسی:</strong> تعداد و جمع مبلغ همه فیشهایی که هنوز نتیجه نهایی ندارند؛ شامل «در انتظار بررسی» و «تأیید ناقص» از همه روزها.</p>
|
||||
<p><strong>اعلامیه امروز:</strong> تعداد اعلامیههای واریزی که امروز ایجاد شدهاند. اعلامیههای حذفشده محاسبه نمیشوند؛ اعلامیه لغوشده، تا زمانی که حذف نشده باشد، در تعداد ثبتشدهها باقی میماند.</p>
|
||||
<p><strong>قواعد مشترک:</strong> همه مبلغها به ریال و بر اساس آخرین نسخه هر فیش هستند؛ نسخههای قبلی و رأیهای متعدد جداگانه شمرده نمیشوند. آمار فقط موارد قابل مشاهده برای شما را شامل میشود و با تغییر فیلترها، تب یا صفحه فهرست تغییر نمیکند. چون مبنای گروهها متفاوت است، مجموع تأیید و رد امروز لزوماً برابر ارسالی امروز نیست.</p>
|
||||
</details>
|
||||
</section>
|
||||
@@ -11,9 +11,21 @@
|
||||
{% if notice.deposit_id %}<div class="pay-deposit-required"><dt>شناسه واریز · ضروری</dt><dd dir="ltr">{{ notice.deposit_id }}</dd><button type="button" data-copy="{{ notice.deposit_id }}" class="pay-copy" aria-label="کپی شناسه واریز">{% include 'payments/_icon.html' with name='copy' %} کپی</button></div>{% endif %}
|
||||
</dl>
|
||||
{% if notice.deposit_id %}<p class="pay-deposit-warning" role="note">هنگام واریز، حتماً شناسه واریز ارائهشده را در فرم بانکی، موبایلبانک یا اینترنتبانک وارد کنید؛ واریز بدون این شناسه معتبر نخواهد بود.</p>{% endif %}
|
||||
<!-- Supplementary details stay visible within the expanded notice. Complete copy uses ALL snapshot fields. -->
|
||||
<p>تأییدشده: {{ notice.approved_amount|intcomma:False }} ریال · باقیمانده: {{ notice.remaining_amount|intcomma:False }} ریال</p><section class="pay-supplement"><h3>اطلاعات تکمیلی و توضیحات</h3>{% if notice.invoice_number %}<p>فاکتور: {{ notice.invoice_number }}</p>{% endif %}<p>{{ notice.description|default:'توضیح دیگری اعلام نشده است.'|linebreaksbr }}</p></section>
|
||||
<div class="pay-actions">{% if not notice.cancelled_at and not staff_notice and notice.remaining_amount > 0 %}<a class="pay-button" href="{% url 'payments:receipt_new' %}?notice={{ notice.public_id }}">ارسال فیش این واریز</a>{% endif %}<button type="button" class="pay-button secondary" data-copy="{{ notice.copy_text }}">کپی کامل اطلاعات</button></div>
|
||||
{% if notice_page and not staff_notice %}
|
||||
<div class="pay-actions"><button type="button" class="pay-button secondary" data-copy="{{ notice.copy_text }}">{% include 'payments/_icon.html' with name='copy' %} کپی کامل اطلاعات</button></div>
|
||||
{% if not notice.cancelled_at and notice.remaining_amount > 0 %}
|
||||
<section class="pay-receipt-next-step" aria-labelledby="receipt-next-step-{{ notice.public_id }}">
|
||||
<div><h2 id="receipt-next-step-{{ notice.public_id }}">پس از واریز</h2><p>تصویر فیش را برای بررسی این واریز ارسال کنید.</p></div>
|
||||
<a class="pay-button pay-receipt-action" href="{% url 'payments:receipt_new' %}?notice={{ notice.public_id }}">{% include 'payments/_icon.html' with name='receipt' %}<span>ارسال فیش این واریز</span></a>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<!-- Supplementary details with content stay visible within the expanded notice. Complete copy uses ALL snapshot fields. -->
|
||||
<p>تأییدشده: {{ notice.approved_amount|intcomma:False }} ریال · باقیمانده: {{ notice.remaining_amount|intcomma:False }} ریال</p>
|
||||
{% with invoice_number=notice.invoice_number.strip description=notice.description.strip %}
|
||||
{% if invoice_number or description %}<section class="pay-supplement"><h3>اطلاعات تکمیلی و توضیحات</h3>{% if invoice_number %}<p>فاکتور: {{ invoice_number }}</p>{% endif %}{% if description %}<p>{{ description|linebreaksbr }}</p>{% endif %}</section>{% endif %}
|
||||
{% endwith %}
|
||||
{% if not notice_page or staff_notice %}<div class="pay-actions">{% if not notice.cancelled_at and not staff_notice and notice.remaining_amount > 0 %}<a class="pay-button" href="{% url 'payments:receipt_new' %}?notice={{ notice.public_id }}">ارسال فیش این واریز</a>{% endif %}<button type="button" class="pay-button secondary" data-copy="{{ notice.copy_text }}">کپی کامل اطلاعات</button></div>{% endif %}
|
||||
{% if notice_page and not staff_notice %}<form class="pay-page-share" method="post" action="{% url 'payments:notice_share_link' notice.public_id %}" data-share-link>
|
||||
{% csrf_token %}<button type="submit" class="pay-button secondary">{% include 'payments/_icon.html' with name='copy' %}<span>کپی لینک کوتاه این صفحه برای ارسال به دیگران</span></button>
|
||||
<p>دارندهٔ لینک به اطلاعات همین واریز دسترسی دارد؛ لینک تا پایان اعتبار آن قابل استفاده است.</p>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{% include 'payments/_link_status.html' %}
|
||||
{% if not payment_link_access %}<nav class="pay-breadcrumb" aria-label="مسیر صفحه"><a href="{% url 'payments:notices' %}">شماره حسابها</a><span aria-hidden="true">/</span><span aria-current="page">جزئیات واریز</span></nav>{% endif %}
|
||||
<header class="customer-finance-title"><span class="pay-page-icon">{% include 'payments/_icon.html' with name='bank' %}</span><div><h1>جزئیات واریز</h1><p>اطلاعات حساب، فیشهای ارسالی و سوابق بررسی این واریز</p></div></header>
|
||||
{% if not notice.cancelled_at and notice.remaining_amount > 0 %}<p class="pay-receipt-guidance">پس از واریز، تصویر فیش را با دکمهٔ <strong>«ارسال فیش این واریز»</strong> در پایین صفحه ارسال کنید.</p>{% endif %}
|
||||
{% include 'payments/_notice.html' with notice_page=True %}
|
||||
<div class="customer-service-footer">{% if payment_link_access %}<a class="pay-text-link" href="{% url 'payments:receipt_new' %}?mode=independent">ارسال فیش واریز مستقل</a>{% else %}<a class="pay-text-link" href="{% url 'payments:notices' %}">بازگشت به واریزهای شما</a>{% endif %}<a class="pay-text-link" href="{% url 'payments:receipt_list' %}">فیشهای من و وضعیت بررسی</a></div>
|
||||
</section>{% endblock %}
|
||||
@@ -7,6 +7,7 @@
|
||||
<div class="pay-page-title"><span class="pay-page-icon">{% include 'payments/_icon.html' with name='receipt' %}</span><div><h1>اعلامیهها و فیشهای واریزی</h1><p>اعلام اطلاعات واریز به مشتریان و بررسی فیشهای پرداخت</p></div></div>
|
||||
{% if can_create %}<div class="pay-head-actions"><a class="pay-button" href="{% url 'staff_payments:notice_new' %}">{% include 'payments/_icon.html' with name='plus' %}ایجاد اعلامیه واریز</a><a class="pay-button secondary" href="{% url 'staff_payments:receipt_customer' %}">ثبت فیش برای مشتری</a></div>{% endif %}
|
||||
</header>
|
||||
{% include 'payments/_daily_summary.html' %}
|
||||
<nav class="pay-tabs pay-desk-tabs" aria-label="نمای پرداختها">
|
||||
<a href="?tab=receipts" {% if tab != 'notices' and not mine %}aria-current="page"{% endif %}>فیشهای واریزی</a>
|
||||
<a href="?tab=notices" {% if tab == 'notices' %}aria-current="page"{% endif %}>اعلامیههای واریز</a>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg class="dispatch-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
{% if icon == 'send' %}<path d="m22 2-7 20-4-9-9-4Z M22 2 11 13"/>
|
||||
{% elif icon == 'shield' %}<path d="M12 3 4 6v6c0 5 8 9 8 9s8-4 8-9V6Z m-4 9 3 3 5-6"/>
|
||||
{% elif icon == 'history' %}<path d="M3 11a9 9 0 1 1 3 8 M3 4v7h7 M12 7v5l3 2"/>
|
||||
{% elif icon == 'people' %}<circle cx="9" cy="7" r="3"/><path d="M3 21v-3a6 6 0 0 1 12 0v3 M17 4a3 3 0 0 1 0 6 M18 15a5 5 0 0 1 3 4v2"/>
|
||||
{% elif icon == 'check' %}<path d="m5 12 4 4L19 6"/>
|
||||
{% elif icon == 'eye' %}<path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/>
|
||||
{% elif icon == 'reply' %}<path d="m9 6-6 6 6 6 M3 12h10a7 7 0 0 1 7 7"/>
|
||||
{% elif icon == 'attachment' %}<path d="m8 12 6-6a3 3 0 0 1 4 4L9 19a5 5 0 0 1-7-7L12 2"/>
|
||||
{% elif icon == 'arrow' %}<path d="M20 12H4 m6-6-6 6 6 6"/>
|
||||
{% else %}<circle cx="12" cy="12" r="9"/><path d="M12 7v6 M12 17h.01"/>{% endif %}
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1020 B |
@@ -4,6 +4,7 @@
|
||||
<div class="staff-ticket-main">
|
||||
<div class="staff-ticket-head"><span class="ticket-number" dir="ltr">{{ ticket.ticket_number }}</span>{% if ticket.status == 'WAITING_STAFF' or ticket.status == 'REOPENED' %}<span class="action-required">نیازمند پاسخ</span>{% endif %}<time>{{ ticket.last_activity_at|jalali_datetime }}</time></div>
|
||||
<h2>{{ ticket.subject }}</h2>
|
||||
{% if ticket.group_dispatch %}<p class="ticket-origin-label">ایجادشده از {{ ticket.group_dispatch.get_operation_type_display }}: {{ ticket.group_dispatch.subject }}</p>{% endif %}
|
||||
{% if not ticket.limited_share and ticket.owner and ticket.owner != user and not perms.tickets.view_all_tickets %}
|
||||
<p class="ticket-locked">قفلشده توسط {{ ticket.owner.staff_profile.display_name }} · جزئیات پس از انتقال قابل مشاهده است</p>
|
||||
{% else %}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<header class="ticket-workspace-head"><div class="ticket-title"><a class="workspace-back" href="{% if magic_link_access %}{% url 'accounts:login' %}{% else %}{% url 'tickets:list' %}{% endif %}" aria-label="بازگشت">→</a><div><span class="ticket-number" dir="ltr">{{ ticket.ticket_number }}</span><h1>{{ ticket.subject }}</h1></div></div><div class="ticket-head-actions"><span class="badge" data-ticket-status>{{ ticket.get_status_display }}</span><button type="button" class="icon-button" data-toggle-panel="ticket-info" aria-label="جزئیات تیکت">ⓘ</button></div></header>
|
||||
<div class="workspace-grid customer-grid">
|
||||
<main class="conversation-pane">
|
||||
<section class="conversation-toolbar"><div><b>سوابق مکاتبات تیکت</b></div><small><span data-conversation-count>{{ conversation|length }}</span> مکاتبه</small></section>
|
||||
<section class="conversation-toolbar"><div><b>{% if ticket.group_dispatch %}{{ ticket.group_dispatch.get_operation_type_display }}{% else %}سوابق مکاتبات تیکت{% endif %}</b>{% if ticket.group_dispatch.response_control.mode == 'SEALED' and not ticket.group_dispatch.response_control.opened_at %}<small role="status">پاسخ شما تا بازگشایی برای کارکنان محرمانه است.</small>{% endif %}</div><small><span data-conversation-count>{{ conversation|length }}</span> مکاتبه</small></section>
|
||||
<section class="conversation" data-conversation role="log" aria-live="polite" aria-label="مکاتبات تیکت">{% if has_older %}<button class="load-older" type="button" data-load-older hx-get="{% url 'tickets:messages' ticket.public_id %}?before={{ older_cursor }}" hx-target="this" hx-swap="outerHTML">↑ نمایش مکاتبات قدیمیتر</button>{% endif %}{% include 'tickets/_messages.html' %}<div class="conversation-end" data-conversation-end aria-hidden="true"></div></section>
|
||||
<button class="new-message-jump" type="button" data-jump-latest hidden>↓ رفتن به آخرین مکاتبه</button>
|
||||
<section class="composer {% if request.GET.reply_result != 'error' %}is-collapsed{% endif %}" data-composer-shell>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends 'base.html' %}{% load static %}
|
||||
{% block title %}تأیید بازگشایی پاسخها{% endblock %}
|
||||
{% block extra_head %}<link rel="stylesheet" href="{% static 'css/dispatch-workspace.css' %}?v={{ static_asset_version }}">{% endblock %}
|
||||
{% block content %}
|
||||
<main class="operations-page dispatch-workspace dispatch-open-confirm">
|
||||
<section class="dispatch-response-banner"><h1>تأیید بازگشایی همه پاسخها</h1><h2>{{ dispatch.subject }}</h2>
|
||||
<p>{{ respondent_count }} مخاطب پاسخ دادهاند. با تأیید، پاسخهای این ارسال همزمان برای کاربران مجاز قابل مشاهده میشوند. این عملیات یکبار انجام میشود.</p>
|
||||
{% if control.notify_sms %}<p>پیامک زیر برای پاسخدهندگان موجود در لحظه بازگشایی در صف ارسال قرار میگیرد:</p><blockquote class="opening-sms-preview">{{ opening_sms|linebreaksbr }}</blockquote>
|
||||
{% else %}<p>اطلاعرسانی پیامکی این ارسال غیرفعال است؛ پیامکی ارسال نمیشود.</p>{% endif %}
|
||||
<p><strong>آیا اطمینان دارید که میخواهید همه پاسخها را بازگشایی کنید؟</strong></p>
|
||||
<form method="post">{% csrf_token %}<input type="hidden" name="confirmation" value="{{ confirmation }}"><div class="dispatch-opening-actions"><a class="dispatch-secondary" href="{% url 'staff_tickets:dispatch_detail' dispatch.public_id %}">بازگشت بدون بازگشایی</a><button type="submit" class="dispatch-primary">تأیید و بازگشایی همه پاسخها</button></div></form>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -8,8 +8,8 @@
|
||||
</header>
|
||||
{% if ticket.group_dispatch %}
|
||||
<section class="ticket-dispatch-context" aria-label="اطلاعات ارسال گروهی">
|
||||
<div><span aria-hidden="true">⇶</span><p><small>ارسال گروهی</small><b>{{ ticket.group_dispatch.subject }}</b><em>{{ ticket.group_dispatch.contact_group.name }}</em></p></div>
|
||||
<div class="ticket-dispatch-context-actions"><a href="{% url 'staff_tickets:dispatch_detail' ticket.group_dispatch.public_id %}">مشاهده گزارش گروه</a>{% if can_resolve_dispatch and group_open_count %}<form method="post" action="{% url 'staff_tickets:dispatch_resolve' ticket.group_dispatch.public_id %}" onsubmit="return confirm('هر {{ group_open_count }} تیکت باز این ارسال گروهی حلشده میشوند. آیا مطمئن هستید؟')">{% csrf_token %}<input type="hidden" name="return_ticket" value="{{ ticket.public_id }}"><button type="submit">✓ حلشدن تمامی تیکتهای گروه ({{ group_open_count }})</button></form>{% endif %}</div>
|
||||
<div><span aria-hidden="true">⇶</span><p><small>{{ ticket.group_dispatch.get_operation_type_display }}</small><b>{{ ticket.group_dispatch.subject }}</b><em>{{ ticket.group_dispatch.contact_group.name }}{% if ticket.group_dispatch.response_control.mode == 'SEALED' and not ticket.group_dispatch.response_control.opened_at %} — پاسخها تا بازگشایی محرمانهاند{% endif %}</em></p></div>
|
||||
<div class="ticket-dispatch-context-actions">{% if can_view_dispatch %}<a href="{% url 'staff_tickets:dispatch_detail' ticket.group_dispatch.public_id %}">مشاهده گزارش گروه</a>{% endif %}{% if can_resolve_dispatch and group_open_count %}<form method="post" action="{% url 'staff_tickets:dispatch_resolve' ticket.group_dispatch.public_id %}" onsubmit="return confirm('هر {{ group_open_count }} تیکت باز این ارسال گروهی حلشده میشوند. آیا مطمئن هستید؟')">{% csrf_token %}<input type="hidden" name="return_ticket" value="{{ ticket.public_id }}"><button type="submit">✓ حلشدن تمامی تیکتهای گروه ({{ group_open_count }})</button></form>{% endif %}</div>
|
||||
<button type="button" class="ticket-dispatch-context-close" data-dismiss-dispatch-context aria-label="بستن پیام ارسال گروهی" title="بستن">×</button>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,17 +1,88 @@
|
||||
{% extends 'base.html' %}{% load static %}
|
||||
{% extends 'base.html' %}{% load static jalali %}
|
||||
{% block title %}{{ page_title }}{% endblock %}
|
||||
{% block content %}<div class="operations-page operations-dispatch">
|
||||
<section class="operations-hero"><div><p class="eyebrow">ارسال انبوه امن</p><h1>ارسال گروهی ایزوله</h1><p>برای هر مخاطب یک تیکت و Magic Link مستقل ایجاد میشود؛ هیچ دریافتکنندهای پاسخ دیگران را نمیبیند.</p></div><div class="security-callout"><span>⌾</span><div><b>حریم خصوصی فعال</b><small>تیکت مستقل برای هر گیرنده</small></div></div></section>
|
||||
<section class="operations-metrics dispatch-metrics" aria-label="آمار ارسالهای گروهی"><article><small>کل عملیات</small><b>{{ metrics.total }}</b></article><article><small>کل گیرندگان</small><b>{{ metrics.recipients }}</b></article><article><small>ارسال کامل</small><b>{{ metrics.sent }}</b></article><article><small>نیازمند پیگیری</small><b>{{ metrics.attention }}</b></article></section>
|
||||
<div class="dispatch-layout"><form method="post" enctype="multipart/form-data" class="dispatch-composer" data-disable-on-submit data-ticket-composer>{% csrf_token %}{% if form.non_field_errors %}<div class="create-form-errors" role="alert">{{ form.non_field_errors }}</div>{% endif %}
|
||||
<section><header><span>۱</span><div><h2>نوع عملیات، مخاطبان و مالکیت</h2><p>قابلیتهای مجاز و گروه هدف را مشخص کنید.</p></div></header><div class="editor-grid"><label><span>{{ form.operation_type.label }}</span>{{ form.operation_type }}{{ form.operation_type.errors }}</label><label><span>{{ form.unit.label }}</span>{{ form.unit }}{{ form.unit.errors }}</label><label><span>{{ form.contact_group.label }}</span>{{ form.contact_group }}{{ form.contact_group.errors }}<small>فقط گروههای واحد انتخابشده معتبر هستند.</small></label></div></section>
|
||||
<section><header><span>۲</span><div><h2>موضوع و دستهبندی</h2><p>عنوان کوتاه و برچسبهای مناسب، گزارشگیری را دقیقتر میکند.</p></div></header><div class="editor-grid"><label><span>{{ form.category.label }}</span>{{ form.category }}{{ form.category.errors }}</label><label><span>{{ form.subject.label }}</span>{{ form.subject }}{{ form.subject.errors }}</label><label class="editor-wide"><span>{{ form.tags.label }}</span>{{ form.tags }}{{ form.tags.errors }}</label></div></section>
|
||||
<section><header><span>۳</span><div><h2>متن و اسناد پیام</h2><p>درخواست را شفاف بنویسید و اسناد لازم را برای همه تیکتهای خصوصی ضمیمه کنید.</p></div></header><label class="message-editor"><span>{{ form.body.label }}</span>{{ form.body }}{{ form.body.errors }}</label><div class="dispatch-attachment-picker media-picker"><div class="attachment-picker-copy"><span class="attachment-icon" aria-hidden="true">+</span><div><b>{{ form.files.label }}</b><small>{{ form.files.help_text }}</small></div></div><div class="media-picker-actions"><label class="media-picker-button gallery-button" for="dispatch-gallery-input">▣ افزودن عکس از گالری</label><label class="media-picker-button" for="dispatch-file-input">+ افزودن فایل</label></div><input id="dispatch-gallery-input" type="file" accept="image/*" multiple data-gallery-input><input id="dispatch-file-input" type="file" multiple data-document-input><span class="canonical-files">{{ form.files }}</span>{{ form.files.errors }}<small class="compression-note" data-compression-status>تصاویر بزرگ پیش از ارسال روی دستگاه شما بهینه میشوند؛ هر پیوست را میتوانید پیش از ثبت حذف کنید.</small><div class="file-preview" data-file-preview aria-live="polite" aria-label="پیشنمایش پیوستهای انتخابشده"></div></div></section>
|
||||
<footer><div><b>قبل از ارسال بررسی کنید</b><small>این عملیات برای همه اعضای گروه تیکت مستقل ایجاد میکند.</small></div><button type="submit">ایجاد تیکتها و قرار دادن پیامک در صف ←</button></footer>
|
||||
</form><aside class="dispatch-history"><header><div><small>ردیابی عملیات</small><h2>ارسالهای اخیر</h2></div><span>{{ page.paginator.count }}</span></header>
|
||||
<form method="get" class="dispatch-filter" aria-label="فیلتر ارسالها"><label class="dispatch-search"><span>جستجو</span><input type="search" name="q" value="{{ request.GET.q }}" placeholder="موضوع، گروه یا کارشناس"></label><div><label><span>وضعیت</span><select name="status"><option value="">همه وضعیتها</option>{% for value,label in dispatch_statuses %}<option value="{{ value }}" {% if request.GET.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><label><span>واحد</span><select name="unit"><option value="">همه واحدها</option>{% for item in units %}<option value="{{ item.pk }}" {% if request.GET.unit == item.pk|stringformat:'s' %}selected{% endif %}>{{ item.name }}</option>{% endfor %}</select></label></div><label><span>گروه مخاطب</span><select name="group"><option value="">همه گروهها</option>{% for item in groups %}<option value="{{ item.pk }}" {% if request.GET.group == item.pk|stringformat:'s' %}selected{% endif %}>{{ item.unit.name }} — {{ item.name }}</option>{% endfor %}</select></label><div><label><span>از تاریخ</span><input type="date" name="created_from" value="{{ request.GET.created_from }}"></label><label><span>تا تاریخ</span><input type="date" name="created_to" value="{{ request.GET.created_to }}"></label></div><footer><button type="submit">اعمال فیلتر</button><a href="{% url 'staff_tickets:dispatch_create' %}">پاک کردن</a></footer></form>
|
||||
<div class="dispatch-results">{% for item in dispatches %}<a class="dispatch-history-card" href="{% url 'staff_tickets:dispatch_detail' item.public_id %}"><span class="dispatch-state state-{{ item.status|lower }}"></span><div><b>{{ item.subject }}</b><small>{{ item.get_operation_type_display }} · {{ item.unit }} · {{ item.contact_group.name }}</small><p><span>{{ item.get_status_display }}</span><span>{{ item.recipient_count }} گیرنده</span><span>{{ item.created_at|date:'Y/m/d H:i' }}</span></p></div><em>←</em></a>{% empty %}<div class="empty-state"><span>⌕</span><p>ارسالی مطابق فیلترهای انتخابی پیدا نشد.</p></div>{% endfor %}</div>
|
||||
{% if page.paginator.num_pages > 1 %}<nav class="operations-pagination" aria-label="صفحهبندی ارسالها">{% if page.has_previous %}<a href="?q={{ request.GET.q|urlencode }}&status={{ request.GET.status|urlencode }}&unit={{ request.GET.unit|urlencode }}&group={{ request.GET.group|urlencode }}&created_from={{ request.GET.created_from|urlencode }}&created_to={{ request.GET.created_to|urlencode }}&page={{ page.previous_page_number }}">قبلی</a>{% endif %}<span>صفحه {{ page.number }} از {{ page.paginator.num_pages }}</span>{% if page.has_next %}<a href="?q={{ request.GET.q|urlencode }}&status={{ request.GET.status|urlencode }}&unit={{ request.GET.unit|urlencode }}&group={{ request.GET.group|urlencode }}&created_from={{ request.GET.created_from|urlencode }}&created_to={{ request.GET.created_to|urlencode }}&page={{ page.next_page_number }}">بعدی</a>{% endif %}</nav>{% endif %}
|
||||
</aside></div>
|
||||
</div>{% endblock %}
|
||||
{% block extra_scripts %}<script defer src="{% static 'vendor/compressor.min.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'js/attachment-uploader.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
|
||||
{% block extra_head %}<link rel="stylesheet" href="{% static 'css/dispatch-workspace.css' %}?v={{ static_asset_version }}">{% endblock %}
|
||||
{% block content %}
|
||||
<div class="operations-page operations-dispatch dispatch-workspace" {% if can_create_dispatch %}data-dispatch-workspace{% endif %}>
|
||||
<header class="dispatch-page-heading">
|
||||
<div><p class="dispatch-eyebrow">ارتباط با گروههای مخاطبان</p><h1>ارسال گروهی ایزوله</h1><p>{% if can_create_dispatch %}یک پیام مشترک بفرستید؛ پاسخ هر مخاطب را در تیکت خصوصی خودش پیگیری کنید.{% else %}سوابق ارسالهای مجاز و وضعیت پاسخ مخاطبان را پیگیری کنید.{% endif %}</p></div>
|
||||
<span class="dispatch-privacy">{% include 'tickets/_dispatch_icon.html' with icon='shield' %} پاسخها برای سایر گیرندگان نمایش داده نمیشوند</span>
|
||||
</header>
|
||||
<section class="dispatch-summary-strip" aria-label="آمار ارسالهای گروهی">
|
||||
<div><span>کل ارسالها</span><strong>{{ metrics.total }}</strong></div><div><span>کل گیرندگان</span><strong>{{ metrics.recipients }}</strong></div><div><span>ارسال به درگاه</span><strong>{{ metrics.sent }}</strong></div><div><span>در صف یا نیازمند پیگیری</span><strong>{{ metrics.attention }}</strong></div>
|
||||
</section>
|
||||
<nav class="dispatch-view-switch" aria-label="بخشهای ارسال گروهی">
|
||||
{% if can_create_dispatch %}<a id="dispatch-compose-tab" href="#dispatch-compose-panel" data-dispatch-tab="compose">{% include 'tickets/_dispatch_icon.html' with icon='send' %} ارسال جدید</a>{% endif %}
|
||||
<a id="dispatch-history-tab" href="#dispatch-history-panel" data-dispatch-tab="history">{% include 'tickets/_dispatch_icon.html' with icon='history' %} سوابق ارسال <span>{{ page.paginator.count }}</span></a>
|
||||
</nav>
|
||||
{% if can_create_dispatch %}<section id="dispatch-compose-panel" data-dispatch-panel="compose" aria-labelledby="dispatch-compose-tab">
|
||||
<div class="dispatch-compose-layout">
|
||||
<form id="dispatch-compose-form" method="post" enctype="multipart/form-data" class="dispatch-composer" data-disable-on-submit data-ticket-composer>
|
||||
{% csrf_token %}
|
||||
{% if form.errors %}<div class="dispatch-error-summary" role="alert" tabindex="-1"><b>ارسال انجام نشد؛ موارد زیر را بررسی کنید.</b>{{ form.non_field_errors }}<ul>{% for field in form %}{% for error in field.errors %}<li><a href="#{{ field.id_for_label }}">{{ field.label }}: {{ error }}</a></li>{% endfor %}{% endfor %}</ul>{% if request.FILES %}<p>پیوستها را دوباره انتخاب کنید.</p>{% endif %}</div>{% endif %}
|
||||
<section><header><span>۱</span><div><h2>گیرندگان پیام</h2><p>واحد و گروه هدف را انتخاب کنید.</p></div></header><div class="editor-grid">
|
||||
<label for="{{ form.operation_type.id_for_label }}"><span>{{ form.operation_type.label }}</span>{{ form.operation_type }}{{ form.operation_type.errors }}</label>
|
||||
<label for="{{ form.unit.id_for_label }}"><span>{{ form.unit.label }} <em>الزامی</em></span>{{ form.unit }}{{ form.unit.errors }}</label>
|
||||
<label class="editor-wide" for="{{ form.contact_group.id_for_label }}"><span>{{ form.contact_group.label }} <em>الزامی</em></span>{{ form.contact_group }}{{ form.contact_group.errors }}<small data-dispatch-group-hint aria-live="polite">فقط اعضای فعال دارای شماره همراه، پیام دریافت میکنند.</small></label>
|
||||
</div></section>
|
||||
<section><header><span>۲</span><div><h2>پیام شما</h2><p>موضوع روشن و متن مشترک برای تمام گیرندگان.</p></div></header><div class="editor-grid">
|
||||
<label class="editor-wide" for="{{ form.subject.id_for_label }}"><span>{{ form.subject.label }} <em>الزامی</em></span>{{ form.subject }}{{ form.subject.errors }}</label>
|
||||
<label class="editor-wide" for="{{ form.category.id_for_label }}"><span>{{ form.category.label }} <em>الزامی</em></span>{{ form.category }}{{ form.category.errors }}</label>
|
||||
</div>
|
||||
<label class="message-editor" for="{{ form.body.id_for_label }}"><span>{{ form.body.label }} <em>الزامی</em></span>{{ form.body }}{{ form.body.errors }}<small>این متن برای همه مشترک است؛ پاسخها در گفتوگوهای مستقل ثبت میشوند.</small></label>
|
||||
<details class="dispatch-tags" {% if form.tags.value %}open{% endif %}><summary>برچسبهای گزارشگیری <span>اختیاری</span></summary><label for="{{ form.tags.id_for_label }}">{{ form.tags.label }}{{ form.tags }}{{ form.tags.errors }}<small>برای انتخاب چند برچسب در رایانه، Ctrl یا Command را نگه دارید.</small></label></details>
|
||||
<div class="dispatch-attachment-picker media-picker">
|
||||
<div class="attachment-picker-copy">{% include 'tickets/_dispatch_icon.html' with icon='attachment' %}<div><b>{{ form.files.label }} <small>اختیاری</small></b><small>هر پیوست به تیکت خصوصی تمام گیرندگان اضافه میشود.</small></div></div>
|
||||
<div class="media-picker-actions"><label class="media-picker-button gallery-button" for="dispatch-gallery-input">افزودن عکس از گالری</label><label class="media-picker-button" for="dispatch-file-input">افزودن فایل</label></div>
|
||||
<input id="dispatch-gallery-input" type="file" accept="image/*" multiple data-gallery-input><input id="dispatch-file-input" type="file" multiple data-document-input><span class="canonical-files">{{ form.files }}</span>{{ form.files.errors }}
|
||||
<small class="compression-note" data-compression-status>انتخاب فایل به معنی ارسال نیست؛ پیوستها را پیش از ثبت میتوانید حذف کنید.</small><div class="file-preview" data-file-preview aria-live="polite" aria-label="پیشنمایش پیوستهای انتخابشده"></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="dispatch-response-options"><header><span>۳</span><div><h2>دسترسی به پاسخها</h2><p>روش مشاهده پاسخها پس از ایجاد ارسال قابل تغییر نیست.</p></div></header>
|
||||
<fieldset><legend>{{ form.response_mode.label }}</legend>{{ form.response_mode }}{{ form.response_mode.errors }}</fieldset>
|
||||
<p>در حالت یکجا، متن و پیوست پاسخها برای همه کارکنان، حتی admin، تا بازگشایی قفل میماند. هر مخاطب پاسخ خودش را میبیند.</p>
|
||||
<label class="dispatch-sms-choice">{{ form.notify_opening_sms }} <span>{{ form.notify_opening_sms.label }}</span></label>
|
||||
<small data-opening-sms-hint aria-live="polite">فقط در حالت یکجا و برای حداکثر ۱۵ گیرندهٔ واجد شرایط مجاز است.</small>
|
||||
</section>
|
||||
<footer><div><b>ابتدا بررسی، سپس ارسال</b><small>قبل از تأیید نهایی، پیامک یا تیکتی ایجاد نمیشود.</small></div><button type="submit" data-dispatch-send>ایجاد تیکتها و قرار دادن پیامک در صف</button></footer>
|
||||
</form>
|
||||
<aside class="dispatch-compose-guide" aria-label="خلاصه ارسال جدید">
|
||||
<div class="dispatch-guide-icon">{% include 'tickets/_dispatch_icon.html' with icon='people' %}</div><h2>پیش از ارسال</h2><p>گیرندگان و پیام را بررسی کنید؛ هر فرد یک مسیر اختصاصی برای پاسخ خواهد داشت.</p>
|
||||
<dl><div><dt>واحد ارسالکننده</dt><dd data-dispatch-unit-summary>انتخاب نشده</dd></div><div><dt>گروه مخاطبان</dt><dd data-dispatch-group-summary>انتخاب نشده</dd></div><div><dt>گیرندگان واجد شرایط</dt><dd data-dispatch-count-summary>پس از انتخاب گروه</dd></div></dl>
|
||||
<p class="dispatch-guide-note">{% include 'tickets/_dispatch_icon.html' with icon='shield' %} اطلاعات سایر اعضا در تیکت مخاطب نمایش داده نمیشود.</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}<section id="dispatch-history-panel" class="dispatch-history" data-dispatch-panel="history" aria-labelledby="dispatch-history-tab">
|
||||
<header><div><h2>سوابق و پیگیری ارسالها</h2><p>هر ارسال را باز کنید تا وضعیت پیامکها و پاسخ مخاطبان را ببینید.</p></div><span>{{ page.paginator.count }} ارسال</span></header>
|
||||
<form method="get" class="dispatch-filter" aria-label="فیلتر ارسالها">
|
||||
<label class="dispatch-search"><span>جستجو</span><input type="search" name="q" value="{{ request.GET.q }}" placeholder="موضوع، گروه یا نام کاربری کارشناس"></label>
|
||||
<label><span>وضعیت ارسال</span><select name="status"><option value="">همه وضعیتها</option>{% for value,label in dispatch_statuses %}<option value="{{ value }}" {% if request.GET.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
|
||||
<details class="dispatch-more-filters" {% if request.GET.unit or request.GET.group or request.GET.created_from or request.GET.created_to %}open{% endif %}><summary>فیلترهای بیشتر</summary><div>
|
||||
<label><span>واحد</span><select name="unit"><option value="">همه واحدها</option>{% for item in history_units %}<option value="{{ item.pk }}" {% if request.GET.unit == item.pk|stringformat:'s' %}selected{% endif %}>{{ item.name }}</option>{% endfor %}</select></label>
|
||||
<label><span>گروه مخاطب</span><select name="group"><option value="">همه گروهها</option>{% for item in history_groups %}<option value="{{ item.pk }}" {% if request.GET.group == item.pk|stringformat:'s' %}selected{% endif %}>{{ item.unit.name }} — {{ item.name }}</option>{% endfor %}</select></label>
|
||||
<label><span>از تاریخ (میلادی)</span><input type="date" name="created_from" value="{{ request.GET.created_from }}"></label><label><span>تا تاریخ (میلادی)</span><input type="date" name="created_to" value="{{ request.GET.created_to }}"></label>
|
||||
</div></details>
|
||||
<footer><button type="submit">اعمال فیلتر</button><a href="{% url 'staff_tickets:dispatch_create' %}#dispatch-history-panel">پاک کردن فیلترها</a></footer>
|
||||
</form>
|
||||
<div class="dispatch-results">{% for item in dispatches %}
|
||||
<a class="dispatch-history-card" href="{% url 'staff_tickets:dispatch_detail' item.public_id %}">
|
||||
<div class="dispatch-history-main"><b>{{ item.subject }}</b><small>{{ item.get_operation_type_display }} · {{ item.unit }} · {{ item.contact_group.name }}</small></div>
|
||||
<span class="report-status status-{{ item.status|lower }}">{{ item.get_status_display }}</span><span class="dispatch-history-count">{{ item.recipient_count }} گیرنده</span>
|
||||
<time datetime="{{ item.created_at|date:'c' }}">{{ item.created_at|jalali_datetime }}</time><span class="dispatch-history-open">مشاهده گزارش {% include 'tickets/_dispatch_icon.html' with icon='arrow' %}</span>
|
||||
</a>
|
||||
{% empty %}<div class="dispatch-empty">{% include 'tickets/_dispatch_icon.html' with icon='history' %}<h3>{% if metrics.total %}ارسالی با این فیلترها پیدا نشد{% else %}هنوز ارسال گروهی ثبت نشده است{% endif %}</h3><p>{% if metrics.total %}عبارت جستجو یا فیلترها را تغییر دهید.{% else %}از بخش «ارسال جدید» اولین پیام گروهی را آماده کنید.{% endif %}</p></div>{% endfor %}</div>
|
||||
{% if page.paginator.num_pages > 1 %}<nav class="operations-pagination" aria-label="صفحهبندی ارسالها">{% if page.has_previous %}<a href="?q={{ request.GET.q|urlencode }}&status={{ request.GET.status|urlencode }}&unit={{ request.GET.unit|urlencode }}&group={{ request.GET.group|urlencode }}&created_from={{ request.GET.created_from|urlencode }}&created_to={{ request.GET.created_to|urlencode }}&page={{ page.previous_page_number }}#dispatch-history-panel">قبلی</a>{% endif %}<span>صفحه {{ page.number }} از {{ page.paginator.num_pages }}</span>{% if page.has_next %}<a href="?q={{ request.GET.q|urlencode }}&status={{ request.GET.status|urlencode }}&unit={{ request.GET.unit|urlencode }}&group={{ request.GET.group|urlencode }}&created_from={{ request.GET.created_from|urlencode }}&created_to={{ request.GET.created_to|urlencode }}&page={{ page.next_page_number }}#dispatch-history-panel">بعدی</a>{% endif %}</nav>{% endif %}
|
||||
</section>
|
||||
<dialog class="dispatch-review-dialog" aria-labelledby="dispatch-review-title" data-dispatch-review>
|
||||
<header><div><p class="dispatch-eyebrow">هنوز ارسال نشده است</p><h2 id="dispatch-review-title">بررسی نهایی ارسال گروهی</h2></div><button type="button" class="dispatch-dialog-close" aria-label="بستن پیشنمایش" data-dispatch-review-close>×</button></header>
|
||||
<dl class="dispatch-review-meta"><div><dt>واحد</dt><dd data-review-unit></dd></div><div><dt>گروه</dt><dd data-review-group></dd></div><div><dt>تعداد گیرندگان</dt><dd data-review-count></dd></div><div><dt>پیوستها</dt><dd data-review-files></dd></div></dl>
|
||||
<p data-review-response-policy></p>
|
||||
<aside class="dispatch-inquiry-recommendation" data-review-inquiry-recommendation hidden role="note"><strong>توصیه برای استعلامگیری</strong><p>برای تمامی استعلامگیریها توصیه میکنیم «بازگشایی یکجا» و «اطلاعرسانی بازگشایی با پیامک» را فعال کنید؛ این دو گزینه به شفافیت و اعتماد مخاطبان کمک میکنند.</p><p data-review-inquiry-limit hidden>در گروههای بیش از ۱۵ گیرنده، پیامک بازگشایی مجاز نیست؛ بازگشایی یکجا را فعال نگه دارید.</p></aside>
|
||||
<h3 data-review-subject></h3><p class="dispatch-review-body" data-review-body></p>
|
||||
<p class="dispatch-review-warning">با تأیید، برای هر گیرنده یک تیکت خصوصی ایجاد و پیامک در صف ارسال قرار میگیرد. تعداد نهایی بر اساس اعضای واجد شرایط در لحظه ثبت محاسبه میشود.</p>
|
||||
<footer><button type="button" class="dispatch-secondary" data-dispatch-review-close>بازگشت و ویرایش</button><button type="button" class="dispatch-primary" data-dispatch-confirm>تأیید و ارسال گروهی</button></footer>
|
||||
</dialog>
|
||||
{{ dispatch_options|json_script:'dispatch-options' }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_scripts %}<script defer src="{% static 'vendor/compressor.min.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'js/attachment-uploader.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'js/dispatch-workspace.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
|
||||
@@ -1,83 +1,83 @@
|
||||
{% extends 'base.html' %}{% load jalali %}
|
||||
{% extends 'base.html' %}{% load static jalali %}
|
||||
{% block title %}{{ dispatch.subject }}{% endblock %}
|
||||
{% block extra_head %}<link rel="stylesheet" href="{% static 'css/dispatch-workspace.css' %}?v={{ static_asset_version }}">{% endblock %}
|
||||
{% block content %}
|
||||
<div class="operations-page operations-dispatch-detail">
|
||||
<section class="dispatch-report-hero">
|
||||
<div class="dispatch-report-title">
|
||||
<div class="dispatch-title-line"><span class="report-status status-{{ dispatch.status|lower }}">{{ dispatch.get_status_display }}</span><span class="eyebrow">گزارش ارسال گروهی</span></div>
|
||||
<h1>{{ dispatch.subject }}</h1>
|
||||
<div class="dispatch-meta-list">
|
||||
<span><b>واحد</b>{{ dispatch.unit }}</span>
|
||||
<span><b>گروه مخاطبان</b>{{ dispatch.contact_group.name }}</span>
|
||||
<span><b>تعداد گیرندگان</b>{{ dispatch.recipient_count }} نفر</span>
|
||||
<span><b>زمان ایجاد</b><time datetime="{{ dispatch.created_at|date:'c' }}">{{ dispatch.created_at|jalali_datetime }}</time></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dispatch-head-actions">
|
||||
<a class="button" href="{% url 'staff_tickets:dispatch_create' %}">+ ارسال گروهی جدید</a>
|
||||
{% if metrics.failed %}<form method="post" action="{% url 'staff_tickets:dispatch_retry' dispatch.public_id %}">{% csrf_token %}<button class="button button-danger-soft" type="submit">↻ تلاش مجدد {{ metrics.failed }} پیامک</button></form>{% endif %}
|
||||
{% if can_resolve_dispatch and open_ticket_count %}<form method="post" action="{% url 'staff_tickets:dispatch_resolve' dispatch.public_id %}" onsubmit="return confirm('با تأیید این عملیات، هر {{ open_ticket_count }} تیکت باز این ارسال گروهی یکجا حلشده میشوند. آیا ادامه میدهید؟')">{% csrf_token %}<button class="button dispatch-resolve-button" type="submit">✓ حلشدن همه تیکتها ({{ open_ticket_count }})</button></form>{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if can_resolve_dispatch %}<aside class="dispatch-resolution-warning" role="note"><span aria-hidden="true">!</span><div><b>{% if open_ticket_count %}پایان رسیدگی گروهی{% else %}رسیدگی این گروه پایان یافته است{% endif %}</b><p>{% if open_ticket_count %}دکمه «حلشدن همه تیکتها» وضعیت {{ open_ticket_count }} تیکت باز این عملیات را همزمان تغییر میدهد. پاسخ تازه مشتری میتواند تیکت مربوط به خودش را دوباره باز کند.{% else %}همه تیکتهای این عملیات اکنون حل یا بسته هستند.{% endif %}</p></div></aside>{% endif %}
|
||||
|
||||
<section class="dispatch-original-message" aria-labelledby="dispatch-message-title">
|
||||
<header><div><span class="panel-index">متن اولیه کارشناس</span><h2 id="dispatch-message-title">پیام اصلی این ارسال</h2></div><span>شناسه عملیات: <b dir="ltr">{{ dispatch.public_id }}</b></span></header>
|
||||
<blockquote>{{ dispatch.body|linebreaksbr }}</blockquote>
|
||||
<footer><span>ایجادکننده: <b>{{ dispatch.created_by.staff_profile.display_name|default:dispatch.created_by.username }}</b></span><span>این متن در یک تیکت مستقل برای هر مخاطب ثبت شده است.</span></footer>
|
||||
</section>
|
||||
|
||||
<section class="delivery-overview" aria-label="خلاصه دلیوری">
|
||||
<div class="delivery-progress-copy"><div><span class="panel-index">پیشرفت تحویل</span><strong>{{ metrics.delivery_percent }}٪</strong></div><p>{{ metrics.delivered }} از {{ dispatch.recipient_count }} پیامک تحویل قطعی شده است.</p></div>
|
||||
<div class="delivery-progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ metrics.delivery_percent }}"><span style="width:{{ metrics.delivery_percent }}%"></span></div>
|
||||
<div class="delivery-overview-note">{% if metrics.tracking %}<b>{{ metrics.tracking }} پیامک در حال پیگیری است</b><span>نتیجههای موقت هر ۳ دقیقه تا ۴۵ دقیقه دوباره استعلام میشوند.</span>{% elif metrics.failed %}<b>موارد ناموفق نیاز به بررسی دارند</b><span>علت دقیق و دکمه تلاش مجدد در ردیف مخاطب قرار دارد.</span>{% else %}<b>پردازش صف پایان یافته است</b><span>برای جزئیات هر مخاطب، گزارش پایین صفحه را ببینید.</span>{% endif %}</div>
|
||||
</section>
|
||||
|
||||
<div class="operations-page operations-dispatch-detail dispatch-workspace" data-dispatch-report>
|
||||
<nav class="dispatch-breadcrumb" aria-label="مسیر صفحه"><a href="{% url 'staff_tickets:dispatch_create' %}#dispatch-history-panel">{% include 'tickets/_dispatch_icon.html' with icon='arrow' %} سوابق ارسال گروهی</a><span>گزارش و پاسخها</span></nav>
|
||||
<header class="dispatch-report-hero">
|
||||
<div class="dispatch-report-title"><div class="dispatch-title-line"><span class="dispatch-eyebrow">{{ dispatch.get_operation_type_display }}</span><span class="report-status status-{{ dispatch.status|lower }}">{{ dispatch.get_status_display }}</span></div><h1>{{ dispatch.subject }}</h1>
|
||||
<div class="dispatch-meta-list"><span><b>واحد</b>{{ dispatch.unit }}</span><span><b>گروه</b>{{ dispatch.contact_group.name }}</span><span><b>گیرندگان</b>{{ dispatch.recipient_count }} نفر</span><span><b>ایجاد</b><time datetime="{{ dispatch.created_at|date:'c' }}">{{ dispatch.created_at|jalali_datetime }}</time></span></div>
|
||||
</div><a class="dispatch-secondary" href="{% url 'staff_tickets:dispatch_create' %}">{% include 'tickets/_dispatch_icon.html' with icon='send' %} ارسال گروهی جدید</a>
|
||||
</header>
|
||||
{% if response_control %}<section class="dispatch-response-banner" aria-label="وضعیت بازگشایی پاسخها">
|
||||
{% if responses_locked %}<h2>پاسخها هنوز بازگشایی نشدهاند</h2><p>{{ metrics.replied }} مخاطب پاسخ دادهاند. متن و پیوست پاسخها برای همه کارکنان، حتی admin، تا بازگشایی قفل است.</p>
|
||||
{% if can_open_responses %}<a class="dispatch-primary" href="{% url 'staff_tickets:dispatch_open_responses' dispatch.public_id %}">بازگشایی همه پاسخها</a>{% else %}<p>بازگشایی توسط ایجادکننده یا کاربر دارای مجوز انجام میشود.</p>{% endif %}
|
||||
{% elif response_control.opened_at %}<h2>پاسخها بازگشایی شدهاند</h2><p>{{ response_control.opened_at|jalali_datetime_precise }} — توسط {{ response_control.opened_by.get_full_name|default:response_control.opened_by.username }}</p><p>پاسخهای جدید و ویرایشهای بعدی برای کاربران مجاز قابل مشاهدهاند.</p>
|
||||
{% for item in opening_jobs %}<p>پیامک بازگشایی: {{ item.count }} مورد — {% if item.status == 'PENDING' %}در صف{% elif item.status == 'SENT' %}ارسالشده{% elif item.status == 'FAILED' %}ناموفق{% elif item.status == 'RETRYING' %}در انتظار تلاش مجدد{% elif item.status == 'PROCESSING' %}در حال ارسال{% else %}لغوشده{% endif %}</p>{% endfor %}
|
||||
{% else %}<h2>مشاهده جداگانه پاسخها</h2><p>هر پاسخ پس از ثبت برای کاربران مجاز قابل مشاهده است. پیامک بازگشایی غیرفعال است.</p>{% endif %}
|
||||
</section>{% endif %}
|
||||
<section class="dispatch-kpi-grid" aria-label="شاخصهای ارسال گروهی">
|
||||
<article class="dispatch-kpi kpi-success"><span class="kpi-icon">✓</span><div><small>تحویل قطعی</small><strong>{{ metrics.delivered }}</strong><p>رسیده به گوشی</p></div></article>
|
||||
<article class="dispatch-kpi kpi-info"><span class="kpi-icon">◌</span><div><small>پیگیری فعال</small><strong>{{ metrics.tracking }}</strong><p>{{ metrics.sent }} مورد پذیرفتهشده توسط درگاه</p></div></article>
|
||||
<article class="dispatch-kpi kpi-danger"><span class="kpi-icon">!</span><div><small>نیازمند اقدام</small><strong>{{ metrics.failed }}</strong><p>ناموفق یا تحویلنشده قطعی</p></div></article>
|
||||
<article class="dispatch-kpi kpi-view"><span class="kpi-icon">◉</span><div><small>تعامل با لینک</small><strong>{{ metrics.opened }}</strong><p>{{ metrics.total_views }} بازدید · {{ metrics.not_opened }} مخاطب بدون بازدید</p></div></article>
|
||||
<article class="dispatch-kpi kpi-reply"><span class="kpi-icon">↵</span><div><small>پاسخ دریافتی</small><strong>{{ metrics.replied }}</strong><p>پاسخ ثبتشده در تیکتها</p></div></article>
|
||||
<article class="dispatch-kpi kpi-success">{% include 'tickets/_dispatch_icon.html' with icon='check' %}<div><small>تحویل به گوشی</small><strong>{{ metrics.delivered }}</strong><p>تحویل قطعی پیامک</p></div></article>
|
||||
<article class="dispatch-kpi kpi-info">{% include 'tickets/_dispatch_icon.html' with icon='history' %}<div><small>در صف ارسال</small><strong>{{ metrics.pending }}</strong><p>{{ metrics.tracking }} پیامک در پیگیری تحویل</p></div></article>
|
||||
<article class="dispatch-kpi kpi-danger">{% include 'tickets/_dispatch_icon.html' %}<div><small>نیازمند اقدام</small><strong>{{ metrics.failed }}</strong><p>ناموفق یا تحویل تأییدنشده</p></div></article>
|
||||
<article class="dispatch-kpi kpi-view">{% include 'tickets/_dispatch_icon.html' with icon='eye' %}<div><small>مخاطبان بازدیدکننده</small><strong>{{ metrics.opened }}</strong><p>{{ metrics.total_views }} بازدید از لینکها</p></div></article>
|
||||
<article class="dispatch-kpi kpi-reply">{% include 'tickets/_dispatch_icon.html' with icon='reply' %}<div><small>پاسخ دریافتی</small><strong>{{ metrics.replied }}</strong><p>مخاطب دارای پاسخ</p></div></article>
|
||||
</section>
|
||||
|
||||
<section class="ticket-stream dispatch-recipient-report">
|
||||
<header class="stream-head"><div><span class="panel-index">جزئیات هر گیرنده</span><h2>وضعیت پیامک، مشاهده و پاسخ</h2></div><span>{{ dispatch.recipient_count }} مخاطب</span></header>
|
||||
<div class="table-scroll"><table class="dispatch-detail-table"><thead><tr><th>مخاطب</th><th>تیکت</th><th>وضعیت پیامک</th><th>وضعیت تیکت</th><th>مشاهده لینک</th><th>آخرین پاسخ</th></tr></thead><tbody>
|
||||
<section class="delivery-overview" aria-label="خلاصه تحویل پیامک">
|
||||
<div class="delivery-progress-copy"><div><span>تحویل قطعی پیامک</span><strong>{{ metrics.delivery_percent }}٪</strong></div><p>{{ metrics.delivered }} از {{ dispatch.recipient_count }} پیامک به گوشی رسیده است.</p></div>
|
||||
<div class="delivery-progress-track" role="progressbar" aria-label="درصد تحویل قطعی پیامک" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ metrics.delivery_percent }}"><span style="width:{{ metrics.delivery_percent }}%"></span></div>
|
||||
<div class="delivery-overview-note">{% if metrics.pending %}<b>{{ metrics.pending }} پیامک در صف ارسال است</b>{% elif metrics.tracking %}<b>{{ metrics.tracking }} پیامک در حال پیگیری تحویل است</b>{% elif metrics.failed %}<b>موارد ناموفق را بررسی کنید</b>{% else %}<b>وضعیت تحویل در گزارش هر مخاطب</b>{% endif %}<span>تحویل پیامک به معنی مشاهده لینک یا دریافت پاسخ نیست.</span></div>
|
||||
</section>
|
||||
<section class="dispatch-original-message" aria-labelledby="dispatch-message-title">
|
||||
<header><div><span class="dispatch-eyebrow">متن اولیه کارشناس</span><h2 id="dispatch-message-title">پیام اصلی این ارسال</h2></div><span>ایجادکننده: <b>{{ dispatch.created_by.staff_profile.display_name|default:dispatch.created_by.username }}</b></span></header>
|
||||
<blockquote>{{ dispatch.body|linebreaksbr }}</blockquote>
|
||||
<details class="dispatch-operation-details"><summary>اطلاعات عملیات</summary><dl><div><dt>شناسه عملیات</dt><dd dir="ltr">{{ dispatch.public_id }}</dd></div><div><dt>دستهبندی</dt><dd>{{ dispatch.category }}</dd></div></dl></details>
|
||||
</section>
|
||||
<section class="dispatch-recipient-report" aria-labelledby="dispatch-recipients-title">
|
||||
<header class="stream-head"><div><h2 id="dispatch-recipients-title">وضعیت پیامک، مشاهده و پاسخ</h2><p>پاسخهای هر مخاطب همراه با زمان ثبت و پیوستها در همین گزارش نمایش داده میشوند.</p></div><span>{{ dispatch.recipient_count }} مخاطب</span></header>
|
||||
<div class="dispatch-recipient-tools" hidden data-recipient-tools>
|
||||
<label><span>جستجوی مخاطب</span><input type="search" data-recipient-search placeholder="نام، شماره همراه یا شماره تیکت"></label>
|
||||
<label><span>نمایش مخاطبان</span><select data-recipient-filter><option value="all">همه مخاطبان</option><option value="unanswered">بدون پاسخ</option><option value="answered">دارای پاسخ</option><option value="unopened">بدون بازدید لینک</option><option value="opened">دارای بازدید لینک</option><option value="retryable">پیامک نیازمند اقدام</option><option value="active">تیکتهای باز</option></select></label>
|
||||
<button type="button" class="dispatch-secondary" data-recipient-reset>پاک کردن فیلتر</button><p role="status" data-recipient-result-count></p>
|
||||
</div>
|
||||
<div class="table-scroll"><table class="dispatch-detail-table"><caption class="dispatch-sr-only">گیرندگان این ارسال، وضعیت پیامک و تمامی پاسخهای هر مخاطب</caption><thead><tr><th scope="col">مخاطب و تیکت</th><th scope="col">وضعیت پیامک</th><th scope="col">مشاهده لینک</th><th scope="col">پاسخها و زمان ثبت</th></tr></thead><tbody>
|
||||
{% for ticket in tickets %}
|
||||
<tr>
|
||||
<td data-label="مخاطب"><div class="recipient-cell"><span class="recipient-avatar">{{ ticket.customer.customer_profile.first_name|first|default:'•' }}</span><div><strong>{{ ticket.customer.customer_profile|default:ticket.customer.username }}</strong><small dir="ltr">{{ ticket.customer.local_mobile }}</small></div></div></td>
|
||||
<td data-label="تیکت"><a class="ticket-reference" href="{% url 'staff_tickets:detail' ticket.public_id %}"><strong dir="ltr">{{ ticket.ticket_number }}</strong><span>مشاهده تیکت ←</span></a></td>
|
||||
<tr data-recipient-row data-answered="{{ ticket.response_count|yesno:'true,false' }}" data-opened="{{ ticket.magic_total_views|yesno:'true,false' }}" data-retryable="{{ ticket.sms_retryable|yesno:'true,false' }}" data-active="{% if ticket.status == 'RESOLVED' or ticket.status == 'CLOSED' %}false{% else %}true{% endif %}" data-search="{{ ticket.customer.customer_profile|default:ticket.customer.username }} {{ ticket.customer.local_mobile }} {{ ticket.ticket_number }}">
|
||||
<td data-label="مخاطب و تیکت"><div class="recipient-cell"><span class="recipient-avatar" aria-hidden="true">{{ ticket.customer.customer_profile.first_name|first|default:'•' }}</span><div><strong>{{ ticket.customer.customer_profile|default:ticket.customer.username }}</strong><small dir="ltr">{{ ticket.customer.local_mobile }}</small></div></div>{% if response_control.mode == 'SEALED' %}<span class="ticket-reference"><strong dir="ltr">{{ ticket.ticket_number }}</strong></span>{% else %}<a class="ticket-reference" href="{% url 'staff_tickets:detail' ticket.public_id %}"><strong dir="ltr">{{ ticket.ticket_number }}</strong></a>{% endif %}<span class="ticket-state-pill state-{{ ticket.status|lower }}">{{ ticket.get_status_display }}</span></td>
|
||||
<td data-label="وضعیت پیامک" class="dispatch-sms-state">
|
||||
<div class="sms-state-card tone-{{ ticket.sms_state_tone }}">
|
||||
<span class="sms-state-icon">{% if ticket.sms_state_tone == 'success' %}✓{% elif ticket.sms_state_tone == 'danger' %}!{% elif ticket.sms_state_tone == 'warning' %}◌{% else %}↑{% endif %}</span>
|
||||
<div><strong>{{ ticket.sms_state_title }}</strong><p>{{ ticket.sms_state_hint }}</p>{% if ticket.sms_job.delivery_next_check_at %}<small>استعلام بعدی: {{ ticket.sms_job.delivery_next_check_at|timeuntil }} دیگر</small>{% endif %}</div>
|
||||
</div>
|
||||
{% if ticket.sms_job %}
|
||||
<details class="sms-technical-details">
|
||||
<summary>جزئیات فنی و رهگیری</summary>
|
||||
<dl>
|
||||
<div><dt>ارسال</dt><dd>{% if ticket.sms_job.attempts == 1 %}موفق در اولین تلاش{% else %}{{ ticket.sms_job.attempts }} تلاش از {{ sms_max_attempts }}{% endif %}</dd></div>
|
||||
{% if ticket.sms_job.provider_message_id %}<div><dt>شناسه رهگیری</dt><dd dir="ltr">{{ ticket.sms_job.provider_message_id }}</dd></div>{% endif %}
|
||||
{% if ticket.sms_job.delivery_status_code %}<div><dt>کد دلیوری</dt><dd dir="ltr">{{ ticket.sms_job.delivery_status_code }}</dd></div>{% endif %}
|
||||
{% if ticket.sms_job.delivery_check_attempts %}<div><dt>تعداد استعلام</dt><dd>{{ ticket.sms_job.delivery_check_attempts }} نوبت</dd></div>{% endif %}
|
||||
{% if ticket.sms_job.delivery_checked_at %}<div><dt>آخرین استعلام</dt><dd>{{ ticket.sms_job.delivery_checked_at|jalali_datetime }}</dd></div>{% endif %}
|
||||
</dl>
|
||||
{% if ticket.sms_job.delivery_description %}<p class="gateway-technical-message">{{ ticket.sms_job.delivery_description }}</p>{% endif %}
|
||||
</details>
|
||||
{% if ticket.sms_job.last_error %}<div class="sms-error-panel"><b>علت دقیق عدم ارسال</b><p>{{ ticket.sms_job.last_error }}</p></div>{% endif %}
|
||||
{% if ticket.sms_retryable %}<form method="post" action="{% url 'staff_tickets:dispatch_retry' dispatch.public_id %}" class="inline-retry-form">{% csrf_token %}<input type="hidden" name="job_id" value="{{ ticket.sms_job.pk }}"><button type="submit">↻ تلاش مجدد همین پیامک</button></form>{% endif %}
|
||||
{% endif %}
|
||||
<div class="sms-state-card tone-{{ ticket.sms_state_tone }}"><span class="sms-state-icon">{% if ticket.sms_state_tone == 'success' %}{% include 'tickets/_dispatch_icon.html' with icon='check' %}{% elif ticket.sms_state_tone == 'warning' %}{% include 'tickets/_dispatch_icon.html' with icon='history' %}{% else %}{% include 'tickets/_dispatch_icon.html' %}{% endif %}</span><div><strong>{{ ticket.sms_state_title }}</strong><p>{{ ticket.sms_state_hint }}</p>{% if ticket.sms_job.delivery_next_check_at %}<small>استعلام بعدی: {{ ticket.sms_job.delivery_next_check_at|timeuntil }} دیگر</small>{% endif %}</div></div>
|
||||
{% if ticket.sms_job %}<details class="sms-technical-details"><summary>جزئیات فنی و رهگیری</summary><dl>
|
||||
<div><dt>ارسال</dt><dd>{% if ticket.sms_job.status == 'SENT' and ticket.sms_job.attempts == 1 %}موفق در اولین تلاش{% else %}{{ ticket.sms_job.attempts }} تلاش از {{ sms_max_attempts }}{% endif %}</dd></div>
|
||||
{% if ticket.sms_job.provider_message_id %}<div><dt>شناسه رهگیری</dt><dd dir="ltr">{{ ticket.sms_job.provider_message_id }}</dd></div>{% endif %}
|
||||
{% if ticket.sms_job.delivery_status_code %}<div><dt>کد تحویل</dt><dd dir="ltr">{{ ticket.sms_job.delivery_status_code }}</dd></div>{% endif %}
|
||||
{% if ticket.sms_job.delivery_check_attempts %}<div><dt>تعداد استعلام</dt><dd>{{ ticket.sms_job.delivery_check_attempts }} نوبت</dd></div>{% endif %}
|
||||
{% if ticket.sms_job.delivery_checked_at %}<div><dt>آخرین استعلام</dt><dd>{{ ticket.sms_job.delivery_checked_at|jalali_datetime }}</dd></div>{% endif %}
|
||||
</dl>{% if ticket.sms_job.delivery_description %}<p class="gateway-technical-message">{{ ticket.sms_job.delivery_description }}</p>{% endif %}</details>
|
||||
{% if ticket.sms_job.last_error %}<div class="sms-error-panel"><b>علت عدم ارسال</b><p>{{ ticket.sms_job.last_error }}</p></div>{% endif %}
|
||||
{% if ticket.sms_retryable %}<form method="post" action="{% url 'staff_tickets:dispatch_retry' dispatch.public_id %}" class="inline-retry-form">{% csrf_token %}<input type="hidden" name="job_id" value="{{ ticket.sms_job.pk }}"><button type="submit">تلاش مجدد همین پیامک</button></form>{% endif %}{% endif %}
|
||||
</td>
|
||||
<td data-label="وضعیت تیکت"><span class="ticket-state-pill state-{{ ticket.status|lower }}">{{ ticket.get_status_display }}</span></td>
|
||||
<td data-label="مشاهده لینک">
|
||||
<div class="engagement-state {% if ticket.magic_total_views %}has-engagement{% endif %}"><span>{% if ticket.magic_total_views %}◉{% else %}○{% endif %}</span><div>{% if ticket.magic_total_views %}<strong>{{ ticket.magic_total_views }} بازدید</strong>{% if ticket.magic_last_used_at %}<small>آخرین بازدید {{ ticket.magic_last_used_at|jalali_datetime }}</small>{% endif %}{% elif ticket.magic_link_count %}<strong>مشاهده نشده</strong><small>{{ ticket.magic_active_count }} لینک فعال</small>{% else %}<strong>لینک صادر نشده</strong>{% endif %}</div></div>
|
||||
<td data-label="مشاهده لینک"><div class="engagement-state {% if ticket.magic_total_views %}has-engagement{% endif %}">{% include 'tickets/_dispatch_icon.html' with icon='eye' %}<div>{% if ticket.magic_total_views %}<strong>{{ ticket.magic_total_views }} بازدید</strong>{% if ticket.magic_last_used_at %}<small>آخرین بازدید {{ ticket.magic_last_used_at|jalali_datetime }}</small>{% endif %}{% elif ticket.magic_link_count %}<strong>مشاهده نشده</strong><small>{{ ticket.magic_active_count }} لینک فعال</small>{% else %}<strong>لینک صادر نشده</strong>{% endif %}</div></div></td>
|
||||
<td data-label="پاسخها و زمان ثبت">
|
||||
<p><b>پاسخ داده است: {{ ticket.response_count|yesno:'بله,خیر' }}</b></p>
|
||||
{% if ticket.response_count %}<p>{{ ticket.response_count }} پاسخ</p><small>اولین پاسخ: {{ ticket.first_response_at|jalali_datetime_precise }}<br>آخرین پاسخ: {{ ticket.last_response_at|jalali_datetime_precise }}</small>
|
||||
{% if responses_locked %}<p class="no-reply-state">متن و پیوست پاسخ تا بازگشایی محرمانه است.</p>
|
||||
{% else %}{% for answer in ticket.report_customer_responses %}<article class="recipient-reply" aria-label="پاسخ {{ forloop.counter }}">
|
||||
<time datetime="{{ answer.created_at|date:'c' }}">{{ answer.created_at|jalali_datetime_precise }}</time>
|
||||
{% if answer.body %}<p>{{ answer.body|linebreaksbr }}</p>{% else %}<p>پاسخ دارای پیوست</p>{% endif %}
|
||||
{% if answer.attachments.all %}<div class="dispatch-response-files">{% for file in answer.attachments.all %}<a href="{% url 'attachments:view' file.public_id %}" target="_blank" rel="noopener">{{ file.original_name }} · {{ file.file_size|filesizeformat }}<span class="dispatch-sr-only"> (باز کردن پیوست در پنجره جدید)</span></a>{% endfor %}</div>{% endif %}
|
||||
</article>{% endfor %}{% endif %}
|
||||
{% else %}<p class="no-reply-state">هنوز پاسخی ثبت نشده</p>{% endif %}
|
||||
{% if response_control.mode != 'SEALED' %}<a class="dispatch-reply-link" href="{% url 'staff_tickets:detail' ticket.public_id %}" aria-label="مشاهده و پاسخ به {{ ticket.customer.customer_profile|default:ticket.customer.username }}" title="مشاهده و پاسخ">{% include 'tickets/_dispatch_icon.html' with icon='reply' %}</a>{% endif %}
|
||||
</td>
|
||||
<td data-label="آخرین پاسخ">{% if ticket.latest_customer_response %}<div class="recipient-reply"><p>{{ ticket.latest_customer_response.body|truncatechars:140 }}</p><small>{{ ticket.latest_customer_response.created_at|jalali_datetime }}</small></div>{% else %}<span class="no-reply-state">هنوز پاسخی ثبت نشده</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% empty %}<tr><td colspan="6">برای این ارسال تیکتی ثبت نشده است.</td></tr>{% endfor %}
|
||||
{% empty %}<tr><td colspan="4"><div class="dispatch-empty"><p>برای این ارسال تیکتی ثبت نشده است.</p></div></td></tr>{% endfor %}
|
||||
</tbody></table></div>
|
||||
<div class="dispatch-empty" data-recipient-empty hidden><h3>مخاطبی با این شرایط پیدا نشد</h3><p>عبارت جستجو یا فیلتر مخاطبان را تغییر دهید.</p><button type="button" class="dispatch-secondary" data-recipient-reset>نمایش همه مخاطبان</button></div>
|
||||
</section>
|
||||
{% if metrics.failed or can_resolve_dispatch %}<section class="dispatch-management" aria-label="اقدامهای گروهی"><div><h2>اقدامهای گروهی</h2><p>این اقدامها روی چند مخاطب اثر میگذارند؛ برای پاسخ به یک نفر، تیکت او را باز کنید.</p></div><div class="dispatch-management-actions">
|
||||
{% if metrics.failed %}<form method="post" action="{% url 'staff_tickets:dispatch_retry' dispatch.public_id %}">{% csrf_token %}<button class="dispatch-secondary" type="submit">تلاش مجدد {{ metrics.failed }} پیامک ناموفق</button></form>{% endif %}
|
||||
{% if can_resolve_dispatch %}{% if open_ticket_count %}<form method="post" action="{% url 'staff_tickets:dispatch_resolve' dispatch.public_id %}" onsubmit="return confirm('با تأیید این عملیات، هر {{ open_ticket_count }} تیکت باز این ارسال گروهی یکجا حلشده میشوند. آیا ادامه میدهید؟')">{% csrf_token %}<button class="dispatch-secondary" type="submit">حلشدن همه تیکتها ({{ open_ticket_count }})</button><small>پاسخ تازه مشتری میتواند تیکت مربوط به خودش را دوباره باز کند.</small></form>{% else %}<p class="dispatch-complete">{% include 'tickets/_dispatch_icon.html' with icon='check' %} رسیدگی این گروه پایان یافته است؛ همه تیکتها حل یا بسته هستند.</p>{% endif %}{% endif %}
|
||||
</div></section>{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_scripts %}<script defer src="{% static 'js/dispatch-workspace.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from tests.e2e.pages.auth import AuthenticationPage
|
||||
|
||||
|
||||
class CustomerLoginAccessPage(AuthenticationPage):
|
||||
def request_otp(self, mobile):
|
||||
self.goto(reverse("accounts:login"))
|
||||
self.page.get_by_label("شماره موبایل").fill(mobile)
|
||||
self.page.get_by_role("button", name="دریافت کد یکبارمصرف").click()
|
||||
|
||||
def assert_block_reason(self, reason):
|
||||
alert = self.page.get_by_role("alert")
|
||||
expect(alert).to_contain_text("ورود به حساب شما مسدود است")
|
||||
expect(alert).to_contain_text(reason)
|
||||
expect(alert).to_be_visible()
|
||||
assert alert.locator(".customer-login-block-reason").evaluate(
|
||||
"node => getComputedStyle(node).color"
|
||||
) == "rgb(153, 27, 27)"
|
||||
assert self.page.evaluate("document.documentElement.scrollWidth <= innerWidth + 1")
|
||||
|
||||
def assert_logged_out(self, reason):
|
||||
expect(self.page).to_have_url(self.url(reverse("accounts:login")), timeout=20000)
|
||||
self.assert_block_reason(reason)
|
||||
@@ -0,0 +1,89 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from tests.e2e.pages.base import BasePage
|
||||
|
||||
|
||||
class DispatchWorkspacePage(BasePage):
|
||||
@property
|
||||
def review_dialog(self):
|
||||
return self.page.get_by_role("dialog", name="بررسی نهایی ارسال گروهی")
|
||||
|
||||
def _activate(self, control, destination, *, hidden=False):
|
||||
control.click()
|
||||
if hidden:
|
||||
expect(destination).to_be_hidden()
|
||||
else:
|
||||
expect(destination).to_be_visible()
|
||||
|
||||
def open_composer(self):
|
||||
self.goto(reverse("staff_tickets:dispatch_create"))
|
||||
self.page.get_by_role("tab", name="ارسال جدید").wait_for(state="visible")
|
||||
|
||||
def open_history(self):
|
||||
self.page.get_by_role("tab", name="سوابق ارسال").click()
|
||||
|
||||
def return_to_composer(self):
|
||||
self.page.get_by_role("tab", name="ارسال جدید").click()
|
||||
|
||||
def compose(self, unit, group, category, subject, body):
|
||||
self.page.get_by_label("واحد ارسالکننده").select_option(str(unit.pk))
|
||||
self.page.get_by_label("گروه مخاطبان", exact=False).select_option(str(group.pk))
|
||||
self.page.get_by_label("دستهبندی", exact=False).select_option(str(category.pk))
|
||||
self.page.get_by_label("موضوع استعلام یا پیام", exact=False).fill(subject)
|
||||
self.page.get_by_label("متن پیام", exact=False).fill(body)
|
||||
|
||||
def review(self):
|
||||
self._activate(self.page.get_by_role("button", name="بررسی و ادامه"), self.review_dialog)
|
||||
|
||||
def cancel_review(self):
|
||||
self._activate(self.review_dialog.get_by_role("button", name="بازگشت و ویرایش"), self.review_dialog, hidden=True)
|
||||
|
||||
def send(self):
|
||||
self._activate(
|
||||
self.review_dialog.get_by_role("button", name="تأیید و ارسال گروهی"),
|
||||
self.page.get_by_role("heading", name="وضعیت پیامک، مشاهده و پاسخ"),
|
||||
)
|
||||
|
||||
def open_report(self, dispatch):
|
||||
self.goto(reverse("staff_tickets:dispatch_detail", args=[dispatch.public_id]))
|
||||
|
||||
def filter_recipients(self, label):
|
||||
self.page.get_by_label("نمایش مخاطبان").select_option(label=label)
|
||||
|
||||
def search_recipient(self, text):
|
||||
self.page.get_by_role("searchbox", name="جستجوی مخاطب").fill(text)
|
||||
|
||||
def reset_recipients(self):
|
||||
self.page.get_by_role("button", name="پاک کردن فیلتر", exact=True).click()
|
||||
|
||||
def visible_recipients(self):
|
||||
return self.page.locator("[data-recipient-row]:visible")
|
||||
|
||||
def open_conversation(self):
|
||||
self.page.locator(".dispatch-reply-link").first.click()
|
||||
|
||||
def review_opening(self):
|
||||
self._activate(self.page.get_by_role("link", name="بازگشایی همه پاسخها", exact=True),
|
||||
self.page.get_by_role("heading", name="تأیید بازگشایی همه پاسخها"))
|
||||
|
||||
def confirm_opening(self):
|
||||
self._activate(self.page.get_by_role("button", name="تأیید و بازگشایی همه پاسخها"),
|
||||
self.page.get_by_role("heading", name="پاسخها بازگشایی شدهاند"))
|
||||
|
||||
def choose_response_mode(self, label):
|
||||
self.page.get_by_role("radio", name=label, exact=True).check()
|
||||
|
||||
@property
|
||||
def opening_sms(self):
|
||||
return self.page.get_by_role("checkbox", name="اطلاعرسانی بازگشایی با پیامک", exact=True)
|
||||
|
||||
def expect_fits_viewport(self):
|
||||
assert self.page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
|
||||
|
||||
def save_screenshot(self, name):
|
||||
directory = Path("test-results/dispatch-design")
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
self.page.screenshot(path=str(directory / name), full_page=True)
|
||||
@@ -5,6 +5,13 @@ from .base import BasePage
|
||||
|
||||
|
||||
class StaffPaymentsPage(BasePage):
|
||||
@property
|
||||
def daily_summary(self):
|
||||
return self.page.get_by_role('region', name='وضعیت امروز')
|
||||
|
||||
def open_summary_definitions(self):
|
||||
self.daily_summary.locator('summary').click()
|
||||
|
||||
def open_desk(self, parameters=''):
|
||||
self.goto(reverse('staff_payments:desk') + parameters)
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from tests.e2e.pages.base import BasePage
|
||||
|
||||
|
||||
class StaffShortLinkPage(BasePage):
|
||||
def open_and_login(self, path, staff, password, ticket):
|
||||
self.goto(path)
|
||||
expect(self.page.get_by_role("heading", name="ورود کارشناسان")).to_be_visible()
|
||||
self.page.locator('input[name="username"]').fill(staff.username)
|
||||
self.page.locator('input[name="password"]').fill(password)
|
||||
self.page.get_by_role("button", name="ورود به میز کار").click()
|
||||
expect(self.page).to_have_url(self.url(reverse("staff_tickets:detail", args=[ticket.public_id])))
|
||||
expect(self.page.get_by_text(ticket.subject, exact=True).first).to_be_visible()
|
||||
@@ -0,0 +1,44 @@
|
||||
import pytest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from django.db import connections
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.customers.models import CustomerProfile
|
||||
from tests.e2e.pages.customer_login_access import CustomerLoginAccessPage
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
|
||||
REASON = "به علت بدهی معوق، ورود شما تا بررسی واحد مالی مسدود شده است."
|
||||
PASSWORD = "Customer-access-e2e-2026!"
|
||||
|
||||
|
||||
def block_customer(profile_id):
|
||||
try:
|
||||
profile = CustomerProfile.objects.get(pk=profile_id)
|
||||
profile.login_blocked = True
|
||||
profile.login_block_reason = REASON
|
||||
profile.save(update_fields=["login_blocked", "login_block_reason"])
|
||||
finally:
|
||||
connections.close_all()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width", [320, 1280])
|
||||
def test_block_reason_is_clear_and_red_on_mobile_and_desktop(live_server, browser_page, width):
|
||||
user = User.objects.create_user(username="blocked-ui", mobile="09121234566", password=PASSWORD)
|
||||
CustomerProfile.objects.create(user=user, login_blocked=True, login_block_reason=REASON)
|
||||
with browser_page() as page:
|
||||
page.set_viewport_size({"width": width, "height": 850})
|
||||
access = CustomerLoginAccessPage(page, live_server.url)
|
||||
access.request_otp(user.local_mobile)
|
||||
access.assert_block_reason(REASON)
|
||||
|
||||
|
||||
def test_open_customer_browser_logs_out_without_interaction(live_server, browser_page):
|
||||
user = User.objects.create_user(username="idle-ui", mobile="09121234566", password=PASSWORD)
|
||||
profile = CustomerProfile.objects.create(user=user)
|
||||
with browser_page() as page:
|
||||
access = CustomerLoginAccessPage(page, live_server.url)
|
||||
access.login_customer(user, PASSWORD)
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
executor.submit(block_customer, profile.pk).result()
|
||||
access.assert_logged_out(REASON)
|
||||
@@ -0,0 +1,105 @@
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from apps.messaging.models import Message
|
||||
from apps.notifications.models import SMSTemplate
|
||||
from apps.tickets.services import dispatch_group_tickets
|
||||
from tests.e2e.pages.auth import AuthenticationPage
|
||||
from tests.e2e.pages.dispatches import DispatchWorkspacePage
|
||||
from tests.e2e.test_dispatch_workspace_e2e import dispatch_case, PASSWORD
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width", [1440, 390, 320])
|
||||
def test_sealed_report_requires_confirmation_and_opens_answers_together(live_server, browser_page, dispatch_case, width):
|
||||
staff, unit, group, category, _, customers = dispatch_case
|
||||
# Transactional live-server tests flush data migrations between tests.
|
||||
SMSTemplate.objects.create(unit=unit, event_type="DISPATCH_RESPONSES_OPENED", title="بازگشایی", body="متن دقیق پیامک بازگشایی برای آزمون")
|
||||
dispatch, tickets = dispatch_group_tickets(user=staff, unit=unit, contact_group=group,
|
||||
category=category, subject="استعلام محرمانه مرورگر", body="پیشنهاد خود را ثبت کنید.")
|
||||
answer = "پیشنهاد محرمانه مخاطب اول " * 12 + "پایان کامل پیشنهاد"
|
||||
Message.objects.create(ticket=tickets[0], author=customers[0], body=answer)
|
||||
second_answer = Message.objects.create(ticket=tickets[0], author=customers[0], body="توضیح تکمیلی پیشنهاد اول")
|
||||
Message.objects.create(ticket=tickets[1], author=customers[1], body="پیشنهاد مخاطب دوم")
|
||||
with browser_page() as page:
|
||||
page.set_viewport_size({"width": width, "height": 900})
|
||||
AuthenticationPage(page, live_server.url).login_staff(staff, PASSWORD)
|
||||
workspace = DispatchWorkspacePage(page, live_server.url)
|
||||
workspace.open_report(dispatch)
|
||||
expect(page.get_by_role("heading", name="پاسخها هنوز بازگشایی نشدهاند")).to_be_visible()
|
||||
expect(page.locator("body")).not_to_contain_text(answer)
|
||||
expect(page.locator('[data-recipient-row] a[href*="/staff/tickets/"]')).to_have_count(0)
|
||||
workspace.filter_recipients("دارای پاسخ")
|
||||
expect(workspace.visible_recipients()).to_have_count(2)
|
||||
expect(workspace.visible_recipients().first).to_contain_text("پاسخ داده است: بله")
|
||||
workspace.expect_fits_viewport()
|
||||
workspace.save_screenshot(f"sealed-report-{width}.png")
|
||||
workspace.review_opening()
|
||||
expect(page.locator(".opening-sms-preview")).to_have_text("متن دقیق پیامک بازگشایی برای آزمون")
|
||||
expect(page.locator("body")).not_to_contain_text(answer)
|
||||
workspace.expect_fits_viewport()
|
||||
workspace.save_screenshot(f"opening-confirm-{width}.png")
|
||||
workspace.confirm_opening()
|
||||
expect(page.locator("body")).to_contain_text(answer)
|
||||
expect(page.locator("body")).to_contain_text(second_answer.body)
|
||||
expect(page.locator("body")).to_contain_text("پیشنهاد مخاطب دوم")
|
||||
expect(page.locator(".recipient-reply")).to_have_count(3)
|
||||
expect(page.locator('[data-recipient-row] a[href*="/staff/tickets/"]')).to_have_count(0)
|
||||
from apps.core.templatetags.jalali import jalali_datetime_precise
|
||||
expect(page.locator(".recipient-reply time").nth(1)).to_have_text(jalali_datetime_precise(second_answer.created_at))
|
||||
workspace.expect_fits_viewport()
|
||||
workspace.save_screenshot(f"opened-report-{width}.png")
|
||||
|
||||
|
||||
def test_composer_defaults_to_sealed_and_disables_sms_in_individual_mode(live_server, browser_page, dispatch_case):
|
||||
staff, unit, group, category, _, _ = dispatch_case
|
||||
with browser_page() as page:
|
||||
AuthenticationPage(page, live_server.url).login_staff(staff, PASSWORD)
|
||||
workspace = DispatchWorkspacePage(page, live_server.url)
|
||||
workspace.open_composer()
|
||||
workspace.compose(unit, group, category, "پیشفرض پاسخ محرمانه", "متن درخواست")
|
||||
expect(page.get_by_role("radio", name="بازگشایی یکجا", exact=True)).to_be_checked()
|
||||
expect(workspace.opening_sms).to_be_checked()
|
||||
workspace.review()
|
||||
expect(workspace.review_dialog.locator('[data-review-inquiry-recommendation]')).to_be_hidden()
|
||||
workspace.cancel_review()
|
||||
workspace.choose_response_mode("مشاهده جداگانه")
|
||||
expect(workspace.opening_sms).to_be_disabled()
|
||||
expect(workspace.opening_sms).not_to_be_checked()
|
||||
workspace.review()
|
||||
expect(workspace.review_dialog).to_contain_text("مشاهده جداگانه؛ پیامک بازگشایی: غیرفعال")
|
||||
expect(workspace.review_dialog.locator('[data-review-inquiry-recommendation]')).to_be_visible()
|
||||
expect(workspace.review_dialog).to_contain_text("برای تمامی استعلامگیریها توصیه میکنیم")
|
||||
workspace.cancel_review()
|
||||
workspace.choose_response_mode("بازگشایی یکجا")
|
||||
expect(workspace.opening_sms).to_be_checked()
|
||||
workspace.opening_sms.uncheck()
|
||||
workspace.choose_response_mode("مشاهده جداگانه")
|
||||
workspace.choose_response_mode("بازگشایی یکجا")
|
||||
expect(workspace.opening_sms).not_to_be_checked()
|
||||
workspace.review()
|
||||
expect(workspace.review_dialog.locator('[data-review-inquiry-recommendation]')).to_be_visible()
|
||||
workspace.save_screenshot("review-inquiry-recommendation.png")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [15, 16])
|
||||
def test_composer_enforces_opening_sms_recipient_cap(live_server, browser_page, dispatch_case, count):
|
||||
from tests.test_organizational_ticketing import make_contact
|
||||
staff, unit, group, category, _, _ = dispatch_case
|
||||
for i in range(2, count):
|
||||
make_contact(f"browser-limit-{i}", f"09122100{i:03}").customer_profile.groups.add(group)
|
||||
with browser_page() as page:
|
||||
AuthenticationPage(page, live_server.url).login_staff(staff, PASSWORD)
|
||||
workspace = DispatchWorkspacePage(page, live_server.url)
|
||||
workspace.open_composer()
|
||||
workspace.compose(unit, group, category, "آزمون سقف پیامک مرورگر", "متن استعلام")
|
||||
if count == 15:
|
||||
expect(workspace.opening_sms).to_be_enabled()
|
||||
expect(workspace.opening_sms).to_be_checked()
|
||||
else:
|
||||
expect(workspace.opening_sms).to_be_disabled()
|
||||
expect(workspace.opening_sms).not_to_be_checked()
|
||||
expect(page.locator('[data-opening-sms-hint]')).to_contain_text("بیش از ۱۵")
|
||||
workspace.review()
|
||||
expect(workspace.review_dialog.locator('[data-review-inquiry-limit]')).to_be_visible()
|
||||
@@ -0,0 +1,108 @@
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from apps.accounts.models import TicketMagicLink, User
|
||||
from apps.customers.models import ContactGroup, CustomerProfile
|
||||
from apps.messaging.models import Message
|
||||
from apps.notifications.models import NotificationJob, SMSTemplate
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from apps.tickets.models import TicketCategory
|
||||
from apps.tickets.services import create_group_dispatch
|
||||
from tests.e2e.pages.auth import AuthenticationPage
|
||||
from tests.e2e.pages.dispatches import DispatchWorkspacePage
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
|
||||
PASSWORD = "Dispatch-E2E-safe-2026"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dispatch_case():
|
||||
unit = OrganizationalUnit.objects.create(name="فروش رابط گروهی", slug="dispatch-ui")
|
||||
unit.permissions.add(*Permission.objects.filter(content_type__app_label="tickets", codename__in=("view_ticket", "change_ticket", "dispatch_group_tickets")))
|
||||
staff = User.objects.create_user(username="dispatch-ui-agent", password=PASSWORD, account_type="STAFF")
|
||||
StaffProfile.objects.create(user=staff, display_name="کارشناس ارسال", primary_unit=unit)
|
||||
group = ContactGroup.objects.create(unit=unit, name="مخاطبان منتخب فروش")
|
||||
empty_group = ContactGroup.objects.create(unit=unit, name="گروه بدون مخاطب")
|
||||
category = TicketCategory.objects.create(unit=unit, title="هماهنگی فروش", slug="dispatch-ui-category")
|
||||
customers = []
|
||||
for index, name in enumerate(("مخاطب پاسخدهنده", "مخاطب بدون پاسخ")):
|
||||
customer = User.objects.create_user(username=f"dispatch-ui-customer-{index}", mobile=f"0912000980{index}", account_type="CUSTOMER")
|
||||
CustomerProfile.objects.create(user=customer, first_name=name).groups.add(group)
|
||||
customers.append(customer)
|
||||
return staff, unit, group, category, empty_group, customers
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width", [1440, 390, 320])
|
||||
def test_review_preserves_draft_and_requires_confirmation_before_group_send(live_server, browser_page, dispatch_case, width):
|
||||
staff, unit, group, category, _, _ = dispatch_case
|
||||
subject = "هماهنگی زمان تحویل گروهی"
|
||||
body = "لطفاً زمان پیشنهادی تحویل را در گفتوگوی خصوصی اعلام کنید."
|
||||
with browser_page() as page:
|
||||
page.set_viewport_size({"width": width, "height": 900 if width == 1440 else 844})
|
||||
AuthenticationPage(page, live_server.url).login_staff(staff, PASSWORD)
|
||||
workspace = DispatchWorkspacePage(page, live_server.url)
|
||||
workspace.open_composer()
|
||||
workspace.compose(unit, group, category, subject, body)
|
||||
expect(page.get_by_label("خلاصه ارسال جدید")).to_contain_text("۲ نفر")
|
||||
workspace.open_history()
|
||||
expect(page.get_by_text("هنوز ارسال گروهی ثبت نشده است")).to_be_visible()
|
||||
workspace.return_to_composer()
|
||||
expect(page.get_by_label("متن پیام", exact=False)).to_have_value(body)
|
||||
workspace.review()
|
||||
dialog = page.get_by_role("dialog")
|
||||
expect(dialog).to_contain_text("۲ نفر")
|
||||
expect(dialog).to_contain_text(subject)
|
||||
workspace.cancel_review()
|
||||
expect(page.get_by_role("button", name="بررسی و ادامه")).to_be_enabled()
|
||||
workspace.save_screenshot(f"compose-{width}.png")
|
||||
workspace.review()
|
||||
workspace.save_screenshot(f"review-{width}.png")
|
||||
workspace.send()
|
||||
expect(page.locator("[data-recipient-row]")).to_have_count(2)
|
||||
expect(page.locator(".sms-state-card").first).to_contain_text("در صف ارسال")
|
||||
expect(page.get_by_text("تحویل پیامک به معنی مشاهده لینک یا دریافت پاسخ نیست.")).to_be_visible()
|
||||
workspace.expect_fits_viewport()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width", [1440, 390, 320])
|
||||
def test_report_filters_search_and_private_reply_navigation(live_server, browser_page, dispatch_case, width):
|
||||
staff, unit, group, category, _, customers = dispatch_case
|
||||
dispatch, tickets = create_group_dispatch(user=staff, unit=unit, contact_group=group, category=category, subject="پیگیری پاسخ مخاطبان منتخب", body="پیام اصلی با پاسخهای خصوصی مستقل.", response_mode="INDIVIDUAL")
|
||||
first = tickets[0]
|
||||
Message.objects.create(ticket=first, author=customers[0], body="زمان پیشنهادی ما ساعت ده صبح است.")
|
||||
first.last_customer_message_at = timezone.now()
|
||||
first.save(update_fields=["last_customer_message_at"])
|
||||
link = TicketMagicLink.issue_with_record(first, created_by=staff)[1]
|
||||
link.view_count = 2
|
||||
link.save(update_fields=["view_count"])
|
||||
NotificationJob.objects.filter(ticket=first, event_type=SMSTemplate.Event.STAFF_TICKET_CREATED).update(status="SENT", attempts=1, delivery_status="DELIVERED")
|
||||
NotificationJob.objects.filter(ticket=tickets[1], event_type=SMSTemplate.Event.STAFF_TICKET_CREATED).update(status="FAILED", attempts=1, last_error="خطای آزمایشی درگاه")
|
||||
with browser_page() as page:
|
||||
page.set_viewport_size({"width": width, "height": 900 if width == 1440 else 844})
|
||||
AuthenticationPage(page, live_server.url).login_staff(staff, PASSWORD)
|
||||
workspace = DispatchWorkspacePage(page, live_server.url)
|
||||
workspace.open_report(dispatch)
|
||||
expect(workspace.visible_recipients()).to_have_count(2)
|
||||
workspace.filter_recipients("دارای پاسخ")
|
||||
expect(workspace.visible_recipients()).to_have_count(1)
|
||||
expect(workspace.visible_recipients()).to_contain_text("زمان پیشنهادی ما ساعت ده صبح است.")
|
||||
workspace.reset_recipients()
|
||||
workspace.filter_recipients("پیامک نیازمند اقدام")
|
||||
expect(workspace.visible_recipients()).to_have_count(1)
|
||||
expect(workspace.visible_recipients()).to_contain_text("ارسال ناموفق")
|
||||
# One failed attempt is not a successful first attempt.
|
||||
expect(workspace.visible_recipients()).not_to_contain_text("موفق در اولین تلاش")
|
||||
workspace.reset_recipients()
|
||||
workspace.search_recipient("۰۹۱۲۰۰۰۹۸۰۰")
|
||||
expect(workspace.visible_recipients()).to_have_count(1)
|
||||
workspace.search_recipient("وجود ندارد")
|
||||
expect(page.get_by_role("heading", name="مخاطبی با این شرایط پیدا نشد")).to_be_visible()
|
||||
workspace.reset_recipients()
|
||||
workspace.expect_fits_viewport()
|
||||
workspace.save_screenshot(f"report-{width}.png")
|
||||
workspace.filter_recipients("دارای پاسخ")
|
||||
workspace.open_conversation()
|
||||
expect(page).to_have_url(workspace.url(reverse("staff_tickets:detail", args=[first.public_id])))
|
||||
@@ -0,0 +1,61 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.utils import timezone
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from apps.payments import services
|
||||
from apps.payments.models import MAX_AMOUNT
|
||||
from tests.e2e.pages.auth import AuthenticationPage
|
||||
from tests.e2e.pages.staff_payments import StaffPaymentsPage
|
||||
from tests.e2e.test_payments_e2e import PASSWORD, PNG, payment_case
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
|
||||
|
||||
|
||||
def test_daily_summary_mobile_desktop_and_filtered_empty_results(live_server, browser_page, payment_case, tmp_path):
|
||||
customer, account, reviewers = payment_case
|
||||
services.issue_notice(reviewers[0], {'customer': customer, 'account': account,
|
||||
'amount': 100000, 'subject': 'اعلامیه آزمایشی', 'description': '', 'deposit_id': ''})
|
||||
receipt = services.submit_receipt(customer, customer, {
|
||||
'paid_amount': MAX_AMOUNT, 'paid_date': timezone.localdate(),
|
||||
'destination_account': account, 'depositor_name': 'مشتری آزمایشی',
|
||||
'submission_key': uuid.uuid4(),
|
||||
'receipt_image': SimpleUploadedFile('receipt.png', PNG, content_type='image/png'),
|
||||
})
|
||||
services.decide_receipt(reviewers[0], receipt.public_id, 1, 'APPROVE')
|
||||
services.decide_receipt(reviewers[1], receipt.public_id, 1, 'APPROVE')
|
||||
with browser_page() as page:
|
||||
AuthenticationPage(page, live_server.url).login_staff(reviewers[0], PASSWORD)
|
||||
workspace = StaffPaymentsPage(page, live_server.url)
|
||||
workspace.open_desk()
|
||||
summary = workspace.daily_summary
|
||||
expect(summary).to_be_visible()
|
||||
expect(summary.locator('[data-summary=submitted]')).to_contain_text('1 فیش')
|
||||
expect(summary.locator('[data-summary=approved]')).to_contain_text('1 فیش')
|
||||
expect(summary.locator('[data-summary=rejected]')).to_contain_text('0 فیش')
|
||||
expect(summary.locator('[data-summary=pending]')).to_contain_text('0 فیش')
|
||||
expect(summary.locator('[data-summary=notices]')).to_contain_text('1 اعلامیه')
|
||||
amount = f'{MAX_AMOUNT:,}'
|
||||
expect(summary.locator('[data-summary=approved]')).to_contain_text(amount)
|
||||
for width, height in [(320, 568), (360, 640), (390, 844), (768, 1024), (1440, 900)]:
|
||||
page.set_viewport_size({'width': width, 'height': height})
|
||||
expect(summary).to_be_visible()
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
|
||||
assert summary.evaluate('(el) => el.scrollWidth <= el.clientWidth + 1')
|
||||
page.screenshot(path=tmp_path / f'payment-summary-{width}.png', full_page=True)
|
||||
page.set_viewport_size({'width': 320, 'height': 568})
|
||||
page.evaluate("document.documentElement.style.fontSize = '200%'")
|
||||
assert summary.evaluate('(el) => el.scrollWidth <= el.clientWidth + 1')
|
||||
expect(summary.locator('[data-summary=approved]')).to_contain_text(amount)
|
||||
workspace.open_summary_definitions()
|
||||
definitions = summary.locator('.pay-day-definitions')
|
||||
for title in ['ارسالی امروز:', 'تأیید امروز:', 'رد امروز:', 'در انتظار بررسی:', 'اعلامیه امروز:', 'قواعد مشترک:']:
|
||||
expect(definitions.get_by_text(title, exact=True)).to_be_visible()
|
||||
expect(definitions).not_to_contain_text('امروز بر اساس ساعت تهران است')
|
||||
page.evaluate("document.documentElement.style.fontSize = ''")
|
||||
workspace.search('no-such-customer')
|
||||
expect(page.get_by_text('موردی پیدا نشد', exact=True)).to_be_visible()
|
||||
expect(summary.locator('[data-summary=approved]')).to_contain_text('1 فیش')
|
||||
expect(summary.locator('[data-summary=approved]')).to_contain_text(amount)
|
||||
@@ -16,6 +16,7 @@ from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from apps.tickets.models import TicketCategory
|
||||
from apps.tickets.services import create_ticket, dispatch_group_tickets
|
||||
from tests.e2e.pages.auth import AuthenticationPage
|
||||
from tests.e2e.pages.dispatches import DispatchWorkspacePage
|
||||
from tests.e2e.pages.tickets import TicketConversationPage
|
||||
|
||||
|
||||
@@ -127,7 +128,10 @@ def test_staff_operations_pages_are_authorized_and_visually_styled(live_server,
|
||||
expect(page).to_have_title("ردیابی E2E")
|
||||
expect(page.locator(".staff-brand-copy b")).to_have_text("مرکز عملیات E2E")
|
||||
expect(page.locator(".staff-developer-credit")).to_contain_text("امضای مستقل sidebar E2E")
|
||||
workspace = DispatchWorkspacePage(page, live_server.url)
|
||||
workspace.open_history()
|
||||
expect(page.get_by_role("searchbox", name="جستجو")).to_be_visible()
|
||||
workspace.return_to_composer()
|
||||
expect(page.locator('link[href*="dispatches.css"]')).to_have_count(1)
|
||||
expect(page.get_by_text("افزودن عکس از گالری")).to_be_visible()
|
||||
expect(page.locator('label[for="dispatch-file-input"]')).to_be_visible()
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from apps.tickets.models import StaffTicketShortLink, TicketCategory
|
||||
from apps.tickets.services import create_ticket
|
||||
from apps.tickets.short_links import append_supervisor_ticket_link
|
||||
from tests.e2e.pages.staff_short_link import StaffShortLinkPage
|
||||
from tests.e2e.pages.tickets import TicketConversationPage
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
|
||||
|
||||
|
||||
def test_supervisor_short_link_login_and_reply(live_server, browser_page):
|
||||
password = "E2e-short-link-password-2026"
|
||||
unit = OrganizationalUnit.objects.create(
|
||||
name="واحد لینک مرورگر", slug="e2e-short-unit", supervisor_sms_ticket_link_enabled=True,
|
||||
)
|
||||
category = TicketCategory.objects.create(unit=unit, title="درخواست مرورگر", slug="e2e-short-category")
|
||||
customer = User.objects.create_user(username="e2e-short-customer", mobile="09121112340", account_type="CUSTOMER")
|
||||
staff = User.objects.create_user(
|
||||
username="e2e-short-staff", mobile="09121112341", password=password, account_type="STAFF",
|
||||
)
|
||||
StaffProfile.objects.create(user=staff, primary_unit=unit, display_name="کارشناس مرورگر")
|
||||
ticket = create_ticket(
|
||||
customer=customer, category=category, selected_expert=staff,
|
||||
subject="درخواست پاسخ از لینک کوتاه", body="پیام مشتری در مرورگر",
|
||||
)
|
||||
append_supervisor_ticket_link(ticket, "اعلان")
|
||||
link = StaffTicketShortLink.objects.get(ticket=ticket)
|
||||
path = reverse("staff_ticket_short_link", args=[link.token])
|
||||
with browser_page() as page:
|
||||
StaffShortLinkPage(page, live_server.url).open_and_login(path, staff, password, ticket)
|
||||
TicketConversationPage(page, live_server.url).send_reply("پاسخ کارشناس از لینک پیامک")
|
||||
@@ -0,0 +1,215 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import SESSION_KEY
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.accounts.models import OTPChallenge, User
|
||||
from apps.customers.models import CustomerProfile
|
||||
from apps.staff.models import StaffProfile
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
PASSWORD = "Customer-access-2026!"
|
||||
REASON = "به علت بدهی معوق، ورود شما تا بررسی واحد مالی مسدود شده است."
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def customer():
|
||||
user = User.objects.create_user(username="blocked-customer", mobile="09121234566", password=PASSWORD)
|
||||
CustomerProfile.objects.create(user=user)
|
||||
return user
|
||||
|
||||
|
||||
def block(user, reason=REASON):
|
||||
profile = CustomerProfile.objects.get(user=user)
|
||||
profile.login_blocked = True
|
||||
profile.login_block_reason = reason
|
||||
profile.save(update_fields=["login_blocked", "login_block_reason"])
|
||||
return profile
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stakeholder", CustomerProfile.StakeholderType.values)
|
||||
def test_blocked_customers_and_contacts_cannot_request_sms(client, customer, stakeholder):
|
||||
profile = block(customer)
|
||||
profile.stakeholder_type = stakeholder
|
||||
profile.save(update_fields=["stakeholder_type"])
|
||||
with patch("apps.accounts.views.get_sms_provider") as provider:
|
||||
response = client.post(reverse("accounts:login"), {"mobile": customer.local_mobile})
|
||||
assert response.status_code == 200
|
||||
assert REASON in response.content.decode()
|
||||
assert 'role="alert"' in response.content.decode()
|
||||
assert SESSION_KEY not in client.session
|
||||
assert not OTPChallenge.objects.exists()
|
||||
provider.assert_not_called()
|
||||
|
||||
|
||||
def test_password_login_is_blocked_and_reason_is_escaped(client, customer):
|
||||
block(customer, "علت مسدودی\n<script>alert('unsafe')</script>")
|
||||
response = client.post(reverse("accounts:password_login"), {"mobile": customer.local_mobile, "password": PASSWORD})
|
||||
content = response.content.decode()
|
||||
assert response.status_code == 200
|
||||
assert "علت مسدودی" in content
|
||||
assert "<script>" in content
|
||||
assert "<script>alert('unsafe')</script>" not in content
|
||||
assert SESSION_KEY not in client.session
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["get", "post"])
|
||||
def test_blocking_after_otp_issue_prevents_verification(client, customer, method):
|
||||
challenge, code = OTPChallenge.issue(customer.mobile)
|
||||
session = client.session
|
||||
session["otp_challenge_id"] = challenge.pk
|
||||
session.save()
|
||||
block(customer)
|
||||
response = getattr(client, method)(reverse("accounts:otp"), {"code": code}, follow=True)
|
||||
assert response.status_code == 200
|
||||
assert REASON in response.content.decode()
|
||||
assert SESSION_KEY not in client.session
|
||||
assert "otp_challenge_id" not in client.session
|
||||
|
||||
|
||||
@pytest.mark.parametrize("endpoint", ["accounts:profile", "tickets:list", "payments:home", "accounts:login", "accounts:login_access"])
|
||||
def test_blocking_logs_out_all_existing_sessions(client, customer, endpoint):
|
||||
other_browser = Client()
|
||||
client.force_login(customer)
|
||||
other_browser.force_login(customer)
|
||||
block(customer)
|
||||
for browser in (client, other_browser):
|
||||
response = browser.get(reverse(endpoint))
|
||||
if endpoint == "accounts:login_access":
|
||||
assert response.json()["authenticated"] is False
|
||||
assert "no-store" in response["Cache-Control"]
|
||||
else:
|
||||
assert response.status_code == 302
|
||||
assert response.url == reverse("accounts:login")
|
||||
assert SESSION_KEY not in browser.session
|
||||
assert REASON in browser.get(reverse("accounts:login")).content.decode()
|
||||
|
||||
|
||||
def test_blocked_session_cannot_write_customer_profile(client, customer):
|
||||
client.force_login(customer)
|
||||
block(customer)
|
||||
response = client.post(reverse("accounts:profile"), {"first_name": "تغییر غیرمجاز"})
|
||||
assert response.status_code == 302
|
||||
customer.customer_profile.refresh_from_db()
|
||||
assert customer.customer_profile.first_name == ""
|
||||
|
||||
|
||||
def test_unblocking_does_not_restore_an_old_session_but_allows_new_login(client, customer):
|
||||
client.force_login(customer)
|
||||
profile = block(customer)
|
||||
profile.login_blocked = False
|
||||
profile.save(update_fields=["login_blocked"])
|
||||
response = client.get(reverse("accounts:profile"))
|
||||
assert response.url == reverse("accounts:login")
|
||||
assert SESSION_KEY not in client.session
|
||||
login_page = client.get(response.url).content.decode()
|
||||
assert "نشست قبلی شما باطل شده است." in login_page
|
||||
assert "ورود به حساب شما مسدود است" not in login_page
|
||||
response = client.post(reverse("accounts:password_login"), {"mobile": customer.local_mobile, "password": PASSWORD})
|
||||
assert response.url == reverse("payments:home")
|
||||
assert SESSION_KEY in client.session
|
||||
assert client.get(reverse("accounts:profile")).status_code == 200
|
||||
|
||||
|
||||
def test_unblocked_customer_can_still_login_with_otp(client, customer):
|
||||
challenge, code = OTPChallenge.issue(customer.mobile)
|
||||
session = client.session
|
||||
session["otp_challenge_id"] = challenge.pk
|
||||
session.save()
|
||||
response = client.post(reverse("accounts:otp"), {"code": code})
|
||||
assert response.url == reverse("payments:home")
|
||||
assert SESSION_KEY in client.session
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["staff", "django_staff", "superuser"])
|
||||
def test_staff_and_admin_sessions_are_exempt_even_with_blocked_customer_profile(client, role):
|
||||
user = User.objects.create_user(
|
||||
username=role, password=PASSWORD, mobile="09129998877",
|
||||
account_type="STAFF" if role == "staff" else "CUSTOMER",
|
||||
is_staff=role == "django_staff", is_superuser=role == "superuser",
|
||||
)
|
||||
CustomerProfile.objects.create(user=user, login_blocked=True, login_block_reason=REASON)
|
||||
if role == "staff":
|
||||
StaffProfile.objects.create(user=user, display_name="کارشناس")
|
||||
response = client.post(reverse("accounts:staff_login"), {"username": user.username, "password": PASSWORD})
|
||||
assert response.url == reverse("staff_tickets:list")
|
||||
workspace = client.get(reverse("staff_tickets:list"))
|
||||
assert workspace.status_code == 200
|
||||
assert "js/customer-login-access.js" not in workspace.content.decode()
|
||||
else:
|
||||
client.force_login(user)
|
||||
assert client.get(reverse("accounts:login_access")).json()["authenticated"] is True
|
||||
assert SESSION_KEY in client.session
|
||||
response = client.get(reverse("accounts:login"))
|
||||
assert response.status_code == 302
|
||||
assert SESSION_KEY in client.session
|
||||
|
||||
|
||||
def test_only_admin_can_change_login_block_settings(client, customer):
|
||||
client.force_login(customer)
|
||||
client.post(reverse("accounts:profile"), {
|
||||
"first_name": "مشتری", "last_name": "آزمایشی", "customer_type": "PERSON",
|
||||
"login_blocked": "on", "login_block_reason": REASON,
|
||||
})
|
||||
profile = CustomerProfile.objects.get(user=customer)
|
||||
assert not profile.login_blocked
|
||||
assert profile.login_block_reason == ""
|
||||
|
||||
|
||||
def test_admin_requires_a_reason_and_can_block_and_unblock(client, customer):
|
||||
admin = User.objects.create_superuser(username="access-admin", password=PASSWORD)
|
||||
client.force_login(admin)
|
||||
profile = customer.customer_profile
|
||||
url = reverse("admin:customers_customerprofile_change", args=[profile.pk])
|
||||
data = {"user": customer.pk, "stakeholder_type": "CUSTOMER", "login_blocked": "on", "login_block_reason": " "}
|
||||
response = client.post(url, data)
|
||||
assert response.status_code == 200
|
||||
assert "برای مسدود کردن ورود، دلیل مسدودی را بنویسید." in response.content.decode()
|
||||
profile.refresh_from_db()
|
||||
assert not profile.login_blocked
|
||||
data["login_block_reason"] = REASON
|
||||
assert client.post(url, data).status_code == 302
|
||||
profile.refresh_from_db()
|
||||
assert profile.login_blocked and profile.login_block_reason == REASON
|
||||
assert profile.login_access_version == 1
|
||||
data.pop("login_blocked")
|
||||
assert client.post(url, data).status_code == 302
|
||||
profile.refresh_from_db()
|
||||
assert not profile.login_blocked
|
||||
assert profile.login_access_version == 1
|
||||
|
||||
|
||||
def test_legacy_session_without_access_version_is_revoked(client, customer):
|
||||
client.force_login(customer)
|
||||
session = client.session
|
||||
session.pop("customer_login_access_version")
|
||||
session.save()
|
||||
block(customer)
|
||||
assert client.get(reverse("accounts:profile")).url == reverse("accounts:login")
|
||||
assert SESSION_KEY not in client.session
|
||||
|
||||
|
||||
def test_missing_profile_does_not_prevent_customer_login(client):
|
||||
user = User.objects.create_user(username="no-profile", mobile="09120000111", password=PASSWORD)
|
||||
response = client.post(reverse("accounts:password_login"), {"mobile": user.local_mobile, "password": PASSWORD})
|
||||
assert response.url == reverse("payments:home")
|
||||
assert client.get(reverse("accounts:login_access")).json()["authenticated"] is True
|
||||
|
||||
|
||||
def test_login_block_does_not_change_scoped_magic_link_access(client, customer):
|
||||
from apps.accounts.models import TicketMagicLink
|
||||
from apps.tickets.models import TicketCategory
|
||||
from apps.tickets.services import create_ticket
|
||||
|
||||
category = TicketCategory.objects.create(title="فروش", slug="login-access-sale")
|
||||
ticket = create_ticket(customer=customer, category=category, subject="دسترسی لینک", body="متن")
|
||||
token = TicketMagicLink.issue(ticket)
|
||||
block(customer)
|
||||
response = client.get(reverse("accounts:magic", args=[token]), follow=True)
|
||||
assert response.status_code == 200
|
||||
assert "فقط به همین تیکت دسترسی دارید" in response.content.decode()
|
||||
assert SESSION_KEY not in client.session
|
||||
assert client.get(reverse("tickets:list")).status_code == 302
|
||||
@@ -0,0 +1,103 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
|
||||
import pytest
|
||||
from django.db import connection, connections
|
||||
from django.db.migrations.executor import MigrationExecutor
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.messaging.models import Message
|
||||
from apps.notifications.dispatch_opening import ensure_opening_template
|
||||
from apps.notifications.models import NotificationJob
|
||||
from apps.tickets.dispatch_responses import OPENING_EVENT, open_dispatch_responses, render_opening_sms
|
||||
from apps.tickets.models import DispatchResponseControl, GroupDispatch
|
||||
from apps.tickets.services import reply_ticket
|
||||
from tests.test_dispatch_response_opening import opening_case
|
||||
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
|
||||
def _postgres_only():
|
||||
if connection.vendor != "postgresql":
|
||||
pytest.skip("Real row-lock concurrency requires PostgreSQL")
|
||||
|
||||
|
||||
def test_two_simultaneous_openings_commit_one_event_and_one_outbox_job(opening_case):
|
||||
_postgres_only()
|
||||
c = opening_case
|
||||
ensure_opening_template()
|
||||
text = render_opening_sms(c["dispatch"])
|
||||
barrier = Barrier(2)
|
||||
def open_once(user_id):
|
||||
connections.close_all()
|
||||
try:
|
||||
user = User.objects.get(pk=user_id)
|
||||
dispatch = GroupDispatch.objects.get(pk=c["dispatch"].pk)
|
||||
barrier.wait(timeout=10)
|
||||
return open_dispatch_responses(dispatch, user, text)[1]
|
||||
finally:
|
||||
connections.close_all()
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
futures = [executor.submit(open_once, c[actor].pk) for actor in ("staff", "admin")]
|
||||
assert sorted(future.result(timeout=20) for future in futures) == [False, True]
|
||||
assert NotificationJob.objects.filter(event_type=OPENING_EVENT).count() == 1
|
||||
assert DispatchResponseControl.objects.get(dispatch=c["dispatch"]).opened_at
|
||||
|
||||
|
||||
def test_reply_racing_opening_matches_the_atomic_recipient_snapshot(opening_case):
|
||||
_postgres_only()
|
||||
c = opening_case
|
||||
ensure_opening_template()
|
||||
text = render_opening_sms(c["dispatch"])
|
||||
barrier = Barrier(2)
|
||||
def run(action):
|
||||
connections.close_all()
|
||||
try:
|
||||
barrier.wait(timeout=10)
|
||||
if action == "reply":
|
||||
return reply_ticket(ticket_id=c["tickets"][1].pk, user=User.objects.get(pk=c["customers"][1].pk), body="پاسخ همزمان")
|
||||
return open_dispatch_responses(c["dispatch"], User.objects.get(pk=c["staff"].pk), text)
|
||||
finally:
|
||||
connections.close_all()
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
futures = [executor.submit(run, action) for action in ("reply", "open")]
|
||||
for future in futures:
|
||||
future.result(timeout=20)
|
||||
control = DispatchResponseControl.objects.get(dispatch=c["dispatch"])
|
||||
expected = set(Message.objects.filter(ticket__group_dispatch=c["dispatch"], author_id__in=[u.pk for u in c["customers"]], created_at__lte=control.opened_at).values_list("author_id", flat=True))
|
||||
assert set(control.respondents_snapshot) == {str(value) for value in User.objects.filter(pk__in=expected).values_list("public_id", flat=True)}
|
||||
assert set(NotificationJob.objects.filter(event_type=OPENING_EVENT).values_list("customer_id", flat=True)) == expected
|
||||
|
||||
|
||||
def test_additive_migrations_preserve_existing_rows_and_legacy_response_visibility(opening_case):
|
||||
c = opening_case
|
||||
# Turn this fixture into a pre-feature dispatch before moving the test schema back.
|
||||
DispatchResponseControl.objects.filter(dispatch=c["dispatch"]).delete()
|
||||
before_dispatch = list(GroupDispatch.objects.values())
|
||||
before_messages = list(Message.objects.values())
|
||||
before_users = list(User.objects.values())
|
||||
old_targets = [("tickets", "0017_ticketaccessgrant_first_viewed_at_and_more"), ("notifications", "0020_receipt_rejection_template")]
|
||||
executor = MigrationExecutor(connection)
|
||||
current_targets = executor.loader.graph.leaf_nodes()
|
||||
try:
|
||||
executor.migrate(old_targets)
|
||||
MigrationExecutor(connection).migrate(current_targets)
|
||||
assert list(GroupDispatch.objects.values()) == before_dispatch
|
||||
assert list(Message.objects.values()) == before_messages
|
||||
assert list(User.objects.values()) == before_users
|
||||
assert not DispatchResponseControl.objects.filter(dispatch=c["dispatch"]).exists()
|
||||
from apps.tickets.staff_conversation import visible_messages
|
||||
assert visible_messages(c["tickets"][0], c["staff"]).filter(pk=c["message"].pk).exists()
|
||||
finally:
|
||||
MigrationExecutor(connection).migrate(current_targets)
|
||||
|
||||
|
||||
def test_migration_refuses_to_drop_response_confidentiality_after_use(opening_case):
|
||||
from django.apps import apps
|
||||
from django.db.migrations.exceptions import IrreversibleError
|
||||
from types import SimpleNamespace
|
||||
import importlib
|
||||
guard = importlib.import_module("apps.tickets.migrations.0018_dispatch_response_opening").preserve_used_response_controls
|
||||
with pytest.raises(IrreversibleError):
|
||||
guard(apps, SimpleNamespace(connection=connection))
|
||||
assert DispatchResponseControl.objects.filter(dispatch=opening_case["dispatch"]).exists()
|
||||
@@ -0,0 +1,46 @@
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.customers.models import ContactGroup, CustomerProfile
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from apps.tickets.dispatch_presentation import dispatch_form_options
|
||||
from apps.tickets.models import TicketCategory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_dispatch_options_count_only_eligible_members_and_preserve_unit_scope(client):
|
||||
unit = OrganizationalUnit.objects.create(name="واحد مجاز", slug="dispatch-options")
|
||||
other = OrganizationalUnit.objects.create(name="واحد دیگر", slug="dispatch-options-other")
|
||||
unit.permissions.add(Permission.objects.get(codename="dispatch_group_tickets"))
|
||||
user = User.objects.create_user(username="dispatch-options", account_type="STAFF")
|
||||
StaffProfile.objects.create(user=user, primary_unit=unit)
|
||||
group = ContactGroup.objects.create(unit=unit, name="گروه مجاز")
|
||||
hidden_group = ContactGroup.objects.create(unit=other, name="گروه پنهان")
|
||||
category = TicketCategory.objects.create(unit=unit, title="مجاز", slug="options-category")
|
||||
hidden_category = TicketCategory.objects.create(unit=other, title="پنهان", slug="options-hidden-category")
|
||||
for index, (mobile, active) in enumerate([("09120000091", True), ("", True), (None, True), ("09120000092", False)]):
|
||||
contact = User.objects.create_user(username=f"options-customer-{index}", mobile=mobile, is_active=active, account_type="CUSTOMER")
|
||||
profile = CustomerProfile.objects.create(user=contact)
|
||||
profile.groups.add(group)
|
||||
client.force_login(user)
|
||||
response = client.get(reverse("staff_tickets:dispatch_create"))
|
||||
assert response.status_code == 200
|
||||
options = response.context["dispatch_options"]
|
||||
assert options["units"] == [{"id": unit.pk, "name": unit.name, "operations": ["BULK"]}]
|
||||
assert options["groups"] == [{"id": group.pk, "name": group.name, "unit_id": unit.pk, "recipient_count": 1}]
|
||||
assert category.pk in [item["id"] for item in options["categories"]]
|
||||
assert hidden_category.pk not in [item["id"] for item in options["categories"]]
|
||||
assert hidden_group.pk not in [item["id"] for item in options["groups"]]
|
||||
assert "?v=" in response.content.decode().split("dispatch-workspace.js", 1)[1]
|
||||
|
||||
|
||||
def test_dispatch_options_include_global_category_fallback_for_units_without_categories():
|
||||
unit = OrganizationalUnit.objects.create(name="بدون دستهبندی", slug="dispatch-options-fallback")
|
||||
user = User.objects.create_superuser(username="options-admin", account_type="STAFF")
|
||||
global_category = TicketCategory.objects.create(title="عمومی", slug="options-global", unit=None)
|
||||
options = dispatch_form_options(user, [unit], ContactGroup.objects.none())
|
||||
assert {"id": global_category.pk, "title": global_category.title, "unit_id": None} in options["categories"]
|
||||
assert options["groups"] == []
|
||||
@@ -0,0 +1,368 @@
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.attachments.models import Attachment, AttachmentPolicy
|
||||
from apps.messaging.models import Message, MessageReadReceipt, MessageRevision
|
||||
from apps.messaging.services import edit_message
|
||||
from apps.notifications.models import NotificationJob, SMSTemplate
|
||||
from apps.notifications.management.commands.process_notifications import _send_bale, _send_sms
|
||||
from apps.staff.models import OrganizationalUnit
|
||||
from apps.tickets.dispatch_responses import OPENING_EVENT, open_dispatch_responses, render_opening_sms
|
||||
from apps.tickets.models import DispatchResponseControl, GroupDispatch, MessageShare, TicketCategory
|
||||
from apps.tickets.policies import TicketPolicy
|
||||
from apps.tickets.services import create_group_dispatch, dispatch_group_tickets, reply_ticket
|
||||
from apps.tickets.sharing import share_staff_message
|
||||
from tests.test_organizational_ticketing import make_contact, make_staff
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def opening_case():
|
||||
unit = OrganizationalUnit.objects.create(name="واحد بازگشایی", slug="opening-unit", bale_chat_id="opening-room")
|
||||
unit.permissions.add(*Permission.objects.filter(content_type__app_label="tickets", codename__in=["view_ticket", "create_inquiry", "create_bulk_dispatch"]))
|
||||
staff = make_staff("opening-owner", unit)
|
||||
other = make_staff("opening-peer", unit)
|
||||
viewer = make_staff("opening-viewer", unit)
|
||||
viewer.user_permissions.add(Permission.objects.get(codename="view_all_bulk_dispatches"))
|
||||
admin = User.objects.create_superuser(username="opening-admin", password="Admin-test-91", account_type="STAFF")
|
||||
from apps.customers.models import ContactGroup
|
||||
group = ContactGroup.objects.create(name="گروه استعلام", unit=unit)
|
||||
customers = [make_contact(f"opening-contact-{i}", f"0912200010{i}") for i in range(2)]
|
||||
for customer in customers:
|
||||
customer.customer_profile.groups.add(group)
|
||||
category = TicketCategory.objects.create(unit=unit, title="استعلام", slug="opening-category")
|
||||
dispatch, tickets = dispatch_group_tickets(user=staff, unit=unit, contact_group=group, category=category,
|
||||
subject="استعلام قیمت محرمانه", body="لطفاً پیشنهاد خود را ثبت کنید.")
|
||||
message = reply_ticket(ticket_id=tickets[0].pk, user=customers[0], body="SECRET_BID_84723")
|
||||
return dict(unit=unit, staff=staff, other=other, viewer=viewer, admin=admin, group=group,
|
||||
customers=customers, category=category, dispatch=dispatch, tickets=tickets, message=message)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("actor", ["staff", "viewer", "admin"])
|
||||
def test_seal_blocks_report_ticket_poll_revisions_and_receipts(client, opening_case, actor):
|
||||
c = opening_case
|
||||
client.force_login(c[actor])
|
||||
for route, public_id in [("dispatch_detail", c["dispatch"].public_id), ("detail", c["tickets"][0].public_id),
|
||||
("messages", c["tickets"][0].public_id), ("poll", c["tickets"][0].public_id)]:
|
||||
response = client.get(reverse("staff_tickets:" + route, args=[public_id]))
|
||||
assert response.status_code == 200
|
||||
assert c["message"].body not in response.content.decode()
|
||||
report = client.get(reverse("staff_tickets:dispatch_detail", args=[c["dispatch"].public_id]))
|
||||
assert report.context["metrics"]["replied"] == 1
|
||||
assert 'data-answered="true"' in report.content.decode()
|
||||
assert 'پاسخ داده است: بله' in report.content.decode()
|
||||
assert client.get(reverse("messages:revisions", args=[c["message"].public_id])).status_code == 403
|
||||
client.post(reverse("staff_tickets:read_shares", args=[c["tickets"][0].public_id]), {"message_ids": [str(c["message"].public_id)]})
|
||||
assert not MessageReadReceipt.objects.filter(message=c["message"]).exists()
|
||||
|
||||
|
||||
def test_unit_wide_access_does_not_grant_unrelated_managed_dispatch_tickets(client, opening_case):
|
||||
c = opening_case
|
||||
assert not TicketPolicy.can_view(c["other"], c["tickets"][0])
|
||||
assert not c["tickets"][0].__class__.objects.filter(TicketPolicy.viewable_q(c["other"]), pk=c["tickets"][0].pk).exists()
|
||||
client.force_login(c["other"])
|
||||
assert client.get(reverse("staff_tickets:detail", args=[c["tickets"][0].public_id])).status_code == 403
|
||||
c["tickets"][0].owner = c["other"]
|
||||
c["tickets"][0].save()
|
||||
assert TicketPolicy.can_view(c["other"], c["tickets"][0])
|
||||
response = client.get(reverse("staff_tickets:detail", args=[c["tickets"][0].public_id]))
|
||||
assert response.status_code == 200 and c["message"].body not in response.content.decode()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["SEALED", "INDIVIDUAL", "LEGACY"])
|
||||
def test_report_shows_full_response_history_and_files_without_ticket_navigation(client, opening_case, mode):
|
||||
from apps.core.templatetags.jalali import jalali_datetime_precise
|
||||
c = opening_case
|
||||
control = c["dispatch"].response_control
|
||||
if mode == "LEGACY":
|
||||
control.delete()
|
||||
elif mode == "INDIVIDUAL":
|
||||
control.mode, control.notify_sms = mode, False
|
||||
control.save()
|
||||
body = "متن کامل پیشنهاد " * 30 + "پایان متن بلند"
|
||||
answer = reply_ticket(ticket_id=c["tickets"][0].pk, user=c["customers"][0], body=body)
|
||||
AttachmentPolicy.objects.create(attachment_type="PDF", max_file_size=1024 * 1024,
|
||||
allowed_extensions=[".pdf"], allowed_mime_types=["application/pdf"])
|
||||
from apps.attachments.services import save_attachments
|
||||
save_attachments(answer, [SimpleUploadedFile("offer.pdf", b"%PDF-1.4\n%%EOF", content_type="application/pdf")], c["customers"][0])
|
||||
file = answer.attachments.get()
|
||||
deleted = reply_ticket(ticket_id=c["tickets"][0].pk, user=c["customers"][0], body="DELETED_OFFER")
|
||||
from django.utils import timezone
|
||||
deleted.deleted_at = timezone.now()
|
||||
deleted.save(update_fields=["deleted_at"])
|
||||
client.force_login(c["staff"])
|
||||
url = reverse("staff_tickets:dispatch_detail", args=[c["dispatch"].public_id])
|
||||
ticket_url = reverse("staff_tickets:detail", args=[c["tickets"][0].public_id])
|
||||
attachment_url = reverse("attachments:view", args=[file.public_id])
|
||||
if mode == "SEALED":
|
||||
locked = client.get(url)
|
||||
assert body not in locked.content.decode() and attachment_url not in locked.content.decode()
|
||||
assert not locked.context["tickets"][0].report_customer_responses
|
||||
open_dispatch_responses(c["dispatch"], c["staff"], render_opening_sms(c["dispatch"]))
|
||||
report = client.get(url)
|
||||
html = report.content.decode()
|
||||
assert body in html and c["message"].body in html and deleted.body not in html
|
||||
assert attachment_url in html and jalali_datetime_precise(answer.created_at) in html
|
||||
assert (f'href="{ticket_url}"' in html) == (mode != "SEALED")
|
||||
|
||||
|
||||
def test_customer_can_read_and_edit_own_sealed_answer(client, opening_case):
|
||||
c = opening_case
|
||||
client.force_login(c["customers"][0])
|
||||
response = client.get(reverse("tickets:detail", args=[c["tickets"][0].public_id]))
|
||||
assert c["message"].body in response.content.decode()
|
||||
edit_message(c["message"].pk, c["customers"][0], "SECRET_EDITED_BID")
|
||||
client.force_login(c["admin"])
|
||||
assert client.get(reverse("messages:revisions", args=[c["message"].public_id])).status_code == 403
|
||||
|
||||
|
||||
def test_opening_is_once_and_uses_confirmed_template_snapshot(opening_case, monkeypatch):
|
||||
c = opening_case
|
||||
text = render_opening_sms(c["dispatch"])
|
||||
control, opened = open_dispatch_responses(c["dispatch"], c["staff"], text)
|
||||
assert opened and control.opened_by == c["staff"] and control.opened_at
|
||||
assert control.respondents_snapshot == [str(c["customers"][0].public_id)]
|
||||
job = NotificationJob.objects.get(event_type=OPENING_EVENT)
|
||||
SMSTemplate.objects.filter(event_type=OPENING_EVENT).update(body="متن تغییر یافته")
|
||||
assert open_dispatch_responses(c["dispatch"], c["admin"], text)[1] is False
|
||||
assert NotificationJob.objects.filter(event_type=OPENING_EVENT).count() == 1
|
||||
sent = []
|
||||
class Provider:
|
||||
def send_message(self, mobile, body):
|
||||
sent.append((mobile, body))
|
||||
return "test-provider-id"
|
||||
monkeypatch.setattr("apps.notifications.management.commands.process_notifications.get_sms_provider", lambda: Provider())
|
||||
assert _send_sms(job) == ("test-provider-id", None)
|
||||
assert sent == [(c["customers"][0].mobile, text)]
|
||||
reply_ticket(ticket_id=c["tickets"][1].pk, user=c["customers"][1], body="پاسخ پس از بازگشایی")
|
||||
assert NotificationJob.objects.filter(event_type=OPENING_EVENT).count() == 1
|
||||
|
||||
|
||||
def test_template_change_requires_new_confirmation_and_view_permission_is_not_open_permission(client, opening_case):
|
||||
c = opening_case
|
||||
with pytest.raises(PermissionDenied):
|
||||
open_dispatch_responses(c["dispatch"], c["viewer"], render_opening_sms(c["dispatch"]))
|
||||
c["viewer"].user_permissions.add(Permission.objects.get(codename="open_all_dispatch_responses"))
|
||||
viewer = User.objects.get(pk=c["viewer"].pk)
|
||||
client.force_login(viewer)
|
||||
url = reverse("staff_tickets:dispatch_open_responses", args=[c["dispatch"].public_id])
|
||||
assert client.post(url).status_code == 200
|
||||
proof = client.get(url).context["confirmation"]
|
||||
SMSTemplate.objects.filter(event_type=OPENING_EVENT).update(body="متن جدید بازگشایی")
|
||||
client.post(url, {"confirmation": proof})
|
||||
assert not DispatchResponseControl.objects.get(dispatch=c["dispatch"]).opened_at
|
||||
proof = client.get(url).context["confirmation"]
|
||||
assert client.post(url, {"confirmation": proof}).status_code == 302
|
||||
assert DispatchResponseControl.objects.get(dispatch=c["dispatch"]).opened_by == viewer
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count,mode,allowed", [(15, "SEALED", True), (16, "SEALED", False), (2, "INDIVIDUAL", False)])
|
||||
def test_sms_recipient_limit_is_frozen_and_individual_mode_disables_it(opening_case, count, mode, allowed):
|
||||
c = opening_case
|
||||
for i in range(2, count):
|
||||
make_contact(f"limit-{i}", f"09122000{i:03}").customer_profile.groups.add(c["group"])
|
||||
dispatch, tickets = create_group_dispatch(user=c["staff"], unit=c["unit"], contact_group=c["group"], category=c["category"],
|
||||
subject="آزمون سقف مخاطبان", body="متن استعلام", response_mode=mode, notify_opening_sms=True)
|
||||
control = DispatchResponseControl.objects.get(dispatch=dispatch)
|
||||
assert control.recipient_count_snapshot == count and control.notify_sms is allowed
|
||||
if count == 16:
|
||||
c["group"].contacts.clear()
|
||||
with transaction.atomic(), pytest.raises(IntegrityError):
|
||||
DispatchResponseControl.objects.filter(pk=control.pk).update(notify_sms=True)
|
||||
if mode == "INDIVIDUAL":
|
||||
message = reply_ticket(ticket_id=tickets[0].pk, user=c["customers"][0], body="پاسخ جداگانه")
|
||||
from apps.tickets.staff_conversation import visible_messages
|
||||
assert visible_messages(tickets[0], c["staff"]).filter(pk=message.pk).exists()
|
||||
|
||||
|
||||
def test_sealed_attachment_shared_routes_admin_and_direct_media_are_blocked(client, opening_case, settings, tmp_path):
|
||||
c = opening_case
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
settings.USE_NGINX_MEDIA = True
|
||||
from apps.attachments.services import save_attachments
|
||||
AttachmentPolicy.objects.create(attachment_type="PDF", max_file_size=1024 * 1024,
|
||||
allowed_extensions=[".pdf"], allowed_mime_types=["application/pdf"])
|
||||
save_attachments(c["message"], [SimpleUploadedFile("secret.pdf", b"%PDF-1.4\n%%EOF", content_type="application/pdf")], c["customers"][0])
|
||||
attachment = Attachment.objects.get(message=c["message"])
|
||||
assert attachment.relative_path.startswith("dispatch-responses/")
|
||||
MessageRevision.objects.create(message=c["message"], body="OLD_SECRET_BID", edited_by=c["customers"][0])
|
||||
with pytest.raises(PermissionDenied):
|
||||
share_staff_message(c["message"], c["staff"], c["other"])
|
||||
grant = MessageShare.objects.create(message=c["message"], user=c["other"], granted_by=c["staff"])
|
||||
client.force_login(c["other"])
|
||||
assert client.get(reverse("staff_tickets:shared_message_attachment", args=[grant.public_id, attachment.public_id])).status_code == 403
|
||||
client.force_login(c["admin"])
|
||||
for route in ("view", "download"):
|
||||
assert client.get(reverse("attachments:" + route, args=[attachment.public_id])).status_code == 403
|
||||
for name, obj in [("messaging_message", c["message"]), ("messaging_messagerevision", c["message"].revisions.get()), ("attachments_attachment", attachment)]:
|
||||
response = client.get(reverse("admin:" + name + "_change", args=[obj.pk]), follow=True)
|
||||
assert response.redirect_chain and c["message"].body not in response.content.decode()
|
||||
open_dispatch_responses(c["dispatch"], c["admin"], render_opening_sms(c["dispatch"]))
|
||||
assert client.get(reverse("admin:messaging_message_change", args=[c["message"].pk])).status_code == 200
|
||||
client.force_login(c["customers"][0])
|
||||
response = client.get(reverse("attachments:view", args=[attachment.public_id]))
|
||||
assert response.status_code == 200 and "no-store" in response["Cache-Control"]
|
||||
assert b"".join(response.streaming_content).startswith(b"%PDF")
|
||||
from config.urls import debug_media
|
||||
from django.http import Http404
|
||||
with pytest.raises(Http404):
|
||||
debug_media(RequestFactory().get("/media/"), attachment.relative_path)
|
||||
|
||||
|
||||
def test_bale_does_not_disclose_managed_response_text(opening_case, monkeypatch):
|
||||
c = opening_case
|
||||
profile = c["staff"].staff_profile
|
||||
profile.bale_chat_id = "private-dispatch-owner"
|
||||
profile.save(update_fields=["bale_chat_id"])
|
||||
sent = []
|
||||
class Provider:
|
||||
def send_message(self, chat_id, body):
|
||||
sent.append((chat_id, body))
|
||||
monkeypatch.setattr("apps.notifications.management.commands.process_notifications.get_bale_provider", lambda: Provider())
|
||||
job = NotificationJob.objects.get(event_type="CUSTOMER_TICKET_REPLY", ticket=c["tickets"][0])
|
||||
_send_bale(job)
|
||||
assert sent and sent[0][0] == "private-dispatch-owner" and c["message"].body not in sent[0][1]
|
||||
|
||||
|
||||
def test_seed_only_adds_missing_opening_template_and_preserves_customizations():
|
||||
from django.apps import apps
|
||||
from django.db import connection
|
||||
migration = importlib.import_module("apps.notifications.migrations.0021_dispatch_response_opening")
|
||||
original = list(SMSTemplate.objects.exclude(event_type=OPENING_EVENT).values())
|
||||
SMSTemplate.objects.filter(event_type=OPENING_EVENT).update(body="قالب اختصاصی حفظ شود")
|
||||
from types import SimpleNamespace
|
||||
schema_editor = SimpleNamespace(connection=connection)
|
||||
migration.seed_opening_template(apps, schema_editor)
|
||||
migration.seed_opening_template(apps, schema_editor)
|
||||
assert SMSTemplate.objects.get(event_type=OPENING_EVENT).body == "قالب اختصاصی حفظ شود"
|
||||
assert list(SMSTemplate.objects.exclude(event_type=OPENING_EVENT).values()) == original
|
||||
|
||||
|
||||
def test_view_only_staff_can_list_reports_without_creation_permission(client, opening_case):
|
||||
c = opening_case
|
||||
unit = OrganizationalUnit.objects.create(name="واحد مشاهده", slug="opening-observer")
|
||||
viewer = make_staff("only-view-dispatches", unit)
|
||||
viewer.user_permissions.add(Permission.objects.get(codename="view_all_bulk_dispatches"))
|
||||
client.force_login(viewer)
|
||||
response = client.get(reverse("staff_tickets:dispatch_create"))
|
||||
assert response.status_code == 200 and c["dispatch"].subject in response.content.decode()
|
||||
assert response.context["staff_operations_access"]["dispatches"] is True
|
||||
assert 'id="dispatch-compose-form"' not in response.content.decode()
|
||||
assert client.post(reverse("staff_tickets:dispatch_create"), {}).status_code == 403
|
||||
assert client.get(reverse("staff_tickets:dispatch_open_responses", args=[c["dispatch"].public_id])).status_code == 403
|
||||
|
||||
|
||||
def test_opening_rolls_back_if_outbox_cannot_be_saved(opening_case, monkeypatch):
|
||||
c = opening_case
|
||||
def fail(*args, **kwargs):
|
||||
raise RuntimeError("outbox unavailable")
|
||||
monkeypatch.setattr("apps.notifications.services.enqueue", fail)
|
||||
with pytest.raises(RuntimeError):
|
||||
open_dispatch_responses(c["dispatch"], c["staff"], render_opening_sms(c["dispatch"]))
|
||||
assert not DispatchResponseControl.objects.get(dispatch=c["dispatch"]).opened_at
|
||||
assert not NotificationJob.objects.filter(event_type=OPENING_EVENT).exists()
|
||||
|
||||
|
||||
def test_existing_worker_sends_opening_job_once_without_issuing_magic_link(opening_case, monkeypatch):
|
||||
from django.core.management import call_command
|
||||
from apps.integrations.sms.base import SMSResult
|
||||
from apps.accounts.models import TicketMagicLink
|
||||
c = opening_case
|
||||
text = render_opening_sms(c["dispatch"])
|
||||
open_dispatch_responses(c["dispatch"], c["staff"], text)
|
||||
job = NotificationJob.objects.get(event_type=OPENING_EVENT)
|
||||
sent = []
|
||||
class Provider:
|
||||
def send_message(self, mobile, body):
|
||||
sent.append((mobile, body))
|
||||
return SMSResult(True, "opening-test-provider")
|
||||
monkeypatch.setattr("apps.notifications.management.commands.process_notifications.get_sms_provider", lambda: Provider())
|
||||
links_before = TicketMagicLink.objects.count()
|
||||
call_command("process_notifications", once=True, job_id=job.pk)
|
||||
call_command("process_notifications", once=True, job_id=job.pk)
|
||||
job.refresh_from_db()
|
||||
assert job.status == "SENT" and sent == [(c["customers"][0].mobile, text)]
|
||||
assert TicketMagicLink.objects.count() == links_before
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_seed_round_trip_preserves_opening_with_user_primary_key_gaps(opening_case, settings, tmp_path, monkeypatch):
|
||||
from django.core.management import call_command
|
||||
from django.db import connection
|
||||
if connection.vendor != "sqlite":
|
||||
pytest.skip("Seed export is deliberately restricted to SQLite sources")
|
||||
c = opening_case
|
||||
settings.MEDIA_ROOT = tmp_path / "source-media"
|
||||
User.objects.create_user(username="discarded-seed-contact").delete()
|
||||
customer = make_contact("stable-seed-respondent", "09122000888")
|
||||
customer.customer_profile.groups.add(c["group"])
|
||||
dispatch, tickets = create_group_dispatch(user=c["staff"], unit=c["unit"], contact_group=c["group"],
|
||||
category=c["category"], subject="بازگشایی با انتقال داده", body="متن", notify_opening_sms=False)
|
||||
ticket = next(ticket for ticket in tickets if ticket.customer_id == customer.pk)
|
||||
reply_ticket(ticket_id=ticket.pk, user=customer, body="پاسخ منتقلشونده")
|
||||
control, _ = open_dispatch_responses(dispatch, c["staff"])
|
||||
snapshot = control.respondents_snapshot
|
||||
opened_at = control.opened_at
|
||||
assert snapshot == [str(customer.public_id)]
|
||||
bundle = tmp_path / "bundle"
|
||||
call_command("export_production_seed", output=str(bundle))
|
||||
call_command("flush", interactive=False, verbosity=0)
|
||||
settings.MEDIA_ROOT = tmp_path / "destination-media"
|
||||
monkeypatch.setattr(connection, "vendor", "postgresql")
|
||||
call_command("import_production_seed", bundle_dir=str(bundle))
|
||||
restored = DispatchResponseControl.objects.get(dispatch_id=dispatch.pk)
|
||||
# Django's existing JSON fixture format retains millisecond precision.
|
||||
assert 0 <= (opened_at - restored.opened_at).total_seconds() < 0.001
|
||||
assert restored.respondents_snapshot == snapshot
|
||||
assert restored.opened_by.username == c["staff"].username
|
||||
assert str(User.objects.get(username=customer.username).public_id) in restored.respondents_snapshot
|
||||
|
||||
|
||||
def test_managed_reply_bale_never_falls_back_to_a_broad_unit_room(opening_case, monkeypatch):
|
||||
c = opening_case
|
||||
sent = []
|
||||
class Provider:
|
||||
def send_message(self, chat_id, text):
|
||||
sent.append((chat_id, text))
|
||||
monkeypatch.setattr("apps.notifications.management.commands.process_notifications.get_bale_provider", lambda: Provider())
|
||||
job = NotificationJob.objects.get(event_type="CUSTOMER_TICKET_REPLY", ticket=c["tickets"][0])
|
||||
assert _send_bale(job) is None
|
||||
assert not sent
|
||||
|
||||
|
||||
def test_acknowledging_question_does_not_mark_a_sealed_response_ticket_read(client, opening_case):
|
||||
from apps.tickets.models import TicketReadState
|
||||
c = opening_case
|
||||
ticket = c["tickets"][0]
|
||||
question = ticket.ticket_messages.get(author=c["staff"])
|
||||
client.force_login(c["admin"])
|
||||
url = reverse("staff_tickets:read_shares", args=[ticket.public_id])
|
||||
client.post(url, {"message_ids": [str(question.public_id)]})
|
||||
assert MessageReadReceipt.objects.filter(message=question, user=c["admin"]).exists()
|
||||
assert not TicketReadState.objects.filter(ticket=ticket, user=c["admin"]).exists()
|
||||
open_dispatch_responses(c["dispatch"], c["admin"], render_opening_sms(c["dispatch"]))
|
||||
assert not MessageReadReceipt.objects.filter(message=c["message"], user=c["admin"]).exists()
|
||||
client.post(url, {"message_ids": [str(c["message"].public_id)]})
|
||||
assert TicketReadState.objects.filter(ticket=ticket, user=c["admin"]).exists()
|
||||
|
||||
|
||||
def test_existing_share_audit_cannot_disclose_sealed_body_to_creator_or_admin(client, opening_case):
|
||||
c = opening_case
|
||||
MessageShare.objects.create(message=c["message"], user=c["other"], granted_by=c["staff"])
|
||||
for actor in ("staff", "admin"):
|
||||
client.force_login(c[actor])
|
||||
response = client.get(reverse("staff_tickets:detail", args=[c["tickets"][0].public_id]))
|
||||
assert response.status_code == 200 and c["message"].body not in response.content.decode()
|
||||
assert not response.context["message_shares"].filter(message=c["message"]).exists()
|
||||
open_dispatch_responses(c["dispatch"], c["staff"], render_opening_sms(c["dispatch"]))
|
||||
response = client.get(reverse("staff_tickets:detail", args=[c["tickets"][0].public_id]))
|
||||
assert c["message"].body in response.content.decode()
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import datetime, timezone as dt_timezone
|
||||
import pytest
|
||||
|
||||
from apps.core.templatetags.jalali import jalali_date, jalali_datetime, persian_time
|
||||
from apps.core.templatetags.jalali import jalali_date, jalali_datetime, jalali_datetime_precise, persian_time
|
||||
|
||||
|
||||
def test_jalali_datetime_uses_iran_time_persian_month_and_digits():
|
||||
@@ -14,3 +15,11 @@ def test_jalali_filters_fail_safely_for_empty_values():
|
||||
assert jalali_datetime(None) == ""
|
||||
assert jalali_date(None) == ""
|
||||
assert persian_time(None) == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value,expected", [
|
||||
(datetime(2026, 9, 5, 22, 31, 17, tzinfo=dt_timezone.utc), "۱۵ شهریور ۱۴۰۵، ساعت ۰۲:۰۱:۱۷"),
|
||||
(None, ""),
|
||||
])
|
||||
def test_opening_timestamp_includes_seconds_in_iran_time(value, expected):
|
||||
assert jalali_datetime_precise(value) == expected
|
||||
@@ -0,0 +1,114 @@
|
||||
from datetime import datetime, time, timedelta, timezone as datetime_timezone
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.payments import policies, selectors, services
|
||||
from apps.payments.forms import ReceiptForm
|
||||
from apps.payments.models import (
|
||||
PaymentAccessConfiguration, PaymentApprovalPolicy, PaymentNotice, PaymentReceipt,
|
||||
PaymentReceiptRevision, ReceiptApprovalCycle,
|
||||
)
|
||||
from tests.test_payments import case, data, grant, submit, upload
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_daily_summary_uses_current_amounts_final_outcomes_and_all_pending(case, django_assert_num_queries):
|
||||
independent_policy = PaymentApprovalPolicy.objects.create(name='بررسی فیش مستقل')
|
||||
independent_policy.reviewers.add(case['first'], case['second'])
|
||||
approved = submit(case)
|
||||
services.decide_receipt(case['first'], approved.public_id, 1, 'APPROVE')
|
||||
services.decide_receipt(case['second'], approved.public_id, 1, 'APPROVE')
|
||||
rejected = submit(case, linked=False)
|
||||
services.decide_receipt(case['first'], rejected.public_id, 1, 'REJECT', 'ناخوانا')
|
||||
partial = submit(case)
|
||||
services.decide_receipt(case['first'], partial.public_id, 1, 'APPROVE')
|
||||
pending = submit(case, linked=False)
|
||||
revised = submit(case)
|
||||
services.decide_receipt(case['first'], revised.public_id, 1, 'REJECT', 'اصلاح مبلغ')
|
||||
form = ReceiptForm(data(case, paid_amount='9000'), {'receipt_image': upload()}, customer=case['customer'])
|
||||
assert form.is_valid(), form.errors
|
||||
services.revise_receipt(case['customer'], revised.public_id, 1, form.cleaned_data)
|
||||
PaymentReceiptRevision.objects.filter(version=1).update(amount=100)
|
||||
yesterday = timezone.now() - timedelta(days=1)
|
||||
PaymentReceipt.objects.filter(pk__in=[approved.pk, pending.pk]).update(created_at=yesterday)
|
||||
# The written bank date is unrelated to when evidence arrived or was reviewed.
|
||||
PaymentReceiptRevision.objects.update(paid_date=yesterday.date())
|
||||
with django_assert_num_queries(2):
|
||||
summary = selectors.daily_payment_summary(
|
||||
PaymentReceipt.objects.all(), PaymentNotice.objects.filter(deleted_at__isnull=True))
|
||||
assert summary == {
|
||||
'date': timezone.localdate(), 'submitted_count': 3, 'submitted_amount': 9200,
|
||||
'approved_count': 1, 'approved_amount': 100, 'rejected_count': 1, 'rejected_amount': 100,
|
||||
'pending_count': 3, 'pending_amount': 9200, 'notice_count': 1,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('offset,included', [
|
||||
(-1, False), (0, True), (24 * 60 * 60 - 1, True), (24 * 60 * 60, False),
|
||||
])
|
||||
def test_daily_summary_tehran_midnight_boundaries(case, monkeypatch, offset, included):
|
||||
receipt = submit(case)
|
||||
services.decide_receipt(case['senior'], receipt.public_id, 1, 'SENIOR_APPROVE', 'بررسی نهایی')
|
||||
today = timezone.localdate()
|
||||
start = timezone.make_aware(datetime.combine(today, time.min), timezone.get_default_timezone())
|
||||
stamp = start + timedelta(seconds=offset)
|
||||
PaymentReceipt.objects.filter(pk=receipt.pk).update(created_at=stamp)
|
||||
PaymentNotice.objects.filter(pk=case['notice'].pk).update(created_at=stamp)
|
||||
ReceiptApprovalCycle.objects.filter(revision__receipt=receipt).update(finalized_at=stamp)
|
||||
monkeypatch.setattr(timezone, 'now', lambda: start + timedelta(hours=1))
|
||||
# An active display timezone must not move the Tehran business-day boundary.
|
||||
with timezone.override(datetime_timezone.utc):
|
||||
summary = selectors.daily_payment_summary(PaymentReceipt.objects.all(), PaymentNotice.objects.all())
|
||||
assert summary['date'] == today
|
||||
assert summary['submitted_count'] == summary['approved_count'] == summary['notice_count'] == int(included)
|
||||
assert summary['submitted_amount'] == summary['approved_amount']
|
||||
assert bool(summary['submitted_amount']) == included
|
||||
|
||||
|
||||
def test_daily_summary_excludes_old_finalizations_and_deleted_notices(case):
|
||||
receipt = submit(case)
|
||||
services.decide_receipt(case['first'], receipt.public_id, 1, 'REJECT', 'ناخوانا')
|
||||
ReceiptApprovalCycle.objects.filter(revision__receipt=receipt).update(
|
||||
finalized_at=timezone.now() - timedelta(days=1))
|
||||
PaymentNotice.objects.filter(pk=case['notice'].pk).update(deleted_at=timezone.now())
|
||||
summary = selectors.daily_payment_summary(
|
||||
policies.visible(PaymentReceipt.objects.all(), case['creator']),
|
||||
policies.visible(PaymentNotice.objects.all(), case['creator']))
|
||||
assert summary['submitted_count'] == 1
|
||||
assert summary['rejected_count'] == summary['rejected_amount'] == summary['notice_count'] == 0
|
||||
|
||||
|
||||
def test_daily_summary_permission_scope_and_zero_state(case, client):
|
||||
submit(case)
|
||||
PaymentAccessConfiguration.objects.update_or_create(pk=1, defaults={'open_view': False})
|
||||
client.force_login(case['outsider'])
|
||||
response = client.get(reverse('staff_payments:desk'))
|
||||
assert response.status_code == 200
|
||||
summary = response.context['daily_summary']
|
||||
assert all(value == 0 for key, value in summary.items() if key != 'date')
|
||||
grant(case['outsider'], 'view_paymentreceipt')
|
||||
response = client.get(reverse('staff_payments:desk'))
|
||||
assert response.context['daily_summary']['submitted_count'] == 1
|
||||
assert response.context['daily_summary']['notice_count'] == 0
|
||||
grant(case['outsider'], 'view_paymentnotice')
|
||||
assert client.get(reverse('staff_payments:desk')).context['daily_summary']['notice_count'] == 1
|
||||
|
||||
|
||||
def test_daily_summary_stays_independent_of_tabs_search_and_pagination(case, client):
|
||||
submit(case)
|
||||
client.force_login(case['creator'])
|
||||
endpoint = reverse('staff_payments:desk')
|
||||
baseline = client.get(endpoint).context['daily_summary']
|
||||
for parameters in [
|
||||
{'tab': 'notices', 'notice_state': 'cancelled'},
|
||||
{'tab': 'receipts', 'mine': '1', 'q': 'no-such-customer', 'page': '99'},
|
||||
{'tab': 'receipts', 'status': 'APPROVED', 'kind': 'independent', 'from': '2000-01-01', 'to': '2000-01-02'},
|
||||
]:
|
||||
response = client.get(endpoint, parameters)
|
||||
assert response.status_code == 200
|
||||
assert response.context['daily_summary'] == baseline
|
||||
assert response.context['page_obj'].paginator.count == 0
|
||||
assert 'مبنای آمار' in response.content.decode()
|
||||
@@ -42,14 +42,12 @@ def run(job):
|
||||
return job
|
||||
|
||||
|
||||
def test_initially_disabled_and_staff_uploads_do_not_notify(case, enabled, monkeypatch):
|
||||
def test_disabled_channels_do_not_notify_customer_or_staff_uploads(case, enabled, monkeypatch):
|
||||
network = Mock(side_effect=AssertionError('Network must not run in registration'))
|
||||
monkeypatch.setattr(requests, 'post', network)
|
||||
enabled.bale_enabled = enabled.sms_enabled = False
|
||||
enabled.save()
|
||||
submit(case)
|
||||
enabled.bale_enabled = enabled.sms_enabled = True
|
||||
enabled.save()
|
||||
form = ReceiptForm(data(case, linked=False), {'receipt_image': upload()}, customer=case['customer'])
|
||||
assert form.is_valid(), form.errors
|
||||
services.submit_receipt(case['creator'], case['customer'], form.cleaned_data)
|
||||
@@ -57,6 +55,38 @@ def test_initially_disabled_and_staff_uploads_do_not_notify(case, enabled, monke
|
||||
network.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('picker,linked', [(False, True), (False, False), (True, True), (True, False)])
|
||||
def test_staff_entry_points_enqueue_once_with_customer_identity(case, enabled, client, monkeypatch, picker, linked):
|
||||
network = Mock(side_effect=AssertionError('Network must not run in registration'))
|
||||
monkeypatch.setattr(requests, 'post', network)
|
||||
client.force_login(case['creator'])
|
||||
endpoint = reverse('staff_payments:receipt_new', args=[case['customer'].public_id])
|
||||
if picker:
|
||||
response = client.post(reverse('staff_payments:receipt_customer'), {'customer': case['customer'].pk})
|
||||
assert response.status_code == 302 and response.url == endpoint
|
||||
assert client.get(endpoint).status_code == 200
|
||||
values = data(case, linked=linked)
|
||||
invalid = client.post(endpoint, {**values, 'paid_amount': '0', 'receipt_image': upload()},
|
||||
HTTP_ACCEPT='application/json')
|
||||
assert invalid.status_code == 422
|
||||
assert not PaymentReceiptAlert.objects.exists()
|
||||
for _ in range(2):
|
||||
response = client.post(endpoint, {**values, 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
|
||||
assert response.status_code == 200, response.content
|
||||
receipt = PaymentReceipt.objects.get(customer=case['customer'])
|
||||
assert receipt.submitted_by_id == case['creator'].pk
|
||||
assert receipt.notice_id == (case['notice'].pk if linked else None)
|
||||
alerts = list(receipt.revisions.get().alerts.select_related('job'))
|
||||
assert len(alerts) == 3
|
||||
assert {a.job.channel for a in alerts} == {'SMS', 'BALE'}
|
||||
assert {a.recipient_mobile for a in alerts} == {'', '+989121110001', '+989121110002'}
|
||||
assert all(a.job.customer_id == case['customer'].pk for a in alerts)
|
||||
assert all(a.context['customer_mobile'] == case['customer'].local_mobile for a in alerts)
|
||||
assert all('واریزکننده آزمایشی' in a.context['customer_name'] for a in alerts)
|
||||
assert all(a.context['receipt_link'].endswith(reverse('staff_payments:receipt_detail', args=[receipt.public_id])) for a in alerts)
|
||||
network.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('anonymous,linked', [(False, True), (False, False), (True, True), (True, False)])
|
||||
def test_all_customer_entry_points_enqueue_once_without_network(case, enabled, client, monkeypatch, anonymous, linked):
|
||||
network = Mock(side_effect=AssertionError('Network must not run in registration'))
|
||||
@@ -85,27 +115,37 @@ def test_all_customer_entry_points_enqueue_once_without_network(case, enabled, c
|
||||
network.assert_not_called()
|
||||
|
||||
|
||||
def test_customer_corrections_have_separate_idempotent_alerts_and_can_be_disabled(case, enabled):
|
||||
@pytest.mark.parametrize('staff', [False, True])
|
||||
def test_customer_corrections_have_separate_idempotent_alerts_and_can_be_disabled(case, enabled, staff):
|
||||
receipt = submit(case)
|
||||
actor = case['creator'] if staff else case['customer']
|
||||
if staff:
|
||||
grant(actor, 'submit_customer_receipt')
|
||||
services.decide_receipt(case['first'], receipt.public_id, 1, 'REJECT', 'تصویر خوانا نیست')
|
||||
form = ReceiptForm(data(case), {'receipt_image': upload()}, customer=case['customer'])
|
||||
assert form.is_valid(), form.errors
|
||||
services.revise_receipt(case['customer'], receipt.public_id, 1, form.cleaned_data)
|
||||
services.revise_receipt(actor, receipt.public_id, 1, form.cleaned_data)
|
||||
assert PaymentReceiptAlert.objects.count() == 6
|
||||
receipt_alerts.safe_queue_receipt_alerts(receipt.revisions.get(version=2), case['customer'])
|
||||
receipt_alerts.safe_queue_receipt_alerts(receipt.revisions.get(version=2), actor)
|
||||
assert PaymentReceiptAlert.objects.count() == 6
|
||||
enabled.notify_revisions = False
|
||||
enabled.save()
|
||||
services.decide_receipt(case['first'], receipt.public_id, 2, 'REJECT', 'اصلاح لازم است')
|
||||
form = ReceiptForm(data(case), {'receipt_image': upload()}, customer=case['customer'])
|
||||
assert form.is_valid()
|
||||
services.revise_receipt(case['customer'], receipt.public_id, 2, form.cleaned_data)
|
||||
services.revise_receipt(actor, receipt.public_id, 2, form.cleaned_data)
|
||||
assert PaymentReceiptAlert.objects.count() == 6
|
||||
|
||||
|
||||
def test_outer_rollback_removes_receipt_and_alerts(case, enabled):
|
||||
@pytest.mark.parametrize('staff', [False, True])
|
||||
def test_outer_rollback_removes_receipt_and_alerts(case, enabled, staff):
|
||||
with pytest.raises(RuntimeError), transaction.atomic():
|
||||
submit(case)
|
||||
if staff:
|
||||
form = ReceiptForm(data(case), {'receipt_image': upload()}, customer=case['customer'])
|
||||
assert form.is_valid(), form.errors
|
||||
services.submit_receipt(case['creator'], case['customer'], form.cleaned_data)
|
||||
else:
|
||||
submit(case)
|
||||
raise RuntimeError('rollback')
|
||||
assert not PaymentReceipt.objects.exists() and not PaymentReceiptAlert.objects.exists()
|
||||
assert not NotificationJob.objects.filter(event_type=receipt_alerts.RECEIPT_ALERT_EVENT).exists()
|
||||
@@ -131,9 +171,15 @@ def test_configuration_failure_isolated_from_registration(case, enabled, monkeyp
|
||||
assert receipt.revisions.count() == 1
|
||||
|
||||
|
||||
def test_worker_sends_private_photo_with_database_token_and_sms_to_managers(case, enabled, monkeypatch, settings):
|
||||
@pytest.mark.parametrize('staff', [False, True])
|
||||
def test_worker_sends_private_photo_with_database_token_and_sms_to_managers(case, enabled, monkeypatch, settings, staff):
|
||||
settings.BALE_BOT_TOKEN = 'existing-ticket-token'
|
||||
receipt = submit(case)
|
||||
if staff:
|
||||
form = ReceiptForm(data(case), {'receipt_image': upload()}, customer=case['customer'])
|
||||
assert form.is_valid(), form.errors
|
||||
receipt = services.submit_receipt(case['creator'], case['customer'], form.cleaned_data)
|
||||
else:
|
||||
receipt = submit(case)
|
||||
posted, sent = [], []
|
||||
def post(url, **kwargs):
|
||||
posted.append((url, kwargs['data'], kwargs['files']['photo'][1].read(), kwargs['timeout']))
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import re
|
||||
from datetime import timedelta
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import TicketMagicLink, User
|
||||
from apps.integrations.sms.base import SMSResult
|
||||
from apps.notifications.management.commands.process_notifications import _send_sms
|
||||
from apps.notifications.models import NotificationJob, SMSTemplate
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from apps.tickets.models import StaffTicketShortLink, TicketCategory
|
||||
from apps.tickets.services import create_ticket, delete_ticket
|
||||
from apps.tickets.short_links import append_supervisor_ticket_link
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def short_link_case(db):
|
||||
unit = OrganizationalUnit.objects.create(
|
||||
name="واحد لینک کوتاه", slug="short-link-unit", supervisor_mobile_1="09121112233",
|
||||
supervisor_mobile_2="09121112234", supervisor_mobile_3="09121112235",
|
||||
supervisor_mobile_4="09121112236", supervisor_mobile_5="09121112237",
|
||||
supervisor_sms_ticket_link_enabled=True,
|
||||
)
|
||||
unit.permissions.add(
|
||||
Permission.objects.get(content_type__app_label="tickets", codename="view_ticket"),
|
||||
Permission.objects.get(content_type__app_label="messaging", codename="add_message"),
|
||||
)
|
||||
category = TicketCategory.objects.create(unit=unit, title="درخواست لینک", slug="short-link-category")
|
||||
customer = User.objects.create_user(username="short-customer", mobile="09121112238", account_type="CUSTOMER")
|
||||
staff = User.objects.create_user(
|
||||
username="short-staff", password="Staff-password-123", mobile="09121112239", account_type="STAFF",
|
||||
)
|
||||
StaffProfile.objects.create(user=staff, primary_unit=unit, display_name="کارشناس لینک")
|
||||
ticket = create_ticket(customer=customer, category=category, subject="موضوع لینک کوتاه", body="متن درخواست")
|
||||
return unit, customer, staff, ticket
|
||||
|
||||
|
||||
def issue_link(ticket):
|
||||
append_supervisor_ticket_link(ticket, "اعلان")
|
||||
link = StaffTicketShortLink.objects.get(ticket=ticket)
|
||||
return link, reverse("staff_ticket_short_link", args=[link.token])
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_all_five_supervisors_receive_same_short_link_without_magic_access(short_link_case, monkeypatch, settings):
|
||||
unit, _, _, ticket = short_link_case
|
||||
settings.PUBLIC_BASE_URL = "https://tickets.example.com/"
|
||||
provider = Mock()
|
||||
provider.send_message.return_value = SMSResult(True, "short-link-test")
|
||||
monkeypatch.setattr("apps.notifications.management.commands.process_notifications.get_sms_provider", lambda: provider)
|
||||
SMSTemplate.objects.create(
|
||||
unit=unit, event_type=SMSTemplate.Event.UNIT_SUPERVISOR_NEW_TICKET,
|
||||
title="الگوی لینک کوتاه", body="تیکت {ticket_number}",
|
||||
)
|
||||
jobs = list(NotificationJob.objects.filter(ticket=ticket, event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("pk"))
|
||||
before = timezone.now()
|
||||
for job in jobs:
|
||||
result, magic_link = _send_sms(job)
|
||||
assert result.success and magic_link is None
|
||||
sent = [call.args for call in provider.send_message.call_args_list]
|
||||
link, path = issue_link(ticket)
|
||||
assert before + timedelta(days=7) <= link.expires_at <= timezone.now() + timedelta(days=7)
|
||||
assert re.fullmatch(r"/vt/[a-zA-Z0-9]{10}", path)
|
||||
assert [mobile for mobile, _ in sent] == list(unit.supervisor_mobiles)
|
||||
assert {message for _, message in sent} == {
|
||||
f"تیکت {ticket.ticket_number}\nمشاهده و پاسخ: https://tickets.example.com{path}"
|
||||
}
|
||||
_send_sms(jobs[0])
|
||||
link.refresh_from_db()
|
||||
assert StaffTicketShortLink.objects.filter(ticket=ticket).count() == 1
|
||||
assert not TicketMagicLink.objects.filter(ticket=ticket).exists()
|
||||
assert not ticket.access_grants.exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unit_toggle_preserves_plain_sms_and_does_not_issue_link(short_link_case):
|
||||
unit, _, _, ticket = short_link_case
|
||||
unit.supervisor_sms_ticket_link_enabled = False
|
||||
unit.save()
|
||||
ticket.refresh_from_db()
|
||||
assert append_supervisor_ticket_link(ticket, "اعلان") == "اعلان"
|
||||
assert not StaffTicketShortLink.objects.filter(ticket=ticket).exists()
|
||||
assert OrganizationalUnit._meta.get_field("supervisor_sms_ticket_link_enabled").default is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_anonymous_login_returns_to_real_ticket_and_can_reply(short_link_case, client):
|
||||
_, _, staff, ticket = short_link_case
|
||||
_, path = issue_link(ticket)
|
||||
response = client.get(path)
|
||||
assert response.status_code == 302
|
||||
assert urlsplit(response.url).path == "/staff/login/"
|
||||
assert parse_qs(urlsplit(response.url).query)["next"] == [path]
|
||||
assert "no-store" in response["Cache-Control"]
|
||||
client.get(response.url)
|
||||
# Destination also survives a failed attempt and a POST without query params.
|
||||
response = client.post("/staff/login/", {"username": staff.username, "password": "incorrect"})
|
||||
assert response.status_code == 200
|
||||
response = client.post("/staff/login/", {"username": staff.username, "password": "Staff-password-123"})
|
||||
assert response.url == path
|
||||
response = client.get(path)
|
||||
detail = reverse("staff_tickets:detail", args=[ticket.public_id])
|
||||
assert response.url == detail
|
||||
assert client.get(detail).status_code == 200
|
||||
response = client.post(reverse("staff_tickets:reply", args=[ticket.public_id]), {"body": "پاسخ از لینک کوتاه"})
|
||||
assert response.status_code == 302
|
||||
assert ticket.ticket_messages.filter(author=staff, body="پاسخ از لینک کوتاه").exists()
|
||||
assert "ticket_magic_link_id" not in client.session
|
||||
assert not ticket.access_grants.exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("expired", [False, True])
|
||||
def test_authenticated_staff_redirect_or_expired_link(short_link_case, client, expired):
|
||||
_, _, staff, ticket = short_link_case
|
||||
link, path = issue_link(ticket)
|
||||
if expired:
|
||||
StaffTicketShortLink.objects.filter(pk=link.pk).update(expires_at=timezone.now() - timedelta(seconds=1))
|
||||
client.force_login(staff)
|
||||
response = client.get(path)
|
||||
assert response.status_code == (404 if expired else 302)
|
||||
assert "no-store" in response["Cache-Control"]
|
||||
if not expired:
|
||||
assert response.url == reverse("staff_tickets:detail", args=[ticket.public_id])
|
||||
else:
|
||||
assert append_supervisor_ticket_link(ticket, "اعلان") == "اعلان"
|
||||
link.refresh_from_db()
|
||||
assert link.expires_at <= timezone.now()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_expiry_is_rechecked_after_login(short_link_case, client):
|
||||
_, _, staff, ticket = short_link_case
|
||||
link, path = issue_link(ticket)
|
||||
client.get(client.get(path).url)
|
||||
StaffTicketShortLink.objects.filter(pk=link.pk).update(expires_at=timezone.now())
|
||||
client.post("/staff/login/", {"username": staff.username, "password": "Staff-password-123"})
|
||||
assert client.get(path).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_short_link_cannot_bypass_customer_or_other_unit_permissions(short_link_case, client):
|
||||
_, customer, _, ticket = short_link_case
|
||||
_, path = issue_link(ticket)
|
||||
client.force_login(customer)
|
||||
assert client.get(path).status_code == 403
|
||||
outsider = User.objects.create_user(username="outsider", mobile="09121112240", account_type="STAFF")
|
||||
other_unit = OrganizationalUnit.objects.create(name="واحد دیگر", slug="short-other-unit")
|
||||
StaffProfile.objects.create(user=outsider, primary_unit=other_unit, display_name="کارشناس دیگر")
|
||||
client.force_login(outsider)
|
||||
assert client.get(path).status_code == 403
|
||||
assert not ticket.access_grants.exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_missing_link_deleted_ticket_and_post_are_rejected(short_link_case, client):
|
||||
_, _, staff, ticket = short_link_case
|
||||
_, path = issue_link(ticket)
|
||||
client.force_login(staff)
|
||||
assert client.get("/vt/unknown").status_code == 404
|
||||
assert client.post(path).status_code == 405
|
||||
staff.is_superuser = True
|
||||
staff.save()
|
||||
delete_ticket(ticket_id=ticket.pk, user=staff)
|
||||
assert client.get(path).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("target", ["https://evil.example/vt/abc", "//evil.example/vt/abc", "/\\evil.example/vt/abc", "/login/", "/staff/login/"])
|
||||
def test_staff_login_rejects_unsafe_or_unrelated_next(short_link_case, client, target):
|
||||
_, _, staff, _ = short_link_case
|
||||
response = client.post("/staff/login/", {
|
||||
"username": staff.username, "password": "Staff-password-123", "next": target,
|
||||
})
|
||||
assert response.url == reverse("staff_tickets:list")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unit_admin_exposes_toggle(short_link_case, client):
|
||||
unit, _, staff, _ = short_link_case
|
||||
staff.is_superuser = staff.is_staff = True
|
||||
staff.save()
|
||||
client.force_login(staff)
|
||||
response = client.get(reverse("admin:staff_organizationalunit_change", args=[unit.pk]))
|
||||
assert response.status_code == 200
|
||||
assert b'name="supervisor_sms_ticket_link_enabled"' in response.content
|
||||
assert "supervisor_sms_ticket_link_enabled" in admin.site._registry[OrganizationalUnit].list_filter
|
||||
Reference in new issue
Block a user