fix some bugs

This commit is contained in:
javad committed 2026-09-15 09:50:17 +03:30
1 parent b8b3aba855
commit 963fd839dc
22 files changed
+570 -33

No files matched your search

+191 -8
View File
@@ -1,11 +1,11 @@
Project Root: customerTicket
Generated At: 2026-09-10T06:55:10+00:00
Python Files: 174
Generated At: 2026-09-15T04:54:39+00:00
Python Files: 186
Django Files: 37
JavaScript/TypeScript Files: 17
HTML Files: 29
Total Files: 648
Total Directories: 80
JavaScript/TypeScript Files: 20
HTML Files: 30
Total Files: 821
Total Directories: 90
customerTicket
├── apps
@@ -34,6 +34,7 @@ customerTicket
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── delivery.py
│ │ ├── models.py
│ │ ├── services.py
│ │ ├── urls.py
@@ -43,6 +44,8 @@ customerTicket
│ │ │ ├── commands
│ │ │ │ ├── __init__.py
│ │ │ │ ├── bootstrap.py
│ │ │ │ ├── export_production_seed.py
│ │ │ │ ├── import_production_seed.py
│ │ │ │ └── normalize_persian_data.py
│ │ │ └── __init__.py
│ │ ├── migrations
@@ -51,6 +54,7 @@ customerTicket
│ │ │ ├── 0003_sitebranding_developer_credit_html_and_more.py
│ │ │ ├── 0004_sitebranding_staff_sidebar_credit_html_and_more.py
│ │ │ ├── 0005_sitebranding_company_logo_and_more.py
│ │ │ ├── 0006_productionseedstate.py
│ │ │ └── __init__.py
│ │ ├── templatetags
│ │ │ ├── __init__.py
@@ -62,7 +66,8 @@ customerTicket
│ │ ├── iran_locations.py
│ │ ├── middleware.py
│ │ ├── models.py
│ │ └── persian_text.py
│ │ ├── persian_text.py
│ │ └── production_seed.py
│ ├── customers
│ │ ├── migrations
│ │ │ ├── 0001_initial.py
@@ -117,6 +122,8 @@ customerTicket
│ │ │ ├── 0007_notificationjob_notif_due_idx_and_more.py
│ │ │ ├── 0008_notificationjob_provider_message_id.py
│ │ │ ├── 0009_ensure_global_sms_templates.py
│ │ │ ├── 0010_use_actual_unit_in_staff_ticket_sms.py
│ │ │ ├── 0011_notification_delivery_tracking.py
│ │ │ └── __init__.py
│ │ ├── __init__.py
│ │ ├── admin.py
@@ -134,6 +141,7 @@ customerTicket
│ │ │ ├── 0007_organizationalunit_show_in_customer_form.py
│ │ │ ├── 0008_unitmembership_ticket_scope_and_roles.py
│ │ │ ├── 0009_alter_staffprofile_sort_order.py
│ │ │ ├── 0010_organizationalunit_notification_destinations.py
│ │ │ └── __init__.py
│ │ ├── __init__.py
│ │ ├── admin.py
@@ -157,6 +165,7 @@ customerTicket
│ │ │ ├── 0013_merge_legacy_accounting_unit.py
│ │ │ ├── 0014_merge_legacy_warehouse_units.py
│ │ │ ├── 0015_seed_customer_category_catalog.py
│ │ │ ├── 0016_scope_legacy_public_tickets_to_units.py
│ │ │ └── __init__.py
│ │ ├── __init__.py
│ │ ├── admin.py
@@ -182,12 +191,20 @@ customerTicket
│ ├── env.py
│ ├── urls.py
│ └── wsgi.py
├── design-prototypes
│ └── ticket-new-wizard
│ ├── index.html
│ ├── prototype.js
│ ├── styles.css
│ └── test_prototype_e2e.py
├── docker
│ ├── entrypoint.sh
│ └── nginx.conf
├── docs
│ ├── DEPLOYMENT_COOLIFY.md
│ ├── ORGANIZATIONAL_TICKETING.md
│ ├── ORGANIZATIONAL_TICKETING_GAP_ANALYSIS.md
│ ├── PRODUCTION_SEED.md
│ ├── QA_PERMISSION_MATRIX.md
│ └── THEMING.md
├── graphify-out
@@ -223,40 +240,69 @@ customerTicket
│ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json
│ ├── 2026-09-14
│ │ ├── .graphify_analysis.json
│ │ ├── .graphify_labels.json
│ │ ├── cost.json
│ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json
│ ├── cache
│ │ ├── ast
│ │ │ └── v0.9.56-s2
│ │ │ ├── 00aea1c179c20548df2499948796faba373334997bfeddad9e335db138753cc5.json
│ │ │ ├── 00afff188288984e240329931ac9e4273f5ab89be582e021e9edbdbead42ebfe.json
│ │ │ ├── 0138bfeaa54b14916df0028a970de356913b83e8da2cd5cfc610361887baa9c2.json
│ │ │ ├── 01d84090a3b22716edb63a5cf9c0a56cc94c0f5207cdbd5005843f858bbb292f.json
│ │ │ ├── 02108c5a5a69113e104cb5a933a331a5de86cbb18203baf5339a714de46c2372.json
│ │ │ ├── 027726034309b90d12137f6110b86751f2558dec73d338c93e190b2f9706caa4.json
│ │ │ ├── 02a3519f5b471b7083c161d9d9c73ab5b0ee78cf7e2cc4d5be7c2542435aa58e.json
│ │ │ ├── 039590a02c184d21a28caec4178b981a824ec4356a225d1ce546cc212d2f550f.json
│ │ │ ├── 053337dddf6eb41ca0de06296d382047253aa08d9b91d61fe86923462c4e2dc8.json
│ │ │ ├── 058b4e36240cd25d158647c300472baad5e7101ebde0319883abb0edc8c9c74d.json
│ │ │ ├── 074e22579bb4a8d9c4f0c12cdf3cee2aa1dbc9e91778fdbbbbe22ce014511f47.json
│ │ │ ├── 07e174cf218887aa0f8747199874faf6bc8af9347e43ff9b5a2073c089637f6f.json
│ │ │ ├── 08ff4daae812563107028890c2ae7257bb5f9a376a978244c814b7a4c1d10a98.json
│ │ │ ├── 091194a653ec9ff59e50d6d6121d12c0b0238b3f5cbb0467133a224b7eda59c8.json
│ │ │ ├── 0aa59f78fa092e41298775a4db40136ea63654d45e3ae835903c4e3dbd0578d4.json
│ │ │ ├── 0aafac1c4b289aeef9a6251e3a12fbf1710ae576dbd647d155aa3981008d50b7.json
│ │ │ ├── 0bc6d4b595803438408992e7a267aabdae34a6e30bcb947a90307114918928fb.json
│ │ │ ├── 0d5f84af41009fdecae2a46d4ad5fab38a79c1a5dde262e9c7f8d35707a35092.json
│ │ │ ├── 0d837f2122843751eeb3e945c9c56992b5c410b54ca0dbceccf5413022601d27.json
│ │ │ ├── 0ef31d053655ee64599a0246f26c8eb61f27654ae1ffbd41b87500438fc9b06a.json
│ │ │ ├── 0fd49b978854a13276bcd9479e5d287e54f19e235d799825f049400153a78c6f.json
│ │ │ ├── 11d2b71feb1480622fb18ee3795cf070421b146e4273b5016fad81e7cc8e2c7e.json
│ │ │ ├── 12db2ab20d1e0be8080b5edd6cc40cacfdff350dd585126a0e0ce144073ad12f.json
│ │ │ ├── 12f95828a2c1818402459627e36481199020e31faa280d214f81700fb9ad520d.json
│ │ │ ├── 134a89bb3cc350a74b9b750cc913b6dec440dc2f7ee7bf1eb224793ac3a07544.json
│ │ │ ├── 143ae363276b4a1db64805529e618db71c204aa56bc8b54761693a9a2060336c.json
│ │ │ ├── 15539048155c760644fc70dd1fbff3855ceedf11bc6ef7b270d1c7280cb4df48.json
│ │ │ ├── 157a547bf11b52f36b8ae31334708f612d0d615ad75a9d7e42076806661217d7.json
│ │ │ ├── 161e1e32c695094d9d3ceb2a867fa497be0aaef0db27bdfdf94489c6f306e82b.json
│ │ │ ├── 16f67769b1d3e30239a9537cd98689a1f4448c60b2cc8bfdf8468ac9dc712ba6.json
│ │ │ ├── 178afffc3f3531ee7ffdae18dcbd5505108e5c07892b776a9d8a3413fa39c56d.json
│ │ │ ├── 17ab5521ea572ea85443db030bd5085f1443c20b20da9cbf808f21ad695eb319.json
│ │ │ ├── 17ff6234af168a40b807e5226bfdb41cf2011a7134c763aba3c69428b6879d66.json
│ │ │ ├── 18ba25713068baf53992eb4ea94f95212ecaeb931fdcf6769970fc2fd19b2f93.json
│ │ │ ├── 19ed1276256eb4d44b9cea2dddac4de3801d67a08cf381a55466e82ee6f2bbc6.json
│ │ │ ├── 1b9e0fc2a347605b7d5ae84c15a0dc71fca5af90fa461763085e622f4c5877ec.json
│ │ │ ├── 1bfc839bf502b97d0b70b0bacc9781cae7511d27d078ed6ac6978fd438833dd1.json
│ │ │ ├── 1d6b17be6b2c0e7d25b4622b4a8c09ab705cecf9db09e2f86c35a4a564485791.json
│ │ │ ├── 1db075565e2fe1027675b7ee03d055150e6b741f3e9865bcd46d9a6113c538a8.json
│ │ │ ├── 1f440edc6feb5bf8c2a462b1b4a0b44bd22b4a763286fb2b340c797c282b5bcd.json
│ │ │ ├── 1f6cfd55d13bc48aa6c68558090ceeaf24a54c7bec690d51d95243c904daf0a6.json
│ │ │ ├── 1fabd63a56e826ff351d3edca222f5f958004c01f0bd16aed30080e8b13da33f.json
│ │ │ ├── 20107b827654d2af6301ac0a72a7d29ef6ef423ccde3e1fa48f5f92490fdfa90.json
│ │ │ ├── 20a6a77982f0ffa677ecf8b96662481ca9acccb7ef05274a31fd7ddbd217842a.json
│ │ │ ├── 211549439bc4b41e71f437e9909b8fc7bc306273615743fc61f53c8b8dc57e56.json
│ │ │ ├── 21f38c2f6a379ab402b4d3bd1b0809129c8554ebb01202a160c42a1e9b49b717.json
│ │ │ ├── 21f56aad8baf0dfcbc69c801ff4f738d0bcb84270150cde91f6290ebdef0e2d4.json
│ │ │ ├── 22534d744aadf9c9b61b908eccea0e9b3eaa30bc381bace54b3221ee0861a0ec.json
│ │ │ ├── 22df765bbeaca09caaf2fbcddcdd3ef1d4c0800f0c545f963bd91e85c33fe7ec.json
│ │ │ ├── 2396802d9ca82a055f431939b4cd95eea6c29556956075d1d1ff35e4bca3b7c6.json
│ │ │ ├── 244419b0eb45bd51409a2284de828aa5de674be78dd572f04c911b105a6c9ee7.json
│ │ │ ├── 2447edb7f88778fbf1ff0ac017796eb52bb2ced4c91786eef12a341b37adfaf7.json
│ │ │ ├── 25513735be357804874f44a0ab8d868419bbc8b0e33ee2bc20ce677554c7e43e.json
│ │ │ ├── 2609e627752bd20df923f6c69e090173a368757b3dbd2ce418e3cc9f67c697ba.json
│ │ │ ├── 2667c1ca679408fbaca6212c012d64f504eb8ddd574841569403de39c25dff3b.json
│ │ │ ├── 26c6832fa65fe38fa62f98bbfd8d544cf6a470cce547579e1d650390e8b9fded.json
@@ -264,8 +310,12 @@ customerTicket
│ │ │ ├── 26f1fc81bffa8c22334482cd1cc638ac1f38647ae723ac8c360dd855281598e9.json
│ │ │ ├── 287fdc570ea3598f1b62d5764970740f7397df117fd25e9340f03b0f8d0e5171.json
│ │ │ ├── 28cefeff27c9cb5760aead796c37afdb1311bff0a5ff759136f0473ffa0152ff.json
│ │ │ ├── 298888276da87757f59b0e0280ea46039c0ee05945961c3e00b287abcbc6a2f0.json
│ │ │ ├── 2ac9a38700a628e196b14c23c8615d221fc2efdf0a9564722d4ddf4e293e70c2.json
│ │ │ ├── 2b104842fa1eaead6f52dc015e74fa38c6327cfa1a95f0858e649d093b1e98bd.json
│ │ │ ├── 2b7cc5d0dec7234082c1797ab637b916ac7df39277f8fbf109ae90190fc1e900.json
│ │ │ ├── 2c8d91fbfca457646cf60edb03fd3a89a2494a6b6612087854edeac5a119739a.json
│ │ │ ├── 2df1edd1fd32c467f237babe4a94f2ed73b67270ede6fa8298b32eab7c3eb80b.json
│ │ │ ├── 2e17dd08443df447b74080559d8cfc6b782adfc04963abfb7473e8a59c21d701.json
│ │ │ ├── 2fd5953e7926f320bdeac2d559967758aef4632bf7dfa26a5ee50ad386fc9b67.json
│ │ │ ├── 3142f9d4517f137483c716dd61985811bbf006b85506791e9a2ee37d58b2f53e.json
@@ -274,31 +324,47 @@ customerTicket
│ │ │ ├── 32c40d17e90ccf2511e124b4fcc5b3674a7200dd5215ee8742d319f8090b04ba.json
│ │ │ ├── 334f218e8e74fa3ac3e285059183c77efdbfe99ccfb426df17b4eceef14627e2.json
│ │ │ ├── 3358bbaf4a10bb5102cd25fe7cdb5b8401880e8ec0d711b9b9e9b4c92404865e.json
│ │ │ ├── 33fc3acd587f6970b19f708df61db16a5256a1914954829e2c622dacb8026331.json
│ │ │ ├── 345cea9e55c5f1954119121edf5ec28f1514b11d83de6c5bddf80d014bd98c94.json
│ │ │ ├── 34b48eae711a65d522f81081262d437469f08182368d46643dc80eadd96bbdb5.json
│ │ │ ├── 36000938c9c3b13fbd92622f905f8c4f4bfbfdabe66e3357e88c162a4fb198ce.json
│ │ │ ├── 360e95dd7f9428a51961a1f4ae7968a6e3101b8df00027432c6c743f64ccf9f8.json
│ │ │ ├── 367d8bf55d8d85048571009c40b04ef43211cc9c630af6222fc5db65fb382cc8.json
│ │ │ ├── 36c68425778aff8c4320f570cc4e4ec8b3c40094abdeffc556f5488085c587e5.json
│ │ │ ├── 37159060b34fd31f9b937b215a65baef4b2091c310ea6430960e1d4991a37c5c.json
│ │ │ ├── 3948717314f47afa835e760073df4764ac427f4858f5ec0ee6a2f298ac393d49.json
│ │ │ ├── 3b878a979cc2ea2ac926f8a72f24dbef940bbaafdfbdc76679f4054a3ebf791a.json
│ │ │ ├── 3b9252049dbf8ef67e64a756bb41325a6cf50190004d4442119d438620bacc72.json
│ │ │ ├── 3c4a42b0b79b95acf81967479efdc4cd0a369e3b53b771fe87478d6327ddf0eb.json
│ │ │ ├── 3cec8055583c7d94e03ced535376a8bd9d3d335cd6c7e328f38dc2a9e6b1e397.json
│ │ │ ├── 3cf352346fa2dbd498d619b0d994f6c5b1f5a86f2e61974e24d85a836cdea0b9.json
│ │ │ ├── 3f0cad4a4a6eb440a2b133bf72f2e72d6e7ef3668f4c8dbc2c759dbd1a41eb6f.json
│ │ │ ├── 408f6eb568e45e950fdc170776dabf9087829df0db630e16643fba8995318d26.json
│ │ │ ├── 4177b5c9bcdd159b716f3e956852b337fc54e7e597b5631d0f0e9a4039011901.json
│ │ │ ├── 42ffbc76dd6afcd90fde30281453fcc765ed07bc0e729fae4eee28d86f840578.json
│ │ │ ├── 433946952194be24247d30e4d5ba4d67b64ee2cda27a9212f16b4160a6f3fb71.json
│ │ │ ├── 434bb9df51f8929ca10167ce360dbaa9d93fdbfbd27053bdf11f3b171189edb8.json
│ │ │ ├── 44684e85104b3a520e0369a953041469689cb680c2144a6c3fd7107cadc9eed0.json
│ │ │ ├── 454699d97755028c24e254cc79a0b879de262c5931178ee5dc201f64c3e13f39.json
│ │ │ ├── 47730ea39698b58e7a30a7e5fce471272e9844e0a0c09a0d603699446667a9e2.json
│ │ │ ├── 478a054f8292cd197743a61b5e3e04487f67f8db76ae658a28b04e4aef670ab2.json
│ │ │ ├── 479992e96c0c5fac0a00bd4d9e0822dd52273158f5364c26884649b017e5b874.json
│ │ │ ├── 47fb67d01a9006c7f835ad87b75b95bb1365c27b342144f081d858e07fd309b3.json
│ │ │ ├── 48ffc385500573148218512753c10924e3d70b9b1cb255782425e40063572449.json
│ │ │ ├── 4993b53d428cbbb1e334ea396aae6397586a1295a35f39da038cfe72f0b89fd4.json
│ │ │ ├── 49be8523d744110ec6038ec61ccc9e8f47894a392b60e970206c512c27e68178.json
│ │ │ ├── 4ae084687e4372e69f349e8bbc5cf603768a032d3d9ee4d2504e6709ed8ee440.json
│ │ │ ├── 4ae419f04a3a3b1221faef43b8526910f1958336162fbce432bab0e596ef2aa1.json
│ │ │ ├── 4bcdbebd8ab168095ad8130c68a697dd5ed64c3f52350fbd395c37408b9a7afa.json
│ │ │ ├── 4bf46c6c0f27e14312f7c1311bca1bf5e1bfee29ea5cb35e6f94142141985d23.json
│ │ │ ├── 4ca6d659e3dbcd21e9bfcdc394e19ad8b4dfb6a26665f3395e5a0d5f41e611ce.json
│ │ │ ├── 4cf64fe0b79aec18c49355f7d82ab04ae66067042a6c364e89842109427ffb5d.json
│ │ │ ├── 4dae69d87cc12f2afcaf76a8124cbb0ed4ed5b1b196563ead3179384bf5cb5a8.json
│ │ │ ├── 4ece1fa07b9b0b7d50f5efe48a083265e3d8efac191ed4a14875c8399522db42.json
│ │ │ ├── 4ed0e6e63784e85d5e256cb6eda996383dad486161209fda91200caa511e8907.json
│ │ │ ├── 4f4799a971a5d6701087428e8c709396c0d947f7ce5bcf7a048cd542d5551c57.json
│ │ │ ├── 4fd59b7f74524c4eb0b9293df963b8aa007039c53e88fef4ca2bd9b61197a114.json
│ │ │ ├── 508a0054ec5c5b3353e94566a0f728d533ec47721e20e6037aa55e2d665a5143.json
│ │ │ ├── 52cd74ad5803b940489587043429b741434ae931fa4d244f5b0dc53582cee050.json
│ │ │ ├── 53705e64605d8b06954f91829d03ceef80bfacf943427a2d51079c554e41a42b.json
│ │ │ ├── 552bcabe2c76a5db8c1f071dbd2b19c31aeee0b07cf605cd5f2b031ea3b6440f.json
@@ -306,23 +372,33 @@ customerTicket
│ │ │ ├── 576178824e99e86dee1ca5fcdf46f61962380f6b8a708b046fbdfc766eb650ce.json
│ │ │ ├── 5794ed0ca9011299d935a3be468c921c65ab616e78f0c6509ec22e3a77cdb794.json
│ │ │ ├── 57bc96866536f0d67300ed29e56bd0f45fa9119c787fac805f228adcab18dc6f.json
│ │ │ ├── 582c91a8ddabbea5521fc65d724e4fb3672c070f9213fae7b58595fcf62f3c85.json
│ │ │ ├── 596a13e9b322f8c3332a3ae214ca7bf368ba6158ff3adcc7db4582f102ea51dc.json
│ │ │ ├── 5a34ded8b2dd4c10c4251d287580918b88690c7058dd2299d2d670170104241d.json
│ │ │ ├── 5b939ad6be2b3eb4da79f453386a58c6ff6661e02d3089092784a774c1957ae4.json
│ │ │ ├── 5bb0895d417e210a10ebf2c63ecf3185dca651057d952203e2e39abc249180a8.json
│ │ │ ├── 5d43f2c9afc3451745d2a1b5f1ed17bc138c9b19caff61beadbc40a4e970b525.json
│ │ │ ├── 5dd23426751ac130946faa8dff76b2200a8a6c5515863890c15ac5c6c3a542d3.json
│ │ │ ├── 5f1bede7e08769186ddcf9e524e41c839ca5d4ead29bff053524cdfa59654e2a.json
│ │ │ ├── 60ef2d6fded9e070e35cfed119e8325010aa76bce63aa74ca0f25005d8ed9517.json
│ │ │ ├── 62dab578f0debe93a8df61a1776953980abd254ae6f1eea77afb00dfefd28f15.json
│ │ │ ├── 62e55882d2ce81dcfe5df1d58395e767ded55d6e39997d82cc3489c5e02ae910.json
│ │ │ ├── 62fba7c993ca0a10198cab8e5a7538f39f88c00face1d324cbf4613effb5ee1b.json
│ │ │ ├── 6469cbb1dbfa0b98762fde08c6fd05cc831e1cf21bd61aebb56b34a7e916bb1a.json
│ │ │ ├── 6506009802a6a268be82b680ace7580613f18dc702ed27aaff929398a85760f3.json
│ │ │ ├── 65c9b0921f2ad9b0228f5685c40d5b341da9604f8cec59339fded4cf0bfa40f4.json
│ │ │ ├── 6802a4e7d920a2a5ce48a61989565f6fa769c314a01b9179cb9d44653b9467ae.json
│ │ │ ├── 682101a547823a97feec07270aac7a9176109731e376f49d3429fe01db3e8719.json
│ │ │ ├── 688d055d3333b15b1beed13c74d14b507a1db1400918f22f01c3bc37bdb4c84e.json
│ │ │ ├── 68f74aa5f41fe3d0e77d36da903e26c49346ee9226c8218a71f7b44c2c41bd93.json
│ │ │ ├── 696bb846cc6a3735e085b502a1008e270903c81c7a7adcc7676abcf1930e926a.json
│ │ │ ├── 699dc95b512c50339855225983dce69519cdcfd21b830ef92b94c885ec7b9f6d.json
│ │ │ ├── 6a7b027b7f77736aea87ce048be3247c43cf4878e8a1105811247839427ebcf8.json
│ │ │ ├── 6b60f8a4e1c97f193edc097890ea2759ec246b8ea09035b2afe8b0b32181f345.json
│ │ │ ├── 6bd49215f8d2739a9e365dcc8855b95cfa34352c067a9e9ff48a623318c4ddaa.json
│ │ │ ├── 6c69494ba124d4bc564bcd3a3df1f33ae0ee0769001a2dfc46825ef8b44dc3e2.json
│ │ │ ├── 6c983917b91998d7130b467295146438ee4a631519fb7c54b582340f90bfc7e6.json
│ │ │ ├── 6d3b094dc56f9b26ce7374ba28e460b85248711223cf516f8e732c5d8f4d9d75.json
│ │ │ ├── 6e56bc8cfd4ce0c21b56fe2b18b7923c24b34cc09ff39276d1438666c54b074f.json
│ │ │ ├── 6ee9154979fb497a8ec229a7177660af53a487e1aa17c7b555cdc67797615162.json
│ │ │ ├── 7093cbd0a855750457814388b6e527fd3b612163f7a2b898ed6ce470dae74843.json
@@ -338,14 +414,25 @@ customerTicket
│ │ │ ├── 7805df77950548e276a1dac439cf36a3620025bdb8a5739e7a84237f38d01a8c.json
│ │ │ ├── 780c2256439479b3eadb498309a4309b20313a36633aa8bec2a0dfcfce3a3d32.json
│ │ │ ├── 78381b49b02bd2a6248eb0363cbceaafe887dbf8e6b19dc513739b35b87cca95.json
│ │ │ ├── 791d193245824d3da6413fd877ab4a079771d29f800f41c9c4774c86f1a903df.json
│ │ │ ├── 79cb5047a177592ad737ee438a5e0de370e5dcd28b164b5a48234a6e4f554b1e.json
│ │ │ ├── 79ede3c31ce20a16af1a767ed248e4a7840803b6d34cf90f1205aa36f3acd7be.json
│ │ │ ├── 7a886aec3fe4d432060a7ad6c88a9e0fd810a122ad1d244456af089a828af8c1.json
│ │ │ ├── 7aff3a4d59fae95d952044a679293bff08c7305212ff12bd7be1d314e1f25a3a.json
│ │ │ ├── 7b25ccdfeab6d57509c1b8a9745fb601ab7e8fcae0e73a22932ceb725fd38c9a.json
│ │ │ ├── 7b6916f499ae0ceae3eb3d509b51f42a7eaa98e43e161fe87c44b9a5bb01287b.json
│ │ │ ├── 7c9789f4b51a967448da1de52e4ac3bfb4b342284bab1257343262aaa32b0d6d.json
│ │ │ ├── 7cd278b7ea82af8777e4da2f84fd1773dae3dd371744f66bbe12c0b925fd6486.json
│ │ │ ├── 7d276789b80c8d9d1677556b3fb33b2da05988e97f6bf28903a044bb9fd64894.json
│ │ │ ├── 7e136779a95d8e8e2ac6cad2192890254fd0d54d46b4f5514debba0f22618d75.json
│ │ │ ├── 7e1c0a38a3ed90205d1829bdef528887ef12c928d4da797e06a79b3e7a1a92ba.json
│ │ │ ├── 80468d8a995fb4092dfe5e57e25592c06713287d48367b675ae4d00955d4b25f.json
│ │ │ ├── 806a3b07b690ca3871b1854aaff6f9042e48cf7aaa9bb9b6f15ab9634eae3b37.json
│ │ │ ├── 80ee0e1cf4fb023c632197378ec4601f72c438df11001fe2648f59ae2aa89199.json
│ │ │ ├── 80f142842848c4420eeec86959e77c24566467dbece6a275dd9c6da15f98551c.json
│ │ │ ├── 8206f93340496777fb5c0c2b431493c2d243c12fb74dc8cac46123d15f9d3f44.json
│ │ │ ├── 82fcc152dd67510406aa894d1e38939bac365093de9f34db4dfe2485806fda4f.json
│ │ │ ├── 833fb89a8d72afc195b8f5bd7338109d8ce7be79f240dac31c4dcd36c8c674f6.json
│ │ │ ├── 835c014cd4e3398cae6e9466790acda90cd37183b9a777ddece3881351026c3c.json
│ │ │ ├── 84579b3014e1a83e4201862df1e93378f5f953fbb547fea30965e335be5e7b1e.json
│ │ │ ├── 84dedd4165eed360c4051690b2aa84f6ecc08f3f053f4129ca6f3e33c334ba35.json
@@ -354,21 +441,29 @@ customerTicket
│ │ │ ├── 88ade181c6611ec33d9bcf5fe739dc809d81875b6571b9f60f87dafb4f5941b2.json
│ │ │ ├── 897df8bbfa3d5a2c272916d47f3326de2b1cad7c3046015c949a46f69e9b8247.json
│ │ │ ├── 899b16856382d32c4e5b39d02cb251bdedaf35c5a81bede5c19bfaff092208e9.json
│ │ │ ├── 8a33ace67f0a10b86cbecbba57107ea42ee61fd12b496085dc98f23d8b300c99.json
│ │ │ ├── 8aeea7acc875fd642cb75f8766dc1fa84e8f91f027c3ced3bf44b8faa1a2798f.json
│ │ │ ├── 8b45a75ab824f6e8b21a94f6828c0da172fd242fadeb1f61b32de34b80f62cee.json
│ │ │ ├── 8b7bbf32006c5ceccb03e4965f57199e55c6309eddcd09e544d7e64e8e383cae.json
│ │ │ ├── 8c258f28e7a23d569a250b17e2f591ea2b1a5b9072ab1e0ddff1b535468badb7.json
│ │ │ ├── 8e216def3e5fce2d7f4db160b9bc86433bdb5f0638be1367af2ab095d06571b6.json
│ │ │ ├── 8fd86558bd11e7a00f6b28cfad545707ef94afd87a9095ab532588b36c49f842.json
│ │ │ ├── 8fe2c3236c2b05a4814f32898daea17b341332cd0cc887df7a154ad999480d75.json
│ │ │ ├── 9141ebe765f15cd7e3b24431f315af03e41a165ff0a54c33bec2c4884515eff2.json
│ │ │ ├── 91d83c62f39f219ccb6125502bf8e0a71a587dfc0a66d977a9bb93c8f2dc5dc6.json
│ │ │ ├── 9326d87e36b2dc24e6173aa3346f46e7df9fc3353fdb409304af4363c286a41e.json
│ │ │ ├── 939d30ca6a3e0c8379078425d21de30c3e8c2743ddf3e13efd984becbf68d109.json
│ │ │ ├── 9697c37cf3a88d2b4eb5419b2381951a9aca966172316db79e7b4c32557d5e87.json
│ │ │ ├── 972650f72e30ee3a72cd37dedff2f0ba823b355300dd68c5e196f87717bd5bbe.json
│ │ │ ├── 9729a8690e1957a356322dc991c0618b49605bd58709dcf8ce3ab1af7d254a1c.json
│ │ │ ├── 97506546de142970349e57e8a14064280e66a4fcdbced5dac55fc263630a2247.json
│ │ │ ├── 97d8ebb860cbcd63be9ef80c6e2094e4a7ccba668b291d3c4815133d5fb8f37e.json
│ │ │ ├── 980f1be05d605b9093619724462c9881a8be063a5439567866172a35f8d22316.json
│ │ │ ├── 9931a5b7d7ab58794d003cce1d0719c201c623a844c7f834f66c88e9390c824e.json
│ │ │ ├── 996411678d23c3cf8a0e6c1c5076701e8bf5f3dad21ca907ec4116d15366a668.json
│ │ │ ├── 99f94b7b3b60f627c482cc195709b98cd2faa55f14c19c4bf5e0af8a88f34b7a.json
│ │ │ ├── 9a6a5b3e4a7ac5efbe662c2a44a7d9400c2c46f18f15c5dd3627deab4bd7c8d7.json
│ │ │ ├── 9b267b0c45b93cecc1294ca1e040e5a18fdc9e0bee69a0ca8571ee11becdee97.json
│ │ │ ├── 9b8bd2aff50e08b6f053b74a409c9439db41e692e29aabca5b67057884d945af.json
│ │ │ ├── 9bc00a73e1e7393f535b9f58edc50d42169c407c58af664ffdf838c533bd29cf.json
│ │ │ ├── 9ce16f69c6b04f703adb24ec7498a2d2ecbf6f7524c459deb25111247e550698.json
@@ -376,20 +471,31 @@ customerTicket
│ │ │ ├── 9e5144f3933fd4424f1e271f60b78ae110855d238a7921b499b1586d21ad16a9.json
│ │ │ ├── 9eb524ba1a6374b2d29429652e7e8bf087d839fecd65494380982eab349382a8.json
│ │ │ ├── 9ed5e291859abba38b07725bc6cf0275a1548e937888a5ba177674afaa48b96f.json
│ │ │ ├── 9edcaa74bc64450dff4e25af0b02a830bf940c608b2cfaf74092a2d830f8b07d.json
│ │ │ ├── 9ef0e904ba6d1b295c75a1d5741851f571339a8e22c7de96bf5e2508f764ee43.json
│ │ │ ├── a04a8ed5ca0ac9661dda4f6f369248937cf67e61cc982a1f17b24495263d53d6.json
│ │ │ ├── a05b3a5bb6e91642075fb765d43a0a6e4dc595bb9131be86643e6d06e6fc3cd9.json
│ │ │ ├── a063cce1553aca090b61f3b2b621a089d1c128ff347e11f836857b77ef5f3d56.json
│ │ │ ├── a081526ffd8f52690e0834cc71e9d2bf987f949c2d0de68a6eb024063d97e64b.json
│ │ │ ├── a1bd5a32741129c967a18413ae1be6bf3e20754251066d342d652a668ef95025.json
│ │ │ ├── a1d213c52d246524be10c084f9134e77ff2e2a9aa4361b71dc4bedc89f461cdf.json
│ │ │ ├── a3c770875ffabc18c595532501255df82b1a09246ee54b831e7c2221932d4f88.json
│ │ │ ├── a3f339fe1ccb99a051c1a3bff70e7de0e2ee6b952db0aa2d6739e97331c29cd9.json
│ │ │ ├── a4a27e7b3842713c361895d6d9c5fc9e4341cce71c5842f014748e1c1c23a197.json
│ │ │ ├── a5231d68fe35e6b02ff4ff70e02a3229c08d9b6c2bae774a1b3227823b95e15a.json
│ │ │ ├── a5c3aedceeab625bb2f60c9e97163491579353e685d8b230342a4e3e30f00db6.json
│ │ │ ├── a7729f0b6bf20ded62ff7b6ee678fdaae9516dcd80c2a8f9e9f3d9299f6e22a0.json
│ │ │ ├── a7b036e5bd51876694ebd883bd1b0a527c5f8409b48d4716612311e954a8cc0f.json
│ │ │ ├── a8274003d8d4ef62b23c66e3baecf14eea260f99a4509411d58506f560c2b474.json
│ │ │ ├── a90d163d984bed9136db40b58b442e5ffa6561b2b6cca5ea6dc45fcb70b11299.json
│ │ │ ├── a93b84a0def4d2e0e97ff35032871b0ce64786ff468912aed21fd958b9fe782b.json
│ │ │ ├── aa56d5609649d9327e5f4cf6d02b5e8afb7749388f6d4f8776dcf910e5453935.json
│ │ │ ├── ab1648cc3b4d4bba5754e19eeb80ea160fd20207d5530ac371647168f9ed211a.json
│ │ │ ├── ac454f6bfed5913148eb8c9caaa0e30f99bac381936b44612bdee8274bb27b29.json
│ │ │ ├── ad1175b041ab42a2615856a41fbab394ae63ef9dd991901c0658c3fb0846ad3f.json
│ │ │ ├── aeb59c5e1447023f9c49003f6bf4eb26a7b9a34ca5658f7ba2cde1b99e25ec76.json
│ │ │ ├── aee78d4e87210ef46180d47e7015345bd66a7763cd140d84cd86539e720e8e67.json
│ │ │ ├── af49d8d606d42a439479739b7b89b8b13fd99adc7ef7c0dbfc456229e6690e6d.json
│ │ │ ├── aff120c654cfc097d07e9078775b97e4f066e7c539b21b49397a372086d45543.json
│ │ │ ├── b390f7bae9f80c48e703a6664d9de6ec9c141cdf8ac2f012d67ac2b4d8a25b4e.json
│ │ │ ├── b39d104e50bfbd13e83fdf946668708536d98cd0c6228eded5fabd5bcf6ddd18.json
@@ -409,57 +515,85 @@ customerTicket
│ │ │ ├── be6cc64f9c2a8edbdbbd10a1a31e3b3a746eb4bddf806c71d525eff5719b9217.json
│ │ │ ├── beb1b14e942397ae4b9f2e8a743282f127cc9f8c6e7222db7a28185de4493e57.json
│ │ │ ├── c1060dca2a3f437f386324975be659215fe9f7e689137dc85e823879b462d11f.json
│ │ │ ├── c10793a36312ddf2bd8ed9edbae5d67a19f54b78ac3cae01682d38c34f2bdf11.json
│ │ │ ├── c1943dfe9eee1a9f88ea571dc5aa06a3f521241fffa51d55b1414c675e63c236.json
│ │ │ ├── c199fcf03e3df0de15c0e7fa5f89fbd9649f2be4a257c8de9a7ebc69ce0d629a.json
│ │ │ ├── c1a36d35264d786572e00bd20a4d62c18000e4a5a744b4bbd585c66ba2b1afa4.json
│ │ │ ├── c251d835538bf3c08a96268c8b1feaae8084a9c298d634ce2f2ee3b4b15188ce.json
│ │ │ ├── c2b1cd2a2842bf81134b918097c420440c56e69a46b7796fa07d02b2db7a3fab.json
│ │ │ ├── c4878d8d8ae19e705821ae40afc1ede4be1a64b559dfdf89f898cb4383718c93.json
│ │ │ ├── c8c8647cd740fbc96327657baebd4b4a16bfa1d8e7d4cfe3f845a0a5ab7c98f7.json
│ │ │ ├── ca42945436e914d9530e20dc780794451ec6ab51e17414e8c9107ccad3125fd5.json
│ │ │ ├── ca8e9d2e76f32d42a0da88c9477956b47dca6cdb27504d8d126e0075306eae98.json
│ │ │ ├── caac8d56f4ba7b3987d86c31357c6ffd2c4125d16e4637cb5a4568ac6eee29de.json
│ │ │ ├── cb744c0a1cb5fa65e239993bee622ad4a60990c628cd1d4503f9e8806d1d2c92.json
│ │ │ ├── cb87cd70f59a8e0950529ae42a2229a259ff8b305e4319422fb1a9dbc41e63e2.json
│ │ │ ├── cc5e32b33a50eb2225359c15bceebc406d04a07218cda65de3e6f79603d67416.json
│ │ │ ├── ccd15ad60b63cd29e2bd2762133a74e2047a2894fd67206dff7f7c7fd082b399.json
│ │ │ ├── cd4804092002a20d5ed88c32b577b0868a0ef39e518c749dbadec455775917fb.json
│ │ │ ├── cdeaabefa47597c8be9310653d52fb6870d859162b49c8dae6db8f4f699dc4cc.json
│ │ │ ├── ce52c1ffbf8db0d0b4ba85e0a07055258a8eb179db097f1df874e5be81aae49c.json
│ │ │ ├── cf1725a1db0d7ecffa63d8b1b693854170e55385eaa4b0cc604f9d6ab204eb4e.json
│ │ │ ├── d0765ede907ec0d299b023be2178bd7c85b9d37463b94c29454a904069676cc1.json
│ │ │ ├── d083dc75811629ffa77ea30c1965e67ef3bc2bf65e5b20a1a35cb0f80a46949c.json
│ │ │ ├── d210ed7569b9e06acf7037d9a7d15ccac0f6f95ef7d3a0707741db8629443177.json
│ │ │ ├── d22b1453361617f8ed7de22e5aace7a02cfe67049aad5fa4b1849d9f8b42b73e.json
│ │ │ ├── d2513d4f5ff7e6088864ff497613f47d4083c1ca378649a3f1db6d45a3f3fb2b.json
│ │ │ ├── d26a8fc663e9e5fcf33c87592f8c0456e7fe9ec3390bcb902631144d455c2922.json
│ │ │ ├── d48290356397f2b79e05fc0b6bc6f9c327da366096ff14c15e90516fad7218a1.json
│ │ │ ├── d55e8987ca5ff35748b62290dc14150b1df53163c6f5a5cbe19d03058368f1c6.json
│ │ │ ├── d561ececcdd4e7ff59595389fa499591f4e7f3c5324a58dbdd749ccf29e90cd1.json
│ │ │ ├── d5dec4474de6c46020e987f20aa3fe54c9bb9c6726a067a75ed94091d552b87f.json
│ │ │ ├── d8cda86cbe3f42bb98bb0097071d95924950dfc6ed65a7075d3b22ff1ae0bf0b.json
│ │ │ ├── d9516c3232e0f78225764bf4f9586c88932df925c45de71f4eb1c883a9ed5b5d.json
│ │ │ ├── d9c81db9cf9f2fee8a4985367d33b1c0ab1d7e6901e85473070c3bfd6bfe32c6.json
│ │ │ ├── daa187dac6877c746c02269141a7e7db178ac32a0b3a4bfeaaa8ed046b6dcffb.json
│ │ │ ├── db04dbc0e2dca073d2b145db8ba6c2c5299d3fd93742e1022c611c938fcb4d80.json
│ │ │ ├── dc90ac5e8d6766f8001bc2a40d21c203717d7a9f469956c2f6d9bedf3ae51aea.json
│ │ │ ├── dd795078450fe69f61a445d3cf1275420be57381b005597cb5003859b2025800.json
│ │ │ ├── df2d803fc69084e91df819bda27a7e9f7303a40ff7a5d30d93fbc16daf5e2d45.json
│ │ │ ├── dfc9279b0d9023afc1dbe55536fc1cd7a556a6a437775128b9e41615431b4733.json
│ │ │ ├── e0373f56a7e20838a75d2c1920393433e8d9a6ffa919334851b70f210ed4dbf3.json
│ │ │ ├── e15f9a7e0698366b7d8b4bee6102d351241860a078550f47a1eb27503e521bbc.json
│ │ │ ├── e2c372f92146217844d34664334cb0d7f01fe262ff98edf009a66c4bf4743b5c.json
│ │ │ ├── e2f4f5c0bcb971ba3cb08a4ba14ed5d1ba6b5899fd36db1f5dcdbd2313cd318e.json
│ │ │ ├── e359eb0e19f76b69decc0ec747a7fb4c9843046bda31c5e45841858e01bcb5a7.json
│ │ │ ├── e42f6a96ad8938a6ab248b342feca7ec6ecf2a734d25141f62dbc9a6c52ecf6b.json
│ │ │ ├── e43b7eb1fd2770d280dedb21e9a453279f3aa8262b2a0409fffe8fa9f7016880.json
│ │ │ ├── e53a0dfeecd1b547e38896f8e7920e6801d84dbf61bb4513541892d44d42ae99.json
│ │ │ ├── e6b4123e64b20d577378853fe71efb3743dbe9aff24ca06067b4a2c1c2efc53c.json
│ │ │ ├── e7c0ad70ad7e7f3a1f6f35e55f4cf87f6c0850abc63de71a418e81b88ec826b7.json
│ │ │ ├── e831be2a985dcf2094a014d182ea02e4ee10b94fbd35b44e3c69a98a7acb3451.json
│ │ │ ├── e90aa709aa08fcf3294352b588ce8c8c902f3ef59a503d4b8d9a292a9c478096.json
│ │ │ ├── e974cd0006d8160a6ef1316083bac9e699adfd87acc7edb404ae7eba1c799b68.json
│ │ │ ├── ea42f92a2b1964458b3c1060b13a607581b30e9b43d5e031f8a263cae90ba581.json
│ │ │ ├── ea56445d1314587e18b1992d9599da03e1419ea7bc4929854b9039d6947de33b.json
│ │ │ ├── ea9a2571f9019bc6436c81714fa79bf8321273fc60de3ce5346893ee2f3dead3.json
│ │ │ ├── eb1dd65efa5a99811ccca80ff03a73cb155ad86d13635010a44c09c13953604d.json
│ │ │ ├── eb782e7a5b5695997a5bf628a22f4d36f5ddb886e5bdf4b2f4ae53ecc46fbca1.json
│ │ │ ├── ec7939ed122c473ca1a4eee50f1c564e04e95ddced18cca56ed83efe5d4071dd.json
│ │ │ ├── edfca154606b071ac823ab03572797546c6044eda88073e9e3f55b4960cb3400.json
│ │ │ ├── ee19f3245c11f658fb60d32050a8bc4864ea289071ff5f829717ecacbac1d3e4.json
│ │ │ ├── ee89093aa638ec9d38b76ba7adb66c8424d941d79deba2913d3d7085c1a062f7.json
│ │ │ ├── f06f026d4fb736e9628a73bfaa9ccfe00efc006be79cb1c27f4a77ede5874f52.json
│ │ │ ├── f12416bacd2eea21842fe89fcfc2c02ffe84619d7ce380d13679b64c6ec7496d.json
│ │ │ ├── f150f764dc46f341076a8b373709a9cfefe48476672882b2a979df4624aa2626.json
│ │ │ ├── f2069429b170b19df531b051a2f5fa81854a7bedf1a036aab1a46c89a9ba4c92.json
│ │ │ ├── f2287c55ccdc0e5e9ab12f79895b1a1f46b65b2d14f70915ceb09adfc624e3b8.json
│ │ │ ├── f411c6e74a1dc71e5f7a16f159d065410a8562fd545dd209179b54ae2e848e10.json
│ │ │ ├── f4436ad45c3f2a27b4461333ae2ed21af157a3945e152fca7f43d640dc39f63b.json
│ │ │ ├── f576af4a0cecb6cbaaf8afe5097913be0b7071e200fc924c918ea93446f00c91.json
│ │ │ ├── f58927b2be96cb8769bb26a2ede3d8c05796e7a79fefd7fdcdc89d658609fc66.json
│ │ │ ├── f71ec68c7b2a4467358d618354305f3f4e99cc0dbe60fa00d39478b686fe8bb2.json
│ │ │ ├── f8769e3eeb23aebc01d72869a2bc6e635d7dc82440bc2b0b0e11e6b6b17f5109.json
│ │ │ ├── f8fc55a028176d1ea9a1b580a8653d4c45ae7466445f37e6c23867f12821c44c.json
│ │ │ ├── f92455d5b493a0d7e9565d2c7463a1aa3fe62fa20ea19e635e66c35582ab685e.json
│ │ │ ├── fa19a1bc8027144f14106a6f90d4ddab0277580b8bcdb6e9e3ba67496c043d62.json
│ │ │ ├── fb9736a4ef3aca74573f131c7cbd9f015d88dd755ffba3edd324aec95149175f.json
│ │ │ ├── fc313334fba5b89ae7b30c44e1b0bd43e185bfd06c1e9804ad1b9b6de65efad8.json
│ │ │ ├── fde577f1231f9b033560bfe075896efc6bf8d6079a030a159035584c7d39843b.json
│ │ │ ├── fe032050843e4e1c28f5712205df74fa1698390169658c4ddc4555b6bcce8872.json
│ │ │ ├── fe0c3eed2ff5c4b5d2116aba4494d599ae3c27004136e99f5d455f351281fa78.json
│ │ │ ├── fe77fbd662d91d04f2efc69788d30e9859584c8d87d1fabd60ca66661568091e.json
│ │ │ ├── fe93d0fb278c013183276f7bf5c5e2d0e62fe9c75f6695390f5994ec0b9c8dec.json
│ │ │ └── ff1bf9257c3d267b5a04560ed07ac6d4442a145b3e6db5a0f8e63e8e7f386533.json
@@ -534,7 +668,26 @@ customerTicket
│ │ ├── query_20260910_053611_613a8cc4_qa_role_organization_unit_permission_scope_workflo.md
│ │ ├── query_20260910_060824_3faac489_in__tickets_new__place_unit_and_category_side_by_s.md
│ │ ├── query_20260910_064145_f562e795_برای_واحدهای_فروش_و_مالی_حسابداری_دسته_بندی_های_مش.md
│ │ └── query_20260910_065345_ff4c3175_جستجوی_دسته_بندی_های__tickets_new_search_را_حرفه_ا.md
│ │ ├── query_20260910_065345_ff4c3175_جستجوی_دسته_بندی_های__tickets_new_search_را_حرفه_ا.md
│ │ ├── query_20260910_075602_ae6c7fbd_production_deployment_with_coolify__external_postg.md
│ │ ├── query_20260910_125054_05ced73b_create_a_production_seed_from_the_current_sqlite_d.md
│ │ ├── query_20260910_134049_86119b32_تبدیل_from_python_3_11_slim_as_builder_به_from_pyt.md
│ │ ├── query_20260910_140232_c05525c7_علت_خطای_postgresql_name_or_service_not_known_در_ا.md
│ │ ├── query_20260911_083025_6392fd14_شبکه_external_با_نام_coolify_را_در_docker_compose.md
│ │ ├── query_20260911_085318_37a0a65e_در_deployable_docker_compose_و_تصویر_تنظیمات_cooli.md
│ │ ├── query_20260911_090701_a5c9c0ec_اصلاحات_routing_فایل_های_static_و_اجرای_یک_باره_se.md
│ │ ├── query_20260911_093724_97fb1f0a_بازهم_مشکل_حل_نشد__لطفا_بررسی_دقیق_تری_انجام_بده_ا.md
│ │ ├── query_20260911_095201_b5c43bde_ناموفق_بود.md
│ │ ├── query_20260911_095829_124db60a_deployable_docker_compose_in_coolify.md
│ │ ├── query_20260911_100145_f7600109_آیا_استفاده_از_labels_در_git_based_docker_compose.md
│ │ ├── query_20260911_102142_fb30dba3_ارزیابی_پیشنهاد_unhealthy_و_reverse_proxy_کردن_dja.md
│ │ ├── query_20260911_102916_548d68f4_چرا_nginx_در_coolify_برای_healthz_و_static_پاسخ_40.md
│ │ ├── query_20260911_143307_b8e47560_صف_عمومی_هر_واحد__جداسازی_پیام_های_staff__redirect.md
│ │ ├── query_20260912_052547_361db71f_در_پیامک_تیکت_های_ایجادشده_توسط_staff_و_ارسال_گروه.md
│ │ ├── query_20260912_090237_9dcd72d5_چرا_مدیر_کل_در__staff_contacts__خطای_500_می_گیرد_و.md
│ │ ├── query_20260913_163601_80a69700_بهبود_ui_ویرایش_عضویت_مخاطب_در_گروه_ها_و_افزودن_آپ.md
│ │ ├── query_20260913_171403_c1a197d5_افزودن_chat_id_بله_و_سه_شماره_سرپرست_به_هر_واحد__ا.md
│ │ └── query_20260913_171644_3abcf227_اگر_شناسه_گروه_بله_و_هر_سه_شماره_سرپرست_واحد_خالی.md
│ ├── reflections
│ │ └── LESSONS.md
│ ├── .graphify_analysis.json
@@ -553,9 +706,14 @@ customerTicket
│ ├── graph.json
│ ├── GRAPH_REPORT.md
│ └── manifest.json
├── production-seed
│ ├── data.json.gz
│ ├── manifest.json
│ └── media.tar.gz
├── static
│ ├── css
│ │ ├── app.css
│ │ ├── customer-ticket-wizard.css
│ │ ├── dispatches.css
│ │ ├── enhancements.css
│ │ ├── operations.css
@@ -575,7 +733,9 @@ customerTicket
│ ├── js
│ │ ├── attachment-uploader.js
│ │ ├── chat-coach.js
│ │ ├── contact-group-selector.js
│ │ ├── conversation.js
│ │ ├── customer-ticket-wizard.js
│ │ ├── image-viewer.js
│ │ ├── location-picker.js
│ │ ├── pwa.js
@@ -647,12 +807,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_organization_permissions_e2e.py__test_e2e_02_contact_group_picker_excludes_other_units
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_organization_permissions_e2e.py__test_e2e_05_06_limited_operation_owner_and_ceo_view_all[BULK-03]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_organization_permissions_e2e.py__test_e2e_05_06_limited_operation_owner_and_ceo_view_all[INQUIRY-04]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_organization_permissions_e2e.py__test_unit_change_renders_experts_when_a_legacy_cached_payload_has_no_queue
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_public_ticket_workflows_e2e.py__test_e2e_07_sales_general_queue_creates_verifiable_ticket
│ │ │ ├── failure.png
│ │ │ └── trace.zip
@@ -677,6 +843,18 @@ customerTicket
│ │ ├── tests_e2e_test_public_ticket_workflows_e2e.py__test_e2e_12_sequential_unit_staff_category_and_attachment_state
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_public_ticket_workflows_e2e.py__test_e2e_customer_wizard_auto_stops_voice_and_submits_audio_only_ticket
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_public_ticket_workflows_e2e.py__test_e2e_customer_wizard_validation_preview_edit_and_floating_submit
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_public_ticket_workflows_e2e.py__test_e2e_experts_follow_configured_numeric_order
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_public_ticket_workflows_e2e.py__test_e2e_subject_stays_empty_for_manual_and_search_routing_until_user_types
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_pwa_versioning.py__test_pwa_registers_versioned_worker_and_matching_cache
│ │ │ ├── failure.png
│ │ │ └── trace.zip
@@ -708,12 +886,14 @@ customerTicket
│ ├── test_branding_operations.py
│ ├── test_customer_category_catalog.py
│ ├── test_customer_unit_categories.py
│ ├── test_deployment.py
│ ├── test_domain.py
│ ├── test_env.py
│ ├── test_jalali_templates.py
│ ├── test_organizational_ticketing.py
│ ├── test_permission_architecture.py
│ ├── test_persian_text.py
│ ├── test_production_seed.py
│ ├── test_role_workflow_regression.py
│ └── test_views.py
├── .dockerignore
@@ -725,8 +905,11 @@ customerTicket
├── .npmrc
├── AGENTS.md
├── db.sqlite3
├── db.sqlite3-shm
├── db.sqlite3-wal
├── docker-compose.yml
├── Dockerfile
├── Dockerfile.nginx
├── manage.py
├── package-lock.json
├── package.json
@@ -23,10 +23,15 @@ def _magic_link(job, reason):
token,link = TicketMagicLink.issue_with_record(job.ticket, reason=reason, created_by=job.staff_user or job.ticket.owner or job.ticket.created_by)
return link, f"{settings.PUBLIC_BASE_URL}/t/{token}/"
def _sms_context(job,magic_link):
profile=getattr(job.customer,"customer_profile",None)
def _sms_context(job,magic_link=""):
recipient = job.customer or job.ticket.customer
profile=getattr(recipient,"customer_profile",None)
unit=job.ticket.owning_unit
return {"magic_link":magic_link,"first_name":getattr(profile,"first_name","") or "","last_name":getattr(profile,"last_name","") or "","recipient_name":" ".join(filter(None,[getattr(profile,"first_name","") or "",getattr(profile,"last_name","") or ""])),"unit_name":unit.name if unit else "","company_name":getattr(settings,"COMPANY_NAME","لوله گستر گلپایگان"),"ticket_number":job.ticket.ticket_number,"subject":job.ticket.subject,"province":job.ticket.province or getattr(profile,"province","") or "","city":job.ticket.city or getattr(profile,"city","") or "","expert_name":job.ticket.assigned_expert_name}
customer_profile=getattr(job.ticket.customer,"customer_profile",None)
first_name=getattr(profile,"first_name","") or ""
last_name=getattr(profile,"last_name","") or ""
recipient_name=" ".join(filter(None,[first_name,last_name])) or "ثبت نشده"
return {"magic_link":magic_link,"first_name":first_name,"last_name":last_name,"recipient_name":recipient_name,"unit_name":unit.name if unit else "","company_name":getattr(settings,"COMPANY_NAME","لوله گستر گلپایگان"),"ticket_number":job.ticket.ticket_number,"subject":job.ticket.subject,"province":job.ticket.province or getattr(customer_profile,"province","") or "","city":job.ticket.city or getattr(customer_profile,"city","") or "","expert_name":job.ticket.assigned_expert_name,"customer_mobile":job.ticket.customer.local_mobile or "ثبت نشده","category_name":job.ticket.category.title}
def _render_sms(event_type,context,unit=None):
from string import Formatter
@@ -65,10 +70,7 @@ def _send_sms(job):
recipient = job.payload.get("recipient_mobile", "")
if not recipient:
raise ValueError("Unit supervisor mobile is not configured")
subject = " ".join(job.ticket.subject.split())[:80] or "بدون موضوع"
category = " ".join(job.ticket.category.title.split())[:50]
customer_mobile = job.ticket.customer.local_mobile or "ثبت نشده"
message = f"تیکت جدید {job.ticket.ticket_number}\nتلفن: {customer_mobile}\nموضوع: {subject}\nدسته: {category}"
message = _render_sms(job.event_type, _sms_context(job), job.ticket.owning_unit)
return get_sms_provider().send_message(recipient, message), issued
if job.event_type == "CUSTOMER_TICKET_CREATED":
issued, url = _magic_link(job, "customer_created_ticket")
@@ -0,0 +1,56 @@
from django.db import migrations, models
EVENT = "UNIT_SUPERVISOR_NEW_TICKET"
TITLE = "اطلاع تیکت جدید به سرپرستان واحد"
BODY = "تیکت جدید {ticket_number}\nتلفن: {customer_mobile}\nموضوع: {subject}\nدسته: {category_name}\nارجاع: {expert_name}"
TAGS = [
"magic_link", "first_name", "last_name", "recipient_name", "unit_name",
"company_name", "ticket_number", "subject", "province", "city",
"expert_name", "customer_mobile", "category_name",
]
def seed_supervisor_template(apps, schema_editor):
sms_template = apps.get_model("notifications", "SMSTemplate")
sms_template.objects.get_or_create(
unit=None,
event_type=EVENT,
defaults={"title": TITLE, "body": BODY, "allowed_tags": TAGS, "is_active": True},
)
for template in sms_template.objects.all().iterator():
allowed_tags = list(template.allowed_tags or [])
updated_tags = list(dict.fromkeys([*allowed_tags, "customer_mobile", "category_name"]))
if updated_tags != allowed_tags:
template.allowed_tags = updated_tags
template.save(update_fields=["allowed_tags"])
def remove_supervisor_template(apps, schema_editor):
apps.get_model("notifications", "SMSTemplate").objects.filter(
unit__isnull=True, event_type=EVENT, title=TITLE, body=BODY
).delete()
class Migration(migrations.Migration):
dependencies = [
("notifications", "0011_notification_delivery_tracking"),
("staff", "0011_organizationalunit_supervisor_mobile_4"),
]
operations = [
migrations.AlterField(
model_name="smstemplate",
name="event_type",
field=models.CharField(
choices=[
("CUSTOMER_TICKET_CREATED", "ثبت تیکت توسط مشتری"),
("STAFF_TICKET_CREATED", "ثبت تیکت توسط کارشناس"),
("STAFF_REPLY", "پاسخ کارشناس"),
(EVENT, TITLE),
],
max_length=50,
),
),
migrations.RunPython(seed_supervisor_template, remove_supervisor_template),
]
+2 -1
View File
@@ -5,7 +5,7 @@ from apps.core.models import PersianTextNormalizationMixin
def sms_template_tags():
return ["magic_link", "first_name", "last_name", "recipient_name", "unit_name", "company_name", "ticket_number", "subject", "province", "city", "expert_name"]
return ["magic_link", "first_name", "last_name", "recipient_name", "unit_name", "company_name", "ticket_number", "subject", "province", "city", "expert_name", "customer_mobile", "category_name"]
class SMSTemplate(PersianTextNormalizationMixin, models.Model):
persian_text_fields={"title":False,"body":True}
@@ -13,6 +13,7 @@ class SMSTemplate(PersianTextNormalizationMixin, models.Model):
CUSTOMER_TICKET_CREATED="CUSTOMER_TICKET_CREATED","ثبت تیکت توسط مشتری"
STAFF_TICKET_CREATED="STAFF_TICKET_CREATED","ثبت تیکت توسط کارشناس"
STAFF_REPLY="STAFF_REPLY","پاسخ کارشناس"
UNIT_SUPERVISOR_NEW_TICKET="UNIT_SUPERVISOR_NEW_TICKET","اطلاع تیکت جدید به سرپرستان واحد"
unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.CASCADE,null=True,blank=True,related_name="sms_templates",help_text="خالی یعنی الگوی عمومی و fallback.")
event_type=models.CharField(max_length=50,choices=Event.choices)
title=models.CharField(max_length=120)
+2 -2
View File
@@ -15,13 +15,13 @@ class UnitMembershipInline(admin.TabularInline):
class OrganizationalUnitAdmin(admin.ModelAdmin):
list_display = ("name", "slug", "bale_chat_id", "sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active", "updated_at")
list_filter = ("show_in_customer_form", "accepts_external_tickets", "is_active")
search_fields = ("name", "slug", "bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3")
search_fields = ("name", "slug", "bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4")
list_editable = ("sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active")
filter_horizontal = ("permissions",)
inlines = (UnitMembershipInline,)
fieldsets = (
("مشخصات واحد", {"fields": ("name", "slug", "description", "search_keywords", "sort_order")}),
("اطلاع‌رسانی بله و سرپرستان", {"fields": ("bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3")}),
("اطلاع‌رسانی بله و سرپرستان", {"fields": ("bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4")}),
("نمایش و دسترسی", {"fields": ("permissions", "show_in_customer_form", "accepts_external_tickets", "is_active")}),
)
@@ -0,0 +1,13 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("staff", "0010_organizationalunit_notification_destinations")]
operations = [
migrations.AddField(
model_name="organizationalunit",
name="supervisor_mobile_4",
field=models.CharField(blank=True, max_length=16, verbose_name="شماره همراه سرپرست چهارم"),
),
]
+4 -2
View File
@@ -36,6 +36,7 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
supervisor_mobile_1 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست اول")
supervisor_mobile_2 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست دوم")
supervisor_mobile_3 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست سوم")
supervisor_mobile_4 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست چهارم")
class Meta:
ordering = ["sort_order", "name", "pk"]
@@ -52,6 +53,7 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
self.supervisor_mobile_1,
self.supervisor_mobile_2,
self.supervisor_mobile_3,
self.supervisor_mobile_4,
))))
def clean(self):
@@ -60,7 +62,7 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
normalized = []
errors = {}
for field_name in ("supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3"):
for field_name in ("supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4"):
value = getattr(self, field_name, "").strip()
if not value:
setattr(self, field_name, "")
@@ -80,7 +82,7 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
def save(self, *args, **kwargs):
from apps.accounts.models import normalize_mobile
for field_name in ("supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3"):
for field_name in ("supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4"):
value = getattr(self, field_name, "").strip()
setattr(self, field_name, normalize_mobile(value) if value else "")
self.bale_chat_id = self.bale_chat_id.strip()
+40
View File
@@ -112,6 +112,46 @@ def refresh_group_dispatch_status(dispatch_id):
dispatch.status=status
return dispatch
@transaction.atomic
def resolve_group_dispatch_tickets(*, dispatch, user):
"""Resolve every non-final ticket created by one group dispatch."""
dispatch = GroupDispatch.objects.select_for_update().select_related("unit").get(pk=dispatch.pk)
permitted = user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(
user, dispatch.unit, "tickets.change_ticket"
)
if not (DispatchPolicy.can_view(user, dispatch) and permitted):
raise PermissionDenied("اجازه حل‌کردن تیکت‌های این ارسال گروهی را ندارید.")
now = timezone.now()
tickets = list(
dispatch.tickets.select_for_update().exclude(
status__in=(Ticket.Status.RESOLVED, Ticket.Status.CLOSED)
)
)
if not tickets:
return 0
previous_statuses = {ticket.pk: ticket.status for ticket in tickets}
for ticket in tickets:
ticket.status = Ticket.Status.RESOLVED
ticket.resolved_at = now
ticket.updated_at = now
Ticket.objects.bulk_update(tickets, ["status", "resolved_at", "updated_at"])
TicketEvent.objects.bulk_create([
TicketEvent(
ticket=ticket,
event_type=TicketEvent.Type.STATUS_CHANGED,
actor=user,
metadata={
"from": previous_statuses[ticket.pk],
"to": Ticket.Status.RESOLVED,
"group_dispatch_id": dispatch.pk,
},
)
for ticket in tickets
])
return len(tickets)
@transaction.atomic
def claim_ticket(ticket_id,user):
ticket=Ticket.objects.select_for_update().get(pk=ticket_id)
+1 -1
View File
@@ -1,4 +1,4 @@
from django.urls import path
from . import 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("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("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>/",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("",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("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>/",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")]
+24 -4
View File
@@ -31,7 +31,7 @@ from .forms import ContactGroupForm,GroupDispatchForm,ReplyForm,ShareForm,StaffC
from .models import GroupDispatch,Ticket,TicketCategory,TicketReadState,TicketTag
from .policies import DispatchPolicy, TicketPolicy
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,revoke_access,share_ticket,transfer_ticket
from .services import change_priority,change_status,claim_ticket,create_ticket,dispatch_group_tickets,refresh_group_dispatch_status,reply_ticket,resolve_group_dispatch_tickets,revoke_access,share_ticket,transfer_ticket
MESSAGE_PAGE_SIZE=50
def conversation_page(queryset,before=None):
queryset=queryset.order_by("-created_at","-id")
@@ -389,7 +389,7 @@ def staff_customer_search(request):
return JsonResponse({"results":results})
@staff_required
def staff_detail(request,public_id):
ticket=get_object_or_404(Ticket.objects.select_related("customer__customer_profile","category","owner__staff_profile","selected_expert__staff_profile"),public_id=public_id)
ticket=get_object_or_404(Ticket.objects.select_related("customer__customer_profile","category","owner__staff_profile","selected_expert__staff_profile","group_dispatch__unit","group_dispatch__contact_group"),public_id=public_id)
if not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied
conversation,has_older,older_cursor=conversation_page(ticket.ticket_messages.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"))
mark_messages_read(conversation,request.user)
@@ -397,7 +397,10 @@ def staff_detail(request,public_id):
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]
sms_jobs=ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at")
latest=conversation[-1] if conversation else None
return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":ShareForm(),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"sms_jobs":sms_jobs,"viewer_user":request.user,"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""})
group_dispatch = ticket.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
return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":ShareForm(),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"sms_jobs":sms_jobs,"viewer_user":request.user,"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""})
@staff_required
def staff_messages(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id)
@@ -612,7 +615,24 @@ def staff_dispatch_detail(request, public_id):
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}
return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "sms_max_attempts": 1 + settings.SMS_MAX_RETRIES, "active_staff_section": "dispatches"})
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"})
@staff_required
@require_POST
def staff_dispatch_resolve(request, public_id):
dispatch = get_object_or_404(GroupDispatch, public_id=public_id)
try:
count = resolve_group_dispatch_tickets(dispatch=dispatch, user=request.user)
messages.success(request, f"{count} تیکت این ارسال گروهی حل‌شده علامت‌گذاری شد." if count else "همه تیکت‌های این ارسال گروهی از قبل حل یا بسته شده‌اند.")
except PermissionDenied as exc:
messages.error(request, str(exc))
return_ticket = request.POST.get("return_ticket", "").strip()
if return_ticket and dispatch.tickets.filter(public_id=return_ticket).exists():
return redirect("staff_tickets:detail", public_id=return_ticket)
return redirect("staff_tickets:dispatch_detail", public_id=public_id)
@staff_required
+1
View File
@@ -14,3 +14,4 @@
.ticket-review-callout{display:flex;align-items:center;gap:.65rem;margin:.85rem 0 .65rem;border-radius:.9rem;background:#f0faf6;padding:.7rem}.ticket-review-callout>span{display:grid;width:2rem;height:2rem;flex:none;place-items:center;border-radius:.65rem;background:#fff;color:#16815e;font-weight:900}.ticket-review-callout b,.ticket-review-callout small{display:block}.ticket-review-callout b{font-size:.82rem}.ticket-review-callout small{margin-top:.1rem;color:#66877b;font-size:.7rem;line-height:1.65}.ticket-review-list{overflow:hidden;border:1px solid rgba(8,55,107,.1);border-radius:1rem;background:#fff}.ticket-review-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:start;gap:.6rem;padding:.75rem}.ticket-review-row+.ticket-review-row{border-top:1px solid rgba(8,55,107,.07)}.ticket-review-icon{display:grid;width:1.8rem;height:1.8rem;place-items:center;border-radius:.55rem;background:#e5f4ff;color:#1977ce;font-size:.65rem;font-weight:900}.ticket-review-content{min-width:0}.ticket-review-content>small{color:rgba(8,55,107,.58);font-size:.7rem;font-weight:800}.ticket-review-content h3{overflow-wrap:anywhere;margin:.16rem 0 .08rem;color:#08376b;font-size:.9rem;line-height:1.7}.ticket-review-content [data-review-subject]{font-size:1rem;font-weight:900;line-height:1.8}.ticket-review-content p{display:flex;flex-wrap:wrap;align-items:center;gap:.3rem;margin:0;color:rgba(8,55,107,.62);font-size:.72rem;line-height:1.8}.ticket-review-content p i{font-style:normal}.ticket-review-row>button{display:flex;min-height:2rem;align-items:center;gap:.25rem;border:0;border-radius:.65rem;background:#e5f4ff;padding:.35rem .5rem;color:#1977ce;font-size:.68rem;font-weight:900}.ticket-review-details .ticket-review-content{grid-column:2/-1}.ticket-review-details>button{grid-column:3;grid-row:1}.ticket-review-body{display:block!important;margin-top:.2rem!important;color:rgba(8,55,107,.82)!important;font-size:.88rem!important;font-weight:550;line-height:2!important;white-space:pre-wrap}.ticket-review-files{display:flex;flex-wrap:wrap;gap:.3rem;margin-top:.45rem}.ticket-review-files span{border-radius:999px;background:#e5f4ff;padding:.28rem .5rem;color:#1977ce;font-size:.68rem}.ticket-review-audio{margin-top:.55rem}.ticket-review-step{padding-bottom:6.75rem}.ticket-wizard-card[data-wizard-enhanced] .ticket-final-actions{position:fixed;z-index:45;right:.75rem;bottom:5rem;left:.75rem;display:grid;grid-template-columns:minmax(0,.78fr) minmax(0,1.4fr);gap:.5rem;margin:0;border:1px solid rgba(8,55,107,.12);border-radius:1.1rem;background:rgba(255,255,255,.96);padding:.55rem;box-shadow:0 20px 50px -20px rgba(8,55,107,.55);backdrop-filter:blur(14px)}
@media(min-width:640px){.ticket-wizard-page{padding-bottom:3rem}.ticket-wizard-topbar{min-height:3.8rem}.ticket-wizard-save{display:flex}.ticket-wizard-intro{margin:.8rem 0 1.35rem}.ticket-wizard-intro h1{font-size:2rem}.ticket-wizard-intro>p:last-child{font-size:.9rem}.ticket-wizard-progress{margin-bottom:1rem}.ticket-wizard-progress:before{top:1.12rem}.ticket-wizard-progress li{display:flex;justify-content:center;gap:.5rem}.ticket-wizard-progress li>span{width:2.3rem;height:2.3rem}.ticket-wizard-progress li>div{display:block}.ticket-wizard-progress li b,.ticket-wizard-progress li small{display:block}.ticket-wizard-progress li b{font-size:.78rem}.ticket-wizard-progress li small{font-size:.67rem}.ticket-wizard-card{border-radius:1.6rem}.ticket-wizard-step{padding:1.35rem 1.5rem}.ticket-wizard-step-head h2{font-size:1.2rem}.ticket-intent-search kbd{display:inline;border:1px solid rgba(8,55,107,.1);border-radius:.4rem;background:#eee;padding:.2rem .4rem;color:rgba(8,55,107,.58);font-size:.68rem}.ticket-routing-grid,.ticket-two-columns{grid-template-columns:1fr 1fr}.ticket-manual-body{padding:1rem!important}.ticket-expert-picker{grid-template-columns:repeat(3,1fr)!important}.ticket-expert-picker .expert-card{display:grid!important;grid-template-columns:1fr;justify-items:center;padding:.9rem!important;text-align:center!important}.ticket-expert-picker .expert-card img,.ticket-expert-picker .expert-avatar{width:3.3rem!important;height:3.3rem!important}.ticket-expert-picker .expert-card:after{position:absolute;top:.5rem;left:.5rem}.ticket-attachment-actions{grid-template-columns:1fr 1fr}.ticket-voice-idle{grid-template-columns:auto 1fr;align-items:center}.ticket-voice-idle>small{text-align:right}.ticket-voice-start{min-width:11rem}.ticket-voice-live{display:grid;grid-template-columns:1fr auto;align-items:center}.ticket-voice-controls{grid-template-columns:auto auto auto}.ticket-voice-controls .ticket-voice-cancel{grid-column:auto;min-height:2.65rem}.ticket-wizard-actions{position:static;margin:1.15rem -1.5rem -1.35rem;padding:.8rem 1.5rem}.ticket-wizard-actions>span{display:block;margin-left:auto;color:rgba(8,55,107,.45);font-size:.72rem}.ticket-wizard-actions button{min-width:8.5rem}.ticket-review-row{gap:.8rem;padding:.9rem 1rem}.ticket-review-details .ticket-review-content{grid-column:2}.ticket-review-content h3{font-size:.96rem}.ticket-review-content [data-review-subject]{font-size:1.08rem}.ticket-review-body{font-size:.94rem!important}.ticket-review-step{padding-bottom:6rem}.ticket-wizard-card[data-wizard-enhanced] .ticket-final-actions{position:fixed;right:auto;bottom:1rem;left:50%;width:min(calc(100% - 2rem),42rem);transform:translateX(-50%);padding:.6rem}}
@media(prefers-reduced-motion:reduce){.ticket-voice-wave i{animation:none}}
.ticket-wizard-credit{margin-top:-1px;border:1px solid rgba(8,55,107,.1);border-top:1px dashed rgba(8,55,107,.13);border-radius:0 0 1.15rem 1.15rem;background:#f8fbfd;padding:.85rem 1rem;color:rgba(8,55,107,.62);font-size:.74rem;line-height:1.9;text-align:center}.ticket-wizard-credit a{color:#1977ce}.ticket-wizard-card{border-bottom-right-radius:1.15rem;border-bottom-left-radius:1.15rem}@media(min-width:640px){.ticket-wizard-credit{border-radius:0 0 1.6rem 1.6rem}.ticket-wizard-card{border-bottom-right-radius:1.6rem;border-bottom-left-radius:1.6rem}}
+3
View File
@@ -17,3 +17,6 @@
/* Shared attachments for isolated group dispatches. */
.dispatch-attachment-picker{display:grid;gap:.8rem;margin:0 1rem 1rem;border:1px dashed #8fb7d1;border-radius:.9rem;background:linear-gradient(145deg,#f8fcff,#f2f9fd);padding:1rem}.dispatch-attachment-picker .attachment-picker-copy{display:flex;align-items:center;gap:.75rem}.dispatch-attachment-picker .attachment-icon{display:grid;width:2.45rem;height:2.45rem;flex:none;place-items:center;border-radius:.72rem;background:#dff2ff;color:#1977ce;font-size:1.2rem;font-weight:900}.dispatch-attachment-picker b,.dispatch-attachment-picker small{display:block}.dispatch-attachment-picker b{color:#173f60;font-size:.78rem}.dispatch-attachment-picker .attachment-picker-copy small{margin-top:.15rem;color:#647d91;font-size:.65rem;line-height:1.7}.dispatch-attachment-picker .media-picker-actions{margin:0}.dispatch-attachment-picker .file-preview{margin-top:0}.dispatch-attachment-picker .file-preview:empty{display:none}.dispatch-attachment-picker .file-chip{box-shadow:0 10px 25px -22px #08376b}.dispatch-attachment-picker .compression-note{color:#688095;font-size:.62rem;line-height:1.7}
@media(max-width:700px){.dispatch-attachment-picker{margin:.8rem;padding:.8rem}.dispatch-attachment-picker .media-picker-actions{display:grid;grid-template-columns:1fr}.dispatch-attachment-picker .media-picker-button{justify-content:center}}
.dispatch-resolve-button{border:1px solid #86d5b4!important;background:#eaf9f2!important;color:#126747!important}.dispatch-resolution-warning{display:flex;align-items:flex-start;gap:.75rem;border:1px solid #f0cf84;border-radius:1rem;background:#fffaf0;padding:.9rem 1rem;color:#6e4b0a}.dispatch-resolution-warning>span{display:grid;width:1.8rem;height:1.8rem;flex:none;place-items:center;border-radius:.55rem;background:#f5b942;color:#fff;font-weight:900}.dispatch-resolution-warning b{font-size:.82rem}.dispatch-resolution-warning p{margin:.2rem 0 0;font-size:.72rem;line-height:1.8}.ticket-dispatch-context{position:relative;display:flex;align-items:center;justify-content:space-between;gap:.65rem;border:1px solid #d4e7f0;border-radius:.75rem;background:#f8fcfe;padding:.55rem 2.65rem .55rem .7rem}.ticket-dispatch-context>div:first-child{display:flex;align-items:center;gap:.55rem;min-width:0}.ticket-dispatch-context>div:first-child>span{display:grid;width:1.75rem;height:1.75rem;flex:none;place-items:center;border-radius:.5rem;background:#e3f3fa;color:#0877a7;font-size:.78rem;font-weight:900}.ticket-dispatch-context p{display:grid;min-width:0;gap:.03rem;margin:0}.ticket-dispatch-context small,.ticket-dispatch-context em{color:#718695;font-size:.6rem;font-style:normal}.ticket-dispatch-context b{overflow:hidden;color:#173f60;font-size:.76rem;text-overflow:ellipsis;white-space:nowrap}.ticket-dispatch-context-actions{display:flex;align-items:center;gap:.4rem}.ticket-dispatch-context-actions form{margin:0}.ticket-dispatch-context-actions a,.ticket-dispatch-context-actions button{display:inline-flex;min-height:2rem;align-items:center;justify-content:center;border:1px solid #c8e0ea;border-radius:.55rem;background:#fff;padding:.35rem .55rem;color:#176b91;font:inherit;font-size:.62rem;font-weight:900;cursor:pointer}.ticket-dispatch-context-actions button{border-color:#a9dec9;background:#effaf5;color:#126747}.ticket-dispatch-context-close{position:absolute;top:50%;right:.55rem;display:grid;width:1.65rem;height:1.65rem;min-height:0;transform:translateY(-50%);place-items:center;border:0;border-radius:50%;background:transparent;padding:0;color:#718695;font:inherit;font-size:1.15rem;line-height:1;box-shadow:none;cursor:pointer}.ticket-dispatch-context-close:hover{transform:translateY(-50%);background:#e7f1f5;color:#173f60}
@media(max-width:760px){.dispatch-head-actions>*{width:100%}.dispatch-head-actions .button{width:100%;justify-content:center}.ticket-dispatch-context{align-items:stretch;flex-direction:column;padding:.5rem 2.45rem .5rem .55rem}.ticket-dispatch-context-actions{display:flex;overflow-x:auto}.ticket-dispatch-context-actions a,.ticket-dispatch-context-actions form{flex:1}.ticket-dispatch-context-actions a,.ticket-dispatch-context-actions button{width:100%;white-space:nowrap}.ticket-dispatch-context-close{top:.5rem;transform:none}.ticket-dispatch-context-close:hover{transform:none}}
+1 -1
View File
@@ -1,2 +1,2 @@
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-tracking:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:Vazirmatn,ui-sans-serif,system-ui;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-200:oklch(88.5% .062 18.334);--color-red-500:oklch(63.7% .237 25.331);--color-red-700:oklch(50.5% .213 27.518);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-800:oklch(47.3% .137 46.201);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-800:oklch(43.2% .095 166.913);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-400:oklch(70.4% .04 256.788);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--blur-sm:8px;--blur-xl:24px;--blur-2xl:40px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-ink:#08376b;--color-ink-soft:#0b4c8c;--color-cream:#eee;--color-soft:#eaeaea;--color-paper:#fff;--color-sea:#0693e3;--color-sea-dark:#1977ce;--color-blue:#1977ce;--color-mint:#e5f4ff;--color-coral:#fa9f00;--color-sun:#ffc451}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--defLine truncated
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-leading:initial;--tw-font-weight:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-tracking:initial;--tw-border-style:solid;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:Vazirmatn,ui-sans-serif,system-ui;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-200:oklch(88.5% .062 18.334);--color-red-500:oklch(63.7% .237 25.331);--color-red-700:oklch(50.5% .213 27.518);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-800:oklch(47.3% .137 46.201);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-800:oklch(43.2% .095 166.913);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-400:oklch(70.4% .04 256.788);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--blur-sm:8px;--blur-xl:24px;--blur-2xl:40px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-ink:#08376b;--color-ink-soft:#0b4c8c;--color-cream:#eee;--color-soft:#eaeaea;--color-paper:#fff;--color-sea:#0693e3;--color-sea-dark:#1977ce;--color-blue:#1977ce;--color-mint:#e5f4ff;--color-coral:#fa9f00;--color-sun:#ffc451;--shadow-panel:0 18px 55px -35px #08376b59}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-Line truncated
+1
View File
@@ -44,6 +44,7 @@ document.querySelectorAll('[data-conversation-workspace]').forEach(workspace=>{
if(submissionSucceeded){form?.reset();if(input){input.value='';resize()}setComposerOpen(false,false)}else setComposerOpen(hasDraft||replyResult==='error',false);
composerToggle?.addEventListener('click',()=>setComposerOpen(true));
workspace.querySelectorAll('[data-composer-close]').forEach(button=>button.addEventListener('click',()=>setComposerOpen(false,false)));
workspace.querySelector('[data-dismiss-dispatch-context]')?.addEventListener('click',event=>event.currentTarget.closest('.ticket-dispatch-context')?.remove());
composer?.addEventListener('keydown',event=>{if(event.key==='Escape'){setComposerOpen(false,false);composerToggle?.focus()}});
const primarySubmit=form?.querySelector('[data-primary-submit]');
const guideToPrimarySubmit=event=>{event.preventDefault();event.stopPropagation();if(!primarySubmit)return;primarySubmit.scrollIntoView({behavior:'smooth',block:'center'});primarySubmit.classList.remove('submit-attention');void primarySubmit.offsetWidth;primarySubmit.classList.add('submit-attention');setTimeout(()=>primarySubmit.focus({preventScroll:true}),450)};
+5 -1
View File
@@ -41,9 +41,13 @@ body:has(.ticket-workspace){overflow:hidden}.customer-main:has(.ticket-workspace
/* Collapsible reply panel keeps the correspondence history dominant. */
.ticket-workspace .composer.is-collapsed{padding:.55rem .85rem;border-top:1px solid rgba(8,55,107,.1);background:var(--color-paper)}.composer.is-collapsed>form,.composer.is-collapsed>.mode{display:none}.composer-launch{display:none;width:100%;min-height:3.5rem;align-items:center;justify-content:flex-start;gap:.75rem;border:1px solid rgba(8,55,107,.1);border-radius:11px;background:linear-gradient(135deg,var(--color-paper),var(--color-mint));padding:.55rem .75rem;color:var(--color-ink);box-shadow:none;text-align:right}.composer.is-collapsed .composer-launch{display:flex}.composer-launch:hover{transform:none;border-color:color-mix(in srgb,var(--color-sea) 35%,transparent);background:var(--color-mint)}.composer-launch-icon{@apply grid size-9 shrink-0 place-items-center rounded-lg bg-sea text-lg text-white}.composer-launch b,.composer-launch small{display:block}.composer-launch b{@apply text-xs}.composer-launch small{@apply mt-0.5 text-[9px] font-medium text-ink/45}.composer-launch-arrow{@apply mr-auto text-sm text-sea}.composer-close{@apply text-ink/55}.composer:not(.is-collapsed) .composer-launch{display:none}.composer.is-collapsed~*{transition:none}.composer.is-collapsed+.new-message-jump{bottom:5rem}.ticket-workspace .composer.is-collapsed{box-shadow:0 -10px 30px -28px rgba(8,55,107,.65)}
@media(max-width:639px){.ticket-workspace .composer.is-collapsed{@apply px-2.5 py-2}.composer-launch{min-height:3.25rem}.composer-launch small{max-width:65vw}.composer-close{order:3}.new-message-jump{bottom:5.5rem}.ticket-workspace .composer:not(.is-collapsed){max-height:62dvh;overflow-y:auto}}
@media(max-width:639px){.ticket-workspace .composer.is-collapsed{@apply px-2.5 py-2}.composer-launch{min-height:3.25rem}.composer-launch small{max-width:65vw}.new-message-jump{bottom:5.5rem}.staff-ticket-workspace .composer:not(.is-collapsed){position:fixed;inset-inline:0;bottom:0;z-index:55;box-sizing:border-box;max-height:calc(100dvh - .5rem);overflow-y:auto;overscroll-behavior:contain;border-radius:1rem 1rem 0 0;padding-bottom:max(1rem,env(safe-area-inset-bottom));box-shadow:0 -18px 55px -25px rgba(8,55,107,.75)}.staff-ticket-workspace .composer:not(.is-collapsed) .composer-bar{position:sticky;bottom:calc(-1 * max(1rem,env(safe-area-inset-bottom)));z-index:2;margin-inline:-.65rem;margin-bottom:calc(-1 * max(1rem,env(safe-area-inset-bottom)));padding:.75rem .65rem max(1rem,env(safe-area-inset-bottom));background:var(--color-paper)}.staff-ticket-workspace .composer:not(.is-collapsed) .composer-tools{display:grid;grid-template-columns:1fr 1fr 1fr;width:100%}.staff-ticket-workspace .composer:not(.is-collapsed) .composer-tools .tool-button{justify-content:center;min-width:0;padding-inline:.4rem}.staff-ticket-workspace .composer:not(.is-collapsed) .composer-close{display:inline-flex;order:initial;color:var(--color-ink)} }
.conversation-pane:has(.composer.is-collapsed) .new-message-jump{bottom:5rem}.conversation-pane:not(:has(.composer.is-collapsed)) .new-message-jump{bottom:17rem}@media(max-width:639px){.conversation-pane:has(.composer.is-collapsed) .new-message-jump{bottom:5.5rem}.conversation-pane:not(:has(.composer.is-collapsed)) .new-message-jump{bottom:19rem}}
body:has(.ticket-workspace) .flash{position:fixed;top:5rem;left:50%;z-index:100;width:min(calc(100% - 2rem),42rem);margin:0;transform:translateX(-50%);box-shadow:0 18px 50px -25px rgba(8,55,107,.55);animation:flash-in .22s ease-out}.flash{position:relative;padding-left:3rem}.flash-close{position:absolute;left:.5rem;top:50%;min-height:2rem;width:2rem;transform:translateY(-50%);border-radius:.5rem;background:transparent;padding:0;color:currentColor;box-shadow:none}.flash-close:hover{transform:translateY(-50%);background:rgba(255,255,255,.6)}.flash-leaving{opacity:0;transform:translate(-50%,-.5rem)!important;transition:.25s ease}@keyframes flash-in{from{opacity:0;transform:translate(-50%,-.5rem)}to{opacity:1;transform:translate(-50%,0)}}
/* Group context adds a row above the conversation; keep every control inside the viewport. */
.staff-ticket-workspace:has(.ticket-dispatch-context){display:grid;grid-template-rows:auto auto minmax(0,1fr)}.staff-ticket-workspace:has(.ticket-dispatch-context) .workspace-grid{height:auto}.ticket-dispatch-context{margin:.5rem .65rem 0}
@media(max-width:639px){.staff-ticket-workspace{min-width:0}.ticket-workspace-head{min-width:0}.ticket-title{flex:1}.ticket-head-actions{gap:.35rem}.ticket-head-actions .icon-button{width:2.35rem;height:2.35rem}.ticket-dispatch-context{margin:.4rem .45rem 0}.composer-launch{min-width:0;padding-inline:.55rem;gap:.5rem}.composer-launch>span:nth-child(2){min-width:0;flex:1}.composer-launch b,.composer-launch small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:none}.composer-launch-arrow{flex:none;margin-right:0}.staff-ticket-workspace .composer.is-collapsed{position:fixed;right:0;bottom:0;left:0;z-index:45;padding-bottom:max(.5rem,env(safe-area-inset-bottom))}.staff-ticket-workspace .conversation{padding-bottom:5.25rem}}
.flash.flash-leaving{transform:none!important}body:has(.ticket-workspace) .flash.flash-leaving{transform:translate(-50%,-.5rem)!important}
/* Searchable Iran province/city fields shared by profile and new-ticket forms. */
+8 -4
View File
@@ -46,11 +46,13 @@
<div class="intent-results ticket-intent-results" data-ticket-search-results aria-live="polite"></div>
<details class="ticket-manual-routing manual-routing" {% if form.is_bound and form.errors %}open{% endif %}>
<summary><span>انتخاب دستی واحد و دسته‌بندی</span><small>در صورت تمایل مسیر درخواست را خودتان مشخص کنید</small></summary>
<summary><span>موضوع را به‌صورت دستی مسیر‌دهی کنید</span><small>واحد و دسته‌بندی درخواست را خودتان مشخص کنید</small></summary>
<div class="ticket-manual-body manual-routing-body">
<div class="ticket-routing-grid" data-routing-selectors>
<div class="field">{{ form.unit.label_tag }}{{ form.unit }}{{ form.unit.errors }}</div>
<div class="field">{{ form.category.label_tag }}{{ form.category }}{{ form.category.errors }}</div>
<div class="routing-selectors" data-routing-selectors>
<div class="ticket-routing-grid">
<div class="field">{{ form.unit.label_tag }}{{ form.unit }}{{ form.unit.errors }}</div>
<div class="field">{{ form.category.label_tag }}{{ form.category }}{{ form.category.errors }}</div>
</div>
</div>
</div>
</details>
@@ -135,6 +137,7 @@
<footer class="ticket-wizard-actions ticket-final-actions"><button class="secondary" type="button" data-wizard-prev>مرحله قبل</button><button class="primary" type="submit" data-final-submit>ثبت و ارسال نهایی <b>←</b></button></footer>
</section>
</form>
<footer class="ticket-wizard-credit">{{ developer_credit }}</footer>
</div>
<script src="{% static 'vendor/compressor.min.js' %}?v={{ static_asset_version }}"></script>
@@ -144,3 +147,4 @@
<script src="{% static 'js/attachment-uploader.js' %}?v={{ static_asset_version }}"></script>
<script src="{% static 'js/customer-ticket-wizard.js' %}?v={{ static_asset_version }}"></script>
{% endblock %}
{% block footer %}{% endblock %}
+7
View File
@@ -6,6 +6,13 @@
<div class="ticket-title"><a class="workspace-back" href="{% url 'staff_tickets:list' %}" 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>{% if can_claim %}<form method="post" action="{% url 'staff_tickets:claim' ticket.public_id %}">{% csrf_token %}<button type="submit">اختصاص به من</button></form>{% endif %}<button type="button" class="icon-button" data-toggle-panel="ticket-actions" aria-label="عملیات تیکت">⚙</button><button type="button" class="icon-button" data-toggle-panel="customer-info" aria-label="اطلاعات مشتری">◉</button></div>
</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>
<button type="button" class="ticket-dispatch-context-close" data-dismiss-dispatch-context aria-label="بستن پیام ارسال گروهی" title="بستن">×</button>
</section>
{% endif %}
<div class="workspace-grid staff-conversation-grid">
<aside class="workspace-side actions-side" id="ticket-actions" data-workspace-panel>
<button type="button" class="panel-close" data-close-panel>×</button>
@@ -16,9 +16,12 @@
<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>
+21
View File
@@ -178,3 +178,24 @@ def test_dispatch_report_presents_delivery_and_engagement_professionally(live_se
page.set_viewport_size({"width":390,"height":844})
expect(page.locator(".dispatch-detail-table tbody tr").first).to_be_visible()
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
page.goto(f"{live_server.url}{reverse('staff_tickets:detail',args=[ticket.public_id])}", wait_until="domcontentloaded")
expect(page.locator(".ticket-dispatch-context")).to_contain_text("استعلام حرفه‌ای")
dismiss_context = page.get_by_role("button", name="بستن پیام ارسال گروهی")
expect(dismiss_context).to_be_visible()
launch = page.locator(".composer-launch")
expect(launch).to_be_visible()
box = launch.bounding_box()
assert box is not None
assert box["x"] >= 0 and box["x"] + box["width"] <= 390
assert box["y"] >= 0 and box["y"] + box["height"] <= 844
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
launch.click()
close_composer = page.get_by_role("button", name="بستن", exact=True)
expect(close_composer).to_be_visible()
close_box = close_composer.bounding_box()
assert close_box is not None
assert close_box["x"] >= 0 and close_box["x"] + close_box["width"] <= 390
assert close_box["y"] >= 0 and close_box["y"] + close_box["height"] <= 844
dismiss_context.click()
expect(page.locator(".ticket-dispatch-context")).to_have_count(0)
+17
View File
@@ -49,6 +49,23 @@ def test_developer_credit_html_requires_explicit_trusted_html_switch(client):
assert '<a href="https://example.com" style="color:red"><span>★</span> توسعه نمونه</a>' in rendered
@pytest.mark.django_db
def test_customer_new_renders_developer_credit_once_directly_below_wizard(client):
customer = User.objects.create_user(
username="wizard-credit-customer", mobile="09120000404", account_type=User.AccountType.CUSTOMER
)
branding = SiteBranding.load()
branding.developer_credit_text = "برنامه نویسی و توسعه: محمد جواد فصاحت | 09202202129"
branding.save()
client.force_login(customer)
content = client.get(reverse("tickets:new")).content.decode()
assert content.count(branding.developer_credit_text) == 1
assert '<footer class="ticket-wizard-credit">' in content
assert content.index('</form>') < content.index('<footer class="ticket-wizard-credit">')
assert '<footer class="site-footer">' not in content
@pytest.mark.django_db
def test_staff_sidebar_brand_is_database_backed(client):
user = User.objects.create_superuser(username="brand-admin", password="safe-password", account_type=User.AccountType.STAFF)
+89 -1
View File
@@ -140,10 +140,11 @@ def test_unit_notification_destinations_normalize_and_reject_duplicate_superviso
slug="notification-destinations",
supervisor_mobile_1="0912 111 2233",
supervisor_mobile_2="0935 222 3344",
supervisor_mobile_4="0990 444 5566",
)
unit.full_clean()
unit.save()
assert unit.supervisor_mobiles == ("+989121112233", "+989352223344")
assert unit.supervisor_mobiles == ("+989121112233", "+989352223344", "+989904445566")
unit.supervisor_mobile_3 = "09121112233"
with pytest.raises(ValidationError, match="قبلاً"):
@@ -158,6 +159,7 @@ def test_new_ticket_sms_is_queued_for_each_unit_supervisor_only_on_creation(cate
unit.supervisor_mobile_1 = "09121112233"
unit.supervisor_mobile_2 = "09352223344"
unit.supervisor_mobile_3 = "09903334455"
unit.supervisor_mobile_4 = "09214445566"
unit.save()
ticket = create_ticket(customer=customer, category=category, subject=" پیگیری بارگیری ", body="متن")
jobs = list(NotificationJob.objects.filter(ticket=ticket, event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("id"))
@@ -180,12 +182,98 @@ def test_new_ticket_sms_is_queued_for_each_unit_supervisor_only_on_creation(cate
assert customer.local_mobile in message
assert "موضوع: پیگیری بارگیری" in message
assert f"دسته: {category.title}" in message
assert "ارجاع: صف عمومی" in message
supervisor_job_count = NotificationJob.objects.filter(event_type="UNIT_SUPERVISOR_NEW_TICKET").count()
reply_ticket(ticket_id=ticket.pk, user=customer, body="پاسخ جدید")
assert NotificationJob.objects.filter(event_type="UNIT_SUPERVISOR_NEW_TICKET").count() == supervisor_job_count
@pytest.mark.django_db
def test_unit_supervisor_sms_uses_editable_unit_template_and_selected_expert(category, customer, monkeypatch):
from apps.notifications.management.commands.process_notifications import _send_sms
from apps.notifications.models import SMSTemplate
from apps.customers.models import CustomerProfile
unit = category.unit
unit.supervisor_mobile_1 = "09125556677"
unit.save()
expert = make_staff("selected-supervisor-sms-expert")
expert.staff_profile.display_name = "کارشناس انتخابی پیامک"
expert.staff_profile.primary_unit = unit
expert.staff_profile.save()
CustomerProfile.objects.create(user=customer, first_name="سارا", last_name="احمدی")
template = SMSTemplate.objects.create(
unit=unit,
event_type=SMSTemplate.Event.UNIT_SUPERVISOR_NEW_TICKET,
title="الگوی سفارشی سرپرستان",
body="{first_name} {last_name} | {unit_name} | {ticket_number} | {customer_mobile} | {category_name} | {expert_name}",
)
template.full_clean()
ticket = create_ticket(
customer=customer, category=category, subject="تست الگوی قابل تنظیم",
selected_expert=expert, body="متن",
)
job = NotificationJob.objects.get(ticket=ticket, event_type=SMSTemplate.Event.UNIT_SUPERVISOR_NEW_TICKET)
sent = []
class Provider:
def send_message(self, mobile, message):
sent.append((mobile, message))
return type("Result", (), {"success": True})()
monkeypatch.setattr("apps.notifications.management.commands.process_notifications.get_sms_provider", lambda: Provider())
_send_sms(job)
assert sent == [(
"+989125556677",
f"سارا احمدی | {unit.name} | {ticket.ticket_number} | {customer.local_mobile} | {category.title} | کارشناس انتخابی پیامک",
)]
@pytest.mark.django_db
def test_unit_supervisor_sms_handles_missing_customer_names_and_general_queue(category, customer, monkeypatch):
from apps.notifications.management.commands.process_notifications import _send_sms
from apps.notifications.models import SMSTemplate
category.unit.supervisor_mobile_1 = "09125556677"
category.unit.save()
SMSTemplate.objects.create(
unit=category.unit,
event_type=SMSTemplate.Event.UNIT_SUPERVISOR_NEW_TICKET,
title="الگوی نام خالی سرپرستان",
body="نام: {first_name} {last_name}\nنام جایگزین: {recipient_name}\nارجاع: {expert_name}",
)
ticket = create_ticket(customer=customer, category=category, subject="مشتری بدون نام", body="متن")
job = NotificationJob.objects.get(ticket=ticket, event_type=SMSTemplate.Event.UNIT_SUPERVISOR_NEW_TICKET)
sent = []
class Provider:
def send_message(self, mobile, message):
sent.append((mobile, message))
return type("Result", (), {"success": True})()
monkeypatch.setattr("apps.notifications.management.commands.process_notifications.get_sms_provider", lambda: Provider())
_send_sms(job)
assert sent == [(
"+989125556677",
"نام: \nنام جایگزین: ثبت نشده\nارجاع: صف عمومی",
)]
@pytest.mark.django_db
def test_global_supervisor_sms_template_is_seeded_and_editable():
from apps.notifications.models import SMSTemplate
template = SMSTemplate.objects.get(
unit__isnull=True, event_type=SMSTemplate.Event.UNIT_SUPERVISOR_NEW_TICKET
)
assert "{expert_name}" in template.body
assert "{customer_mobile}" in template.body
assert "{category_name}" in template.body
assert {"expert_name", "customer_mobile", "category_name"}.issubset(template.allowed_tags)
@pytest.mark.django_db
def test_staff_created_ticket_also_notifies_owning_unit_supervisors(category, customer):
category.unit.supervisor_mobile_1 = "09124445566"
+72 -1
View File
@@ -12,12 +12,83 @@ from apps.attachments.models import AttachmentPolicy
from apps.customers.models import ContactGroup, CustomerProfile
from apps.notifications.models import NotificationJob
from apps.staff.models import OrganizationalUnit, StaffProfile
from apps.tickets.models import GroupDispatch, Ticket, TicketCategory, TicketTag
from apps.tickets.models import GroupDispatch, Ticket, TicketCategory, TicketEvent, TicketTag
from apps.tickets.policies import TicketPolicy
from apps.tickets.selectors import staff_ticket_rows
from apps.tickets.services import create_ticket, dispatch_group_tickets, refresh_group_dispatch_status
@pytest.mark.django_db
def test_dispatch_owner_can_resolve_all_open_group_tickets_from_report_or_ticket(client):
unit = OrganizationalUnit.objects.create(name="حل گروهی", slug="resolve-dispatch")
unit.permissions.add(*Permission.objects.filter(
content_type__app_label="tickets",
codename__in=("view_ticket", "dispatch_group_tickets", "change_ticket"),
))
staff = make_staff("dispatch-resolver", unit)
first = make_contact("resolve-first", "09120000401")
second = make_contact("resolve-second", "09120000402")
group = ContactGroup.objects.create(name="گروه قابل حل", unit=unit)
first.customer_profile.groups.add(group)
second.customer_profile.groups.add(group)
category = TicketCategory.objects.create(unit=unit, title="پیگیری گروه", slug="resolve-group")
dispatch, tickets = dispatch_group_tickets(
user=staff, unit=unit, contact_group=group, category=category,
subject="پیگیری پاسخ گروه", body="لطفاً پاسخ دهید.",
)
tickets[1].status = Ticket.Status.CLOSED
tickets[1].closed_at = timezone.now()
tickets[1].save(update_fields=["status", "closed_at", "updated_at"])
client.force_login(staff)
report = client.get(reverse("staff_tickets:dispatch_detail", args=[dispatch.public_id]))
report_text = report.content.decode()
assert report.context["open_ticket_count"] == 1
assert "حل‌شدن همه تیکت‌ها (1)" in report_text
ticket_page = client.get(reverse("staff_tickets:detail", args=[tickets[0].public_id]))
assert "وابسته به ارسال گروهی" in ticket_page.content.decode()
assert dispatch.subject in ticket_page.content.decode()
assert "حل‌شدن تمامی تیکت‌های گروه (1)" in ticket_page.content.decode()
response = client.post(
reverse("staff_tickets:dispatch_resolve", args=[dispatch.public_id]),
{"return_ticket": tickets[0].public_id},
)
assert response.status_code == 302
assert response.url == reverse("staff_tickets:detail", args=[tickets[0].public_id])
tickets[0].refresh_from_db()
tickets[1].refresh_from_db()
assert tickets[0].status == Ticket.Status.RESOLVED
assert tickets[0].resolved_at is not None
assert tickets[1].status == Ticket.Status.CLOSED
assert TicketEvent.objects.filter(
ticket=tickets[0], event_type=TicketEvent.Type.STATUS_CHANGED,
metadata__group_dispatch_id=dispatch.pk,
).exists()
@pytest.mark.django_db
def test_dispatch_bulk_resolve_requires_change_ticket_permission(client):
unit = OrganizationalUnit.objects.create(name="گروه فقط مشاهده", slug="read-only-dispatch")
unit.permissions.add(*Permission.objects.filter(
content_type__app_label="tickets", codename__in=("view_ticket", "dispatch_group_tickets")
))
staff = make_staff("read-only-dispatcher", unit)
customer = make_contact("read-only-recipient", "09120000403")
group = ContactGroup.objects.create(name="گروه فقط خواندنی", unit=unit)
customer.customer_profile.groups.add(group)
category = TicketCategory.objects.create(unit=unit, title="فقط مشاهده", slug="read-only-group")
dispatch, tickets = dispatch_group_tickets(
user=staff, unit=unit, contact_group=group, category=category,
subject="عملیات فقط مشاهده", body="متن عملیات",
)
client.force_login(staff)
assert client.get(reverse("staff_tickets:dispatch_detail", args=[dispatch.public_id])).context["can_resolve_dispatch"] is False
client.post(reverse("staff_tickets:dispatch_resolve", args=[dispatch.public_id]))
tickets[0].refresh_from_db()
assert tickets[0].status != Ticket.Status.RESOLVED
def make_staff(username, unit):
user = User.objects.create_user(username=username, account_type=User.AccountType.STAFF)
StaffProfile.objects.create(user=user, display_name=username, primary_unit=unit)