estelam ticket, varizha , others

This commit is contained in:
javad committed 2026-10-13 15:48:24 +03:30
1 parent 35a1ec1794
commit 6b546aad76
71 files changed
+2861 -187

No files matched your search

+186 -6
View File
@@ -1,11 +1,11 @@
Project Root: customerTicket
Generated At: 2026-10-11T08:57:27+00:00
Python Files: 304
Generated At: 2026-10-13T12:06:20+00:00
Python Files: 327
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: 1582
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
@@ -233,15 +239,20 @@ 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
│ │ │ └── __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
@@ -276,6 +287,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,12 +319,14 @@ 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
@@ -388,6 +407,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 +449,7 @@ customerTicket
│ │ │ ├── 03040dc947a4bba22aafbc6262c9db2c8549826ae5aa1ac4cc1821806f93561f.json
│ │ │ ├── 039590a02c184d21a28caec4178b981a824ec4356a225d1ce546cc212d2f550f.json
│ │ │ ├── 040cdac326eb1631b97e9aeddca78ac07e236243b5b8ab2d77bac37b50a91cdd.json
│ │ │ ├── 042c2088faaafc0dd7d522f52e043a37d879ccf3886eeb86fd8ee76caee8274c.json
│ │ │ ├── 04b84d19d132764512441a31b31294e4f19dc963ed0a6e3abdd3eca7719e8bca.json
│ │ │ ├── 057ccb6901a83922bee4265993977ceb0d0ac425c900cb2d2f3181998df71796.json
│ │ │ ├── 058b4e36240cd25d158647c300472baad5e7101ebde0319883abb0edc8c9c74d.json
@@ -428,10 +469,12 @@ 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
@@ -440,18 +483,22 @@ customerTicket
│ │ │ ├── 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 +511,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 +547,7 @@ customerTicket
│ │ │ ├── 345cea9e55c5f1954119121edf5ec28f1514b11d83de6c5bddf80d014bd98c94.json
│ │ │ ├── 34b48eae711a65d522f81081262d437469f08182368d46643dc80eadd96bbdb5.json
│ │ │ ├── 34cbb71eca44ad5849b04248e513e7cc33c3d3f1a1f66cc27f732666e585bf19.json
│ │ │ ├── 34ee95cf6ae71da6e132fc7523dcf0e14fb271942cdebb9fcea20941e9c8f6d0.json
│ │ │ ├── 35087fe77e282f61d742ba82850f71b99fb5a515c98302e214809a662caace81.json
│ │ │ ├── 36000938c9c3b13fbd92622f905f8c4f4bfbfdabe66e3357e88c162a4fb198ce.json
│ │ │ ├── 360e95dd7f9428a51961a1f4ae7968a6e3101b8df00027432c6c743f64ccf9f8.json
@@ -502,6 +557,7 @@ customerTicket
│ │ │ ├── 393b2ca571bf671f26f243678a1be73266fe7435c43d7c837f1b53d4dc54cc66.json
│ │ │ ├── 3948717314f47afa835e760073df4764ac427f4858f5ec0ee6a2f298ac393d49.json
│ │ │ ├── 39712339ec9b29be60c73ea29595c45b9e853f2facc2c4cad9864ecfce375f8b.json
│ │ │ ├── 39ec3a5032b396c043170c52f702a4511e2f50e6b340b01fe4e2ebbaefa7a8c8.json
│ │ │ ├── 3b3003c9f49c174331e4f6eff0a43e09b9d64d0813c39e0c24805ca796d0d15a.json
│ │ │ ├── 3b9252049dbf8ef67e64a756bb41325a6cf50190004d4442119d438620bacc72.json
│ │ │ ├── 3b97506bb601aaab9825ae607b89cebbd3a1eb30a6176cb038baf9898ab0e4fb.json
@@ -509,8 +565,10 @@ customerTicket
│ │ │ ├── 3ca58a3cda78344757f73fb9dc390d4bb3b45f4a711a1a058c049801282ec1f8.json
│ │ │ ├── 3cec8055583c7d94e03ced535376a8bd9d3d335cd6c7e328f38dc2a9e6b1e397.json
│ │ │ ├── 3d66d87daf2039a3fcd22d5d844e30c00443bed258dc048fbf939d97258e08d2.json
│ │ │ ├── 3e23ccb6b6b6496c83b1332b2bad75bc7708e3e24e540289368d15d1288206e3.json
│ │ │ ├── 3e4a861d0f7c53bd5ec082f319d974a4a34d5943bf2408a639d11e0ce9bfad46.json
│ │ │ ├── 3f0cad4a4a6eb440a2b133bf72f2e72d6e7ef3668f4c8dbc2c759dbd1a41eb6f.json
│ │ │ ├── 3ff030d6213f732c2bdc25a4191f8a42cf0d2270fbf07baabff5fa43dcbab6e4.json
│ │ │ ├── 401b3df2aa1a5c618d917ae2422e552c59dfbc5220b312fe7977d751168f6987.json
│ │ │ ├── 406e12ea55e61f7314b3b46df9e152e549fae319a2f71cbb01e7fe8bc4bde7e0.json
│ │ │ ├── 408f6eb568e45e950fdc170776dabf9087829df0db630e16643fba8995318d26.json
@@ -520,9 +578,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 +594,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 +609,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 +638,7 @@ customerTicket
│ │ │ ├── 5fcd0fdc47a301cbd07130dfa7545017b5975c29b9d7458ec65938ed503506a6.json
│ │ │ ├── 608fed7bef745c90c049732f5db2efe3982e483670dac36fff2b6e22ff2953fa.json
│ │ │ ├── 60ef2d6fded9e070e35cfed119e8325010aa76bce63aa74ca0f25005d8ed9517.json
│ │ │ ├── 62a442e4f632d9f6f6d189a9e2d59b67d6805f5f479dd3034933e59f897ada7b.json
│ │ │ ├── 62dab578f0debe93a8df61a1776953980abd254ae6f1eea77afb00dfefd28f15.json
│ │ │ ├── 62e55882d2ce81dcfe5df1d58395e767ded55d6e39997d82cc3489c5e02ae910.json
│ │ │ ├── 62fba7c993ca0a10198cab8e5a7538f39f88c00face1d324cbf4613effb5ee1b.json
@@ -587,6 +657,7 @@ customerTicket
│ │ │ ├── 69bef93e5bbc63880e88aca4535dc6a229a57edcfbc65fa829912a3bb847a6ff.json
│ │ │ ├── 6a9ce83009b1228569506d7054e15c8c59b4fa9dceb9c15b9cafa288756a2d8c.json
│ │ │ ├── 6bd49215f8d2739a9e365dcc8855b95cfa34352c067a9e9ff48a623318c4ddaa.json
│ │ │ ├── 6be67cd5ad404d51ec194da65dc4348d5b444292801207ee360699a1f8bee839.json
│ │ │ ├── 6c69494ba124d4bc564bcd3a3df1f33ae0ee0769001a2dfc46825ef8b44dc3e2.json
│ │ │ ├── 6d3b094dc56f9b26ce7374ba28e460b85248711223cf516f8e732c5d8f4d9d75.json
│ │ │ ├── 6d5685e1562faf37f6c30c614bcc7628edd19cd8181d5791aff416daf9fd5a6c.json
@@ -595,6 +666,8 @@ customerTicket
│ │ │ ├── 6ee9154979fb497a8ec229a7177660af53a487e1aa17c7b555cdc67797615162.json
│ │ │ ├── 7093cbd0a855750457814388b6e527fd3b612163f7a2b898ed6ce470dae74843.json
│ │ │ ├── 712474bfe126be3bc31a5e719d9119149235071ef01e34cbe1e71ac49fd991ca.json
│ │ │ ├── 71916a519f47715fb6a1b6d0aa35d4b29876de58d1db8058fdc7411959b1a6cb.json
│ │ │ ├── 724ffe81448b103a20c616e5a5458ab25dd1b7b609cb8ad5062d652e38bdd4fb.json
│ │ │ ├── 72ae8a21c9106c639725190cc02f4e07efe7c645896cc4410923ad36751fbb2e.json
│ │ │ ├── 72de23f55766289c980e6ccf604ca5d162e0989a222271b7496775ba4d749e3a.json
│ │ │ ├── 7379dad88b1741f6f4d999fcea3eb0ac4ffe56f72c07dc11881e94615457bd52.json
@@ -608,6 +681,7 @@ customerTicket
│ │ │ ├── 7975e2dc7e1d1eb2154b9aba8f19b6207acf85a245cdda0089c9dd0f80463ef5.json
│ │ │ ├── 79ede3c31ce20a16af1a767ed248e4a7840803b6d34cf90f1205aa36f3acd7be.json
│ │ │ ├── 7a886aec3fe4d432060a7ad6c88a9e0fd810a122ad1d244456af089a828af8c1.json
│ │ │ ├── 7aae245e37fceb3e2773bd83ce355517aea9ff35a05a7ec69b73f54fb0722725.json
│ │ │ ├── 7b25ccdfeab6d57509c1b8a9745fb601ab7e8fcae0e73a22932ceb725fd38c9a.json
│ │ │ ├── 7b6916f499ae0ceae3eb3d509b51f42a7eaa98e43e161fe87c44b9a5bb01287b.json
│ │ │ ├── 7bc90a6d89ccfaea18fd0aa05f2ab506d0639360f33413bfa9385ddf49e6d2fe.json
@@ -622,16 +696,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 +722,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 +738,7 @@ customerTicket
│ │ │ ├── 94ea5a4bac836b2d19b08e446cf90eaea49b9eddc2591d261c384b3513e36f26.json
│ │ │ ├── 95dbd272df1d7fd18ba53b86ffda71c7a57046e6a303b88683e323e80ced3a91.json
│ │ │ ├── 9697c37cf3a88d2b4eb5419b2381951a9aca966172316db79e7b4c32557d5e87.json
│ │ │ ├── 971bcfb611719b6ecbaa2fab9e54895ab1a7e723f910cf36a7f62bde36a909cc.json
│ │ │ ├── 97506546de142970349e57e8a14064280e66a4fcdbced5dac55fc263630a2247.json
│ │ │ ├── 97d8ebb860cbcd63be9ef80c6e2094e4a7ccba668b291d3c4815133d5fb8f37e.json
│ │ │ ├── 980f1be05d605b9093619724462c9881a8be063a5439567866172a35f8d22316.json
@@ -663,12 +746,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 +766,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 +788,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 +819,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 +834,7 @@ customerTicket
│ │ │ ├── c478d961844d415bd36c75ab4921d7bafdc9419a9e0bf6a1efc44f14130e50fe.json
│ │ │ ├── c4878d8d8ae19e705821ae40afc1ede4be1a64b559dfdf89f898cb4383718c93.json
│ │ │ ├── c4a56e0890c5b5bdc963949bff54e8a268081652516ec17e155781a93c56b390.json
│ │ │ ├── c4e73c9f9c0baae99d7c40979cdd7f558410145e6251485932cc8bfab0eeaac2.json
│ │ │ ├── c6c7b21caf5043dfdf6c25863bf23da656645640cf2ccd914c223f8984a1b3ef.json
│ │ │ ├── c8e562a594b215d21999461d5c5fc0894fef0eec4ed7a7ea5b975df305f3d53b.json
│ │ │ ├── ca033fb139fdaaaa0d083b9189e67868f0da87d0f954a863d2031c5955075e8e.json
@@ -748,12 +851,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
@@ -775,6 +883,9 @@ customerTicket
│ │ │ ├── dea57fa4a89ef99a815bd9b1bb71b8475727a3d7d88161a9a696ebf636f29ed0.json
│ │ │ ├── df2d803fc69084e91df819bda27a7e9f7303a40ff7a5d30d93fbc16daf5e2d45.json
│ │ │ ├── dfc9279b0d9023afc1dbe55536fc1cd7a556a6a437775128b9e41615431b4733.json
│ │ │ ├── e00e2e63606d8a49d1669a78789e6be1dc4d2aa78365ce58439e1d7c7975e2bf.json
│ │ │ ├── e247b821b3662d49d7381b743644858c0d14fd5b7bed224964c292f7ce9daad3.json
│ │ │ ├── e2870691d22d4ddbb76733d2050bafd675615cd02645a023b1090f04b499cb11.json
│ │ │ ├── e2f4f5c0bcb971ba3cb08a4ba14ed5d1ba6b5899fd36db1f5dcdbd2313cd318e.json
│ │ │ ├── e359eb0e19f76b69decc0ec747a7fb4c9843046bda31c5e45841858e01bcb5a7.json
│ │ │ ├── e37b6b5144067b3650a3e1045655f367e7027ac2f97a8d8c19ce2e5c5862cea0.json
@@ -788,6 +899,7 @@ customerTicket
│ │ │ ├── e831be2a985dcf2094a014d182ea02e4ee10b94fbd35b44e3c69a98a7acb3451.json
│ │ │ ├── e8f3b285df9ff4d30c78eb1eded3eefdd86cfe325a7c8c29fb8457b6dde5e712.json
│ │ │ ├── e90aa709aa08fcf3294352b588ce8c8c902f3ef59a503d4b8d9a292a9c478096.json
│ │ │ ├── e9475289c4f92913d5288a655bc50a16c4145e9d0bc9707af427725445dc0049.json
│ │ │ ├── e974cd0006d8160a6ef1316083bac9e699adfd87acc7edb404ae7eba1c799b68.json
│ │ │ ├── e99791dc7ccf0fde796771290e06e6ea13ecf4d24044945c735f3e9f470934fd.json
│ │ │ ├── e9a8155f1a3546e434485a2aca7a04c6857071bfcfd5a7317a62472d90919b9d.json
@@ -795,12 +907,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
@@ -1057,6 +1171,7 @@ 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-pre-chaharsu.sqlite3
│ ├── deposit-sharing-live-1440.png
@@ -1155,6 +1270,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 +1298,7 @@ customerTicket
│ │ │ ├── deposit-identifier.js
│ │ │ ├── payments.js
│ │ │ ├── receipt-amount.js
│ │ │ ├── receipt-decision.js
│ │ │ ├── receipt-form.js
│ │ │ └── receipt-upload.js
│ │ ├── attachment-uploader.js
@@ -1190,6 +1307,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 +1345,7 @@ customerTicket
│ │ ├── _customer_identity.html
│ │ ├── _customer_picker.html
│ │ ├── _customer_receipt_form.html
│ │ ├── _daily_summary.html
│ │ ├── _field.html
│ │ ├── _icon.html
│ │ ├── _link_status.html
@@ -1255,6 +1374,7 @@ customerTicket
│ ├── tickets
│ │ ├── _announcement_status.html
│ │ ├── _customer_dock.html
│ │ ├── _dispatch_icon.html
│ │ ├── _image_viewer.html
│ │ ├── _immediate_announcement.html
│ │ ├── _messages.html
@@ -1273,6 +1393,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 +1420,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 +1487,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 +1601,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,12 +1637,14 @@ 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
@@ -1486,17 +1657,21 @@ 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
@@ -1509,6 +1684,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 +1694,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,6 +1703,7 @@ 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
+2 -1
View File
@@ -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)
+13
View File
@@ -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
+4
View File
@@ -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)
+9 -16
View File
@@ -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)
+1 -1
View File
@@ -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.
+1
View File
@@ -16,6 +16,7 @@ SEED_MODEL_LABELS = (
"tickets.SLAConfig",
"tickets.TicketTag",
"tickets.GroupDispatch",
"tickets.DispatchResponseControl",
"tickets.Ticket",
"tickets.TicketAccessGrant",
"tickets.TicketEvent",
+8
View File
@@ -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."""
+3 -1
View File
@@ -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)
+4
View File
@@ -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
+2
View File
@@ -23,6 +23,8 @@ def delete(request,public_id):
msg=get_object_or_404(Message,public_id=public_id); soft_delete_message(msg.pk,request.user); return redirect(_safe_next(request,"/staff/"))
@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")})
+42
View File
@@ -0,0 +1,42 @@
"""Isolated worker handler; existing events keep their delivery flow."""
from django.core.exceptions import ValidationError
from apps.tickets.models import DispatchResponseControl
DEFAULT_OPENING_SMS = (
"لوله گستر گلپایگان با سلام؛ پاسخ استعلام شما بازگشایی شد. جهت صیانت از رقابت عادلانه، تمامی پیشنهادات تا این لحظه کاملاً محرمانه بوده و هیچ‌کس دسترسی به مشاهده آن‌ها نداشته است. اکنون تمامی پاسخ‌ها به‌صورت هم‌زمان بازگشایی شدند تا سلامت فرآیند تضمین گردد.\n"
"از همراهی و اعتماد شما سپاسگزاریم. واحد فناوری اطلاعات لوله گستر گلپایگان"
)
def ensure_opening_template():
"""Legacy seed bundles may predate this event; never replace an existing body."""
from .models import SMSTemplate
return SMSTemplate.objects.get_or_create(unit=None, event_type="DISPATCH_RESPONSES_OPENED", defaults={
"title": "بازگشایی پاسخ‌های ارسال گروهی", "body": DEFAULT_OPENING_SMS, "allowed_tags": [], "is_active": True,
})[0]
def send_opening_sms(job, provider_factory):
control = DispatchResponseControl.objects.get(dispatch_id=job.payload.get("dispatch_id"))
permitted = (control.mode == "SEALED" and control.notify_sms
and control.recipient_count_snapshot <= 15 and control.opened_at
and job.ticket_id and job.ticket.group_dispatch_id == control.dispatch_id
and job.customer_id == job.ticket.customer_id
and str(job.customer.public_id) in control.respondents_snapshot)
if not permitted or job.payload.get("body") != control.sms_body_snapshot:
raise ValidationError("Opening notification does not match its authorized snapshot.")
return provider_factory().send_message(job.customer.mobile, control.sms_body_snapshot)
def send_dispatch_reply_bale(job, text, provider_factory):
"""Dispatch metadata goes to its creator's private destination, never a unit room."""
from apps.tickets.policies import DispatchPolicy
dispatch = job.ticket.group_dispatch
owner = dispatch.created_by
profile = getattr(owner, "staff_profile", None)
if not owner.is_active or not profile or not profile.is_active or not profile.bale_notifications_enabled:
return None
if not profile.bale_chat_id.strip() or not DispatchPolicy.can_view(owner, dispatch):
return None
return provider_factory().send_message(profile.bale_chat_id.strip(), text)
@@ -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
@@ -109,6 +115,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 +136,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),
]
+3
View File
@@ -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)
+3 -1
View File
@@ -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):
+40 -2
View File
@@ -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):
+3 -1
View File
@@ -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)
+40 -1
View File
@@ -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):
+35
View File
@@ -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")),
}
+45
View File
@@ -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})
+139
View File
@@ -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
+7
View File
@@ -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),
]
+27
View File
@@ -176,3 +176,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"),
]
+46
View File
@@ -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)
+22
View File
@@ -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__"
+22 -1
View File
@@ -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):
+5 -1
View File
@@ -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),
}
+8 -11
View File
@@ -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)
+4 -1
View File
@@ -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)
+2 -1
View File
@@ -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
View File
@@ -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 = {
+1 -1
View File
@@ -67,7 +67,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>
+5
View File
@@ -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;
+69
View File
@@ -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 مرورگر پروژه، در عرض‌های ۱۴۴۰، ۳۹۰ و ۳۲۰ اجرا می‌شوند.
+3 -1
View File
@@ -1,6 +1,8 @@
# اطلاع‌رسانی فیش مشتری
پس از ثبت موفق فیش مشتری، اپ `payments` اعلان‌ها را در صف `NotificationJob` قرار می‌دهد. ثبت از حساب مشتری و لینک کوتاه پرداخت هر دو پشتیبانی می‌شوند. فیشی که کارشناس به نمایندگی از مشتری ثبت می‌کند اعلان ندارد. تصویر هر نسخه مستقل است؛ گزینه «اطلاع‌رسانی فیش اصلاح‌شده مشتری» تعیین می‌کند نسخه‌های اصلاحی نیز اعلان داشته باشند.
پس از ثبت موفق فیش مشتری، اپ `payments` اعلان‌ها را در صف `NotificationJob` قرار می‌دهد. ثبت از حساب مشتری، لینک کوتاه پرداخت و «ثبت فیش برای مشتری» در پنل کارشناس همگی پشتیبانی می‌شوند. ثبت دستی کارشناس نیز با همان تنظیمات به مدیران فعال پیامک می‌فرستد و تصویر فیش را به همان گروه بله ارسال می‌کند؛ اطلاعات پیام مربوط به مشتری و واریزکننده است. تصویر هر نسخه مستقل است؛ گزینه «اطلاع‌رسانی فیش اصلاح‌شده مشتری» تعیین می‌کند نسخه‌های اصلاحی نیز، چه توسط مشتری و چه کارشناس مجاز ثبت شوند، اعلان داشته باشند.
این اصلاح migration یا تنظیمات جداگانه نمی‌خواهد. برای اعمال آن، سرور برنامه و Worker موجود `process_notifications` را با نسخه جدید کد بازراه‌اندازی کنید. اعلان‌های مربوط به ثبت‌های قبلی که صفی برای آن‌ها ساخته نشده، به‌صورت خودکار ارسال نمی‌شوند.
## راه‌اندازی
+7
View File
@@ -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}
+263
View File
@@ -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; }
}
+10
View File
@@ -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%}}
+254
View File
@@ -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);
})();
+44
View File
@@ -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>
+15 -3
View File
@@ -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 %}
+1
View File
@@ -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>
+12
View File
@@ -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 %}
+1 -1
View File
@@ -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 %}
+2 -2
View File
@@ -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 %}
+87 -16
View File
@@ -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 %}
+68 -68
View File
@@ -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>
{% 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>
<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>
<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>
{% 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>
<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 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' %}{% 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_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 %}
</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 %}
+89
View File
@@ -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)
+7
View File
@@ -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,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()
+108
View File
@@ -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)
+4
View File
@@ -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()
+103
View File
@@ -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()
+46
View File
@@ -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"] == []
+368
View File
@@ -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()
+10 -1
View File
@@ -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
+114
View File
@@ -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()
+55 -9
View File
@@ -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,26 +115,36 @@ 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():
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()
@@ -131,8 +171,14 @@ 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'
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):