voip announcement

This commit is contained in:
javad committed 2026-09-28 19:37:19 +03:30
1 parent 38b1cf06df
commit c7993aae44
18 files changed
+679 -151

No files matched your search

+112 -144
View File
@@ -1,11 +1,11 @@
Project Root: customerTicket
Generated At: 2026-09-15T04:54:39+00:00
Python Files: 186
Generated At: 2026-09-28T15:29:03+00:00
Python Files: 197
Django Files: 37
JavaScript/TypeScript Files: 20
HTML Files: 30
Total Files: 821
Total Directories: 90
JavaScript/TypeScript Files: 21
HTML Files: 31
Total Files: 782
Total Directories: 97
customerTicket
├── apps
@@ -16,6 +16,8 @@ customerTicket
│ │ │ ├── 0003_magiclinkaccesslog_alter_ticketmagiclink_options_and_more.py
│ │ │ ├── 0004_ticketmagiclink_created_unit_and_more.py
│ │ │ ├── 0005_magiclinkaccesslog_magic_opened_idx_and_more.py
│ │ │ ├── 0006_user_voip_call_user_voip_extension_and_more.py
│ │ │ ├── 0007_user_voip_mobile.py
│ │ │ └── __init__.py
│ │ ├── __init__.py
│ │ ├── admin.py
@@ -55,6 +57,7 @@ customerTicket
│ │ │ ├── 0004_sitebranding_staff_sidebar_credit_html_and_more.py
│ │ │ ├── 0005_sitebranding_company_logo_and_more.py
│ │ │ ├── 0006_productionseedstate.py
│ │ │ ├── 0007_voipconfiguration.py
│ │ │ └── __init__.py
│ │ ├── templatetags
│ │ │ ├── __init__.py
@@ -92,7 +95,8 @@ customerTicket
│ │ │ ├── http.py
│ │ │ ├── providers.py
│ │ │ └── sms0098.py
│ │ └── __init__.py
│ │ ├── __init__.py
│ │ └── voip.py
│ ├── messaging
│ │ ├── migrations
│ │ │ ├── 0001_initial.py
@@ -124,6 +128,9 @@ customerTicket
│ │ │ ├── 0009_ensure_global_sms_templates.py
│ │ │ ├── 0010_use_actual_unit_in_staff_ticket_sms.py
│ │ │ ├── 0011_notification_delivery_tracking.py
│ │ │ ├── 0012_supervisor_sms_template.py
│ │ │ ├── 0013_notificationjob_definitive_delivery_retry.py
│ │ │ ├── 0014_backfill_safe_0098_delivery_retries.py
│ │ │ └── __init__.py
│ │ ├── __init__.py
│ │ ├── admin.py
@@ -142,6 +149,8 @@ customerTicket
│ │ │ ├── 0008_unitmembership_ticket_scope_and_roles.py
│ │ │ ├── 0009_alter_staffprofile_sort_order.py
│ │ │ ├── 0010_organizationalunit_notification_destinations.py
│ │ │ ├── 0011_organizationalunit_supervisor_mobile_4.py
│ │ │ ├── 0012_organizationalunit_supervisor_mobile_5.py
│ │ │ └── __init__.py
│ │ ├── __init__.py
│ │ ├── admin.py
@@ -247,315 +256,247 @@ customerTicket
│ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json
│ ├── 2026-09-16
│ │ ├── .graphify_analysis.json
│ │ ├── .graphify_labels.json
│ │ ├── cost.json
│ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json
│ ├── cache
│ │ ├── ast
│ │ │ └── v0.9.56-s2
│ │ │ └── v0.9.62-s3
│ │ │ ├── 00aea1c179c20548df2499948796faba373334997bfeddad9e335db138753cc5.json
│ │ │ ├── 00afff188288984e240329931ac9e4273f5ab89be582e021e9edbdbead42ebfe.json
│ │ │ ├── 0138bfeaa54b14916df0028a970de356913b83e8da2cd5cfc610361887baa9c2.json
│ │ │ ├── 01d84090a3b22716edb63a5cf9c0a56cc94c0f5207cdbd5005843f858bbb292f.json
│ │ │ ├── 02108c5a5a69113e104cb5a933a331a5de86cbb18203baf5339a714de46c2372.json
│ │ │ ├── 027726034309b90d12137f6110b86751f2558dec73d338c93e190b2f9706caa4.json
│ │ │ ├── 01eb2378ec1b50eb9adf2efeee2c1b18bec9fdc7df91db2655076185e3e32721.json
│ │ │ ├── 02a3519f5b471b7083c161d9d9c73ab5b0ee78cf7e2cc4d5be7c2542435aa58e.json
│ │ │ ├── 039590a02c184d21a28caec4178b981a824ec4356a225d1ce546cc212d2f550f.json
│ │ │ ├── 053337dddf6eb41ca0de06296d382047253aa08d9b91d61fe86923462c4e2dc8.json
│ │ │ ├── 0571f3a7d38f1c019d2c0f1102e0444f8bd8fc429dbe5f7ba51774d3eda0fedc.json
│ │ │ ├── 058b4e36240cd25d158647c300472baad5e7101ebde0319883abb0edc8c9c74d.json
│ │ │ ├── 074e22579bb4a8d9c4f0c12cdf3cee2aa1dbc9e91778fdbbbbe22ce014511f47.json
│ │ │ ├── 07e174cf218887aa0f8747199874faf6bc8af9347e43ff9b5a2073c089637f6f.json
│ │ │ ├── 08ff4daae812563107028890c2ae7257bb5f9a376a978244c814b7a4c1d10a98.json
│ │ │ ├── 091194a653ec9ff59e50d6d6121d12c0b0238b3f5cbb0467133a224b7eda59c8.json
│ │ │ ├── 0aa59f78fa092e41298775a4db40136ea63654d45e3ae835903c4e3dbd0578d4.json
│ │ │ ├── 0aafac1c4b289aeef9a6251e3a12fbf1710ae576dbd647d155aa3981008d50b7.json
│ │ │ ├── 0bc6d4b595803438408992e7a267aabdae34a6e30bcb947a90307114918928fb.json
│ │ │ ├── 0c2c38fa53a0fe1da084703272dff1c8130732eb327a788d26ec0443c72d6b96.json
│ │ │ ├── 0d276b031b1e68ce07a7e0620645994c4d80c88dbdeb4b49deba5e053ef188cb.json
│ │ │ ├── 0d5f84af41009fdecae2a46d4ad5fab38a79c1a5dde262e9c7f8d35707a35092.json
│ │ │ ├── 0d837f2122843751eeb3e945c9c56992b5c410b54ca0dbceccf5413022601d27.json
│ │ │ ├── 0ef31d053655ee64599a0246f26c8eb61f27654ae1ffbd41b87500438fc9b06a.json
│ │ │ ├── 0fd49b978854a13276bcd9479e5d287e54f19e235d799825f049400153a78c6f.json
│ │ │ ├── 11d2b71feb1480622fb18ee3795cf070421b146e4273b5016fad81e7cc8e2c7e.json
│ │ │ ├── 12db2ab20d1e0be8080b5edd6cc40cacfdff350dd585126a0e0ce144073ad12f.json
│ │ │ ├── 12f95828a2c1818402459627e36481199020e31faa280d214f81700fb9ad520d.json
│ │ │ ├── 134a89bb3cc350a74b9b750cc913b6dec440dc2f7ee7bf1eb224793ac3a07544.json
│ │ │ ├── 1436cce90bf2f00f050c9eb67cddf5672ed6a3db185ef663c257ee7936b175ae.json
│ │ │ ├── 143ae363276b4a1db64805529e618db71c204aa56bc8b54761693a9a2060336c.json
│ │ │ ├── 15539048155c760644fc70dd1fbff3855ceedf11bc6ef7b270d1c7280cb4df48.json
│ │ │ ├── 157a547bf11b52f36b8ae31334708f612d0d615ad75a9d7e42076806661217d7.json
│ │ │ ├── 161e1e32c695094d9d3ceb2a867fa497be0aaef0db27bdfdf94489c6f306e82b.json
│ │ │ ├── 16f67769b1d3e30239a9537cd98689a1f4448c60b2cc8bfdf8468ac9dc712ba6.json
│ │ │ ├── 178afffc3f3531ee7ffdae18dcbd5505108e5c07892b776a9d8a3413fa39c56d.json
│ │ │ ├── 1619d65041f501eb2172113b207c505582df29a0e5bfc3c681d8ec075f0bcb56.json
│ │ │ ├── 17ab5521ea572ea85443db030bd5085f1443c20b20da9cbf808f21ad695eb319.json
│ │ │ ├── 17ff6234af168a40b807e5226bfdb41cf2011a7134c763aba3c69428b6879d66.json
│ │ │ ├── 18ba25713068baf53992eb4ea94f95212ecaeb931fdcf6769970fc2fd19b2f93.json
│ │ │ ├── 19ed1276256eb4d44b9cea2dddac4de3801d67a08cf381a55466e82ee6f2bbc6.json
│ │ │ ├── 1b9e0fc2a347605b7d5ae84c15a0dc71fca5af90fa461763085e622f4c5877ec.json
│ │ │ ├── 1bfc839bf502b97d0b70b0bacc9781cae7511d27d078ed6ac6978fd438833dd1.json
│ │ │ ├── 1d6b17be6b2c0e7d25b4622b4a8c09ab705cecf9db09e2f86c35a4a564485791.json
│ │ │ ├── 1db075565e2fe1027675b7ee03d055150e6b741f3e9865bcd46d9a6113c538a8.json
│ │ │ ├── 1e2dc326e510df1ed3d23fabdf289d6a9448275816a15bc94becafa5563de2c7.json
│ │ │ ├── 1f440edc6feb5bf8c2a462b1b4a0b44bd22b4a763286fb2b340c797c282b5bcd.json
│ │ │ ├── 1f6cfd55d13bc48aa6c68558090ceeaf24a54c7bec690d51d95243c904daf0a6.json
│ │ │ ├── 1fabd63a56e826ff351d3edca222f5f958004c01f0bd16aed30080e8b13da33f.json
│ │ │ ├── 20107b827654d2af6301ac0a72a7d29ef6ef423ccde3e1fa48f5f92490fdfa90.json
│ │ │ ├── 20a6a77982f0ffa677ecf8b96662481ca9acccb7ef05274a31fd7ddbd217842a.json
│ │ │ ├── 211549439bc4b41e71f437e9909b8fc7bc306273615743fc61f53c8b8dc57e56.json
│ │ │ ├── 21f38c2f6a379ab402b4d3bd1b0809129c8554ebb01202a160c42a1e9b49b717.json
│ │ │ ├── 21f56aad8baf0dfcbc69c801ff4f738d0bcb84270150cde91f6290ebdef0e2d4.json
│ │ │ ├── 22534d744aadf9c9b61b908eccea0e9b3eaa30bc381bace54b3221ee0861a0ec.json
│ │ │ ├── 22df765bbeaca09caaf2fbcddcdd3ef1d4c0800f0c545f963bd91e85c33fe7ec.json
│ │ │ ├── 22e2f3678f112281b64af6f72a60ae03b15b47bbe45a17282e95d5fcf8b7c3c5.json
│ │ │ ├── 2396802d9ca82a055f431939b4cd95eea6c29556956075d1d1ff35e4bca3b7c6.json
│ │ │ ├── 244419b0eb45bd51409a2284de828aa5de674be78dd572f04c911b105a6c9ee7.json
│ │ │ ├── 2447edb7f88778fbf1ff0ac017796eb52bb2ced4c91786eef12a341b37adfaf7.json
│ │ │ ├── 25513735be357804874f44a0ab8d868419bbc8b0e33ee2bc20ce677554c7e43e.json
│ │ │ ├── 2609e627752bd20df923f6c69e090173a368757b3dbd2ce418e3cc9f67c697ba.json
│ │ │ ├── 2667c1ca679408fbaca6212c012d64f504eb8ddd574841569403de39c25dff3b.json
│ │ │ ├── 26c6832fa65fe38fa62f98bbfd8d544cf6a470cce547579e1d650390e8b9fded.json
│ │ │ ├── 26e3b3874f2dc2c3709945e2f6cf050e5c525933de9f09a8c91b244e3bfa8ca7.json
│ │ │ ├── 26f1fc81bffa8c22334482cd1cc638ac1f38647ae723ac8c360dd855281598e9.json
│ │ │ ├── 287fdc570ea3598f1b62d5764970740f7397df117fd25e9340f03b0f8d0e5171.json
│ │ │ ├── 279274c19bf4156f8f924fa837acf99d1ed42d6d57ec7cd1af194fe3b12c07da.json
│ │ │ ├── 28cefeff27c9cb5760aead796c37afdb1311bff0a5ff759136f0473ffa0152ff.json
│ │ │ ├── 298888276da87757f59b0e0280ea46039c0ee05945961c3e00b287abcbc6a2f0.json
│ │ │ ├── 2ac9a38700a628e196b14c23c8615d221fc2efdf0a9564722d4ddf4e293e70c2.json
│ │ │ ├── 2b104842fa1eaead6f52dc015e74fa38c6327cfa1a95f0858e649d093b1e98bd.json
│ │ │ ├── 2b7cc5d0dec7234082c1797ab637b916ac7df39277f8fbf109ae90190fc1e900.json
│ │ │ ├── 2c8d91fbfca457646cf60edb03fd3a89a2494a6b6612087854edeac5a119739a.json
│ │ │ ├── 2df1edd1fd32c467f237babe4a94f2ed73b67270ede6fa8298b32eab7c3eb80b.json
│ │ │ ├── 2e17dd08443df447b74080559d8cfc6b782adfc04963abfb7473e8a59c21d701.json
│ │ │ ├── 2e219918c1c8880ae7c2e8bdd1c670a74327780e63d81ae4561475832eea2a37.json
│ │ │ ├── 2fd5953e7926f320bdeac2d559967758aef4632bf7dfa26a5ee50ad386fc9b67.json
│ │ │ ├── 3142f9d4517f137483c716dd61985811bbf006b85506791e9a2ee37d58b2f53e.json
│ │ │ ├── 32a4505d16f4fd1efc5bc0c2ea7c8900419856b21a41b06d988cc424258ea1c9.json
│ │ │ ├── 32acb58a89265f31e57697995a8939b1636afd415969541ff44be40b8dd198f0.json
│ │ │ ├── 32c40d17e90ccf2511e124b4fcc5b3674a7200dd5215ee8742d319f8090b04ba.json
│ │ │ ├── 334f218e8e74fa3ac3e285059183c77efdbfe99ccfb426df17b4eceef14627e2.json
│ │ │ ├── 3358bbaf4a10bb5102cd25fe7cdb5b8401880e8ec0d711b9b9e9b4c92404865e.json
│ │ │ ├── 33fc3acd587f6970b19f708df61db16a5256a1914954829e2c622dacb8026331.json
│ │ │ ├── 345cea9e55c5f1954119121edf5ec28f1514b11d83de6c5bddf80d014bd98c94.json
│ │ │ ├── 34b48eae711a65d522f81081262d437469f08182368d46643dc80eadd96bbdb5.json
│ │ │ ├── 34cbb71eca44ad5849b04248e513e7cc33c3d3f1a1f66cc27f732666e585bf19.json
│ │ │ ├── 36000938c9c3b13fbd92622f905f8c4f4bfbfdabe66e3357e88c162a4fb198ce.json
│ │ │ ├── 360e95dd7f9428a51961a1f4ae7968a6e3101b8df00027432c6c743f64ccf9f8.json
│ │ │ ├── 365fb94a688dcb602d723765e4b798e0631f6385cdd5fd410b083136521c1a3c.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
│ │ │ ├── 42a296078b0eb6bea6dd15ebc5799c252901ba9e001290df5d3a9dfae0e4e4d5.json
│ │ │ ├── 42ffbc76dd6afcd90fde30281453fcc765ed07bc0e729fae4eee28d86f840578.json
│ │ │ ├── 433946952194be24247d30e4d5ba4d67b64ee2cda27a9212f16b4160a6f3fb71.json
│ │ │ ├── 434bb9df51f8929ca10167ce360dbaa9d93fdbfbd27053bdf11f3b171189edb8.json
│ │ │ ├── 44684e85104b3a520e0369a953041469689cb680c2144a6c3fd7107cadc9eed0.json
│ │ │ ├── 454699d97755028c24e254cc79a0b879de262c5931178ee5dc201f64c3e13f39.json
│ │ │ ├── 47730ea39698b58e7a30a7e5fce471272e9844e0a0c09a0d603699446667a9e2.json
│ │ │ ├── 478a054f8292cd197743a61b5e3e04487f67f8db76ae658a28b04e4aef670ab2.json
│ │ │ ├── 479992e96c0c5fac0a00bd4d9e0822dd52273158f5364c26884649b017e5b874.json
│ │ │ ├── 47fb67d01a9006c7f835ad87b75b95bb1365c27b342144f081d858e07fd309b3.json
│ │ │ ├── 48ffc385500573148218512753c10924e3d70b9b1cb255782425e40063572449.json
│ │ │ ├── 48f2dd332e3e37756440716095b3b0ba781ebd5abae50130c6b8bfee5a0468cd.json
│ │ │ ├── 4993b53d428cbbb1e334ea396aae6397586a1295a35f39da038cfe72f0b89fd4.json
│ │ │ ├── 49be8523d744110ec6038ec61ccc9e8f47894a392b60e970206c512c27e68178.json
│ │ │ ├── 4ae084687e4372e69f349e8bbc5cf603768a032d3d9ee4d2504e6709ed8ee440.json
│ │ │ ├── 4ae419f04a3a3b1221faef43b8526910f1958336162fbce432bab0e596ef2aa1.json
│ │ │ ├── 4bcdbebd8ab168095ad8130c68a697dd5ed64c3f52350fbd395c37408b9a7afa.json
│ │ │ ├── 4bf46c6c0f27e14312f7c1311bca1bf5e1bfee29ea5cb35e6f94142141985d23.json
│ │ │ ├── 4ca6d659e3dbcd21e9bfcdc394e19ad8b4dfb6a26665f3395e5a0d5f41e611ce.json
│ │ │ ├── 4cf64fe0b79aec18c49355f7d82ab04ae66067042a6c364e89842109427ffb5d.json
│ │ │ ├── 4dae69d87cc12f2afcaf76a8124cbb0ed4ed5b1b196563ead3179384bf5cb5a8.json
│ │ │ ├── 4ece1fa07b9b0b7d50f5efe48a083265e3d8efac191ed4a14875c8399522db42.json
│ │ │ ├── 4ed0e6e63784e85d5e256cb6eda996383dad486161209fda91200caa511e8907.json
│ │ │ ├── 4e71fd2d8486dc3333b9dcdea79eb80552dc8db2aa07ef721173618dd4dfb8e2.json
│ │ │ ├── 4f4799a971a5d6701087428e8c709396c0d947f7ce5bcf7a048cd542d5551c57.json
│ │ │ ├── 4fd59b7f74524c4eb0b9293df963b8aa007039c53e88fef4ca2bd9b61197a114.json
│ │ │ ├── 508a0054ec5c5b3353e94566a0f728d533ec47721e20e6037aa55e2d665a5143.json
│ │ │ ├── 52cd74ad5803b940489587043429b741434ae931fa4d244f5b0dc53582cee050.json
│ │ │ ├── 53705e64605d8b06954f91829d03ceef80bfacf943427a2d51079c554e41a42b.json
│ │ │ ├── 552bcabe2c76a5db8c1f071dbd2b19c31aeee0b07cf605cd5f2b031ea3b6440f.json
│ │ │ ├── 57507a7cdc8d6a5af4629b2714e7f0226805f833900d496b938e8f03dfc617ab.json
│ │ │ ├── 576178824e99e86dee1ca5fcdf46f61962380f6b8a708b046fbdfc766eb650ce.json
│ │ │ ├── 5794ed0ca9011299d935a3be468c921c65ab616e78f0c6509ec22e3a77cdb794.json
│ │ │ ├── 5323b09e3c325ba8c161f5e2cab233b25cfa1b96ff042c6ae79ae1b029e2b1bc.json
│ │ │ ├── 57bc96866536f0d67300ed29e56bd0f45fa9119c787fac805f228adcab18dc6f.json
│ │ │ ├── 582c91a8ddabbea5521fc65d724e4fb3672c070f9213fae7b58595fcf62f3c85.json
│ │ │ ├── 596a13e9b322f8c3332a3ae214ca7bf368ba6158ff3adcc7db4582f102ea51dc.json
│ │ │ ├── 5a34ded8b2dd4c10c4251d287580918b88690c7058dd2299d2d670170104241d.json
│ │ │ ├── 5b939ad6be2b3eb4da79f453386a58c6ff6661e02d3089092784a774c1957ae4.json
│ │ │ ├── 5bb0895d417e210a10ebf2c63ecf3185dca651057d952203e2e39abc249180a8.json
│ │ │ ├── 5cb59d9c1e81aa29d98ffc905bee257072403820c1612faa5513af553ce9af07.json
│ │ │ ├── 5d43f2c9afc3451745d2a1b5f1ed17bc138c9b19caff61beadbc40a4e970b525.json
│ │ │ ├── 5dd23426751ac130946faa8dff76b2200a8a6c5515863890c15ac5c6c3a542d3.json
│ │ │ ├── 5f1bede7e08769186ddcf9e524e41c839ca5d4ead29bff053524cdfa59654e2a.json
│ │ │ ├── 5f945cb173f2e6eea3ee99955caddb779d2910ca41a82c20e12965b94528a15c.json
│ │ │ ├── 5fcd0fdc47a301cbd07130dfa7545017b5975c29b9d7458ec65938ed503506a6.json
│ │ │ ├── 60ef2d6fded9e070e35cfed119e8325010aa76bce63aa74ca0f25005d8ed9517.json
│ │ │ ├── 62dab578f0debe93a8df61a1776953980abd254ae6f1eea77afb00dfefd28f15.json
│ │ │ ├── 62e55882d2ce81dcfe5df1d58395e767ded55d6e39997d82cc3489c5e02ae910.json
│ │ │ ├── 62fba7c993ca0a10198cab8e5a7538f39f88c00face1d324cbf4613effb5ee1b.json
│ │ │ ├── 6469cbb1dbfa0b98762fde08c6fd05cc831e1cf21bd61aebb56b34a7e916bb1a.json
│ │ │ ├── 63a85c116feeb64b68cb48c92e34e48a89fc4ed443f40594b62751711b6e13e0.json
│ │ │ ├── 63d01f1af1f4f0c9ad42a8c4d310a5b9df5547d5526c49284255fc6ff15ace9b.json
│ │ │ ├── 6506009802a6a268be82b680ace7580613f18dc702ed27aaff929398a85760f3.json
│ │ │ ├── 65c9b0921f2ad9b0228f5685c40d5b341da9604f8cec59339fded4cf0bfa40f4.json
│ │ │ ├── 6802a4e7d920a2a5ce48a61989565f6fa769c314a01b9179cb9d44653b9467ae.json
│ │ │ ├── 682101a547823a97feec07270aac7a9176109731e376f49d3429fe01db3e8719.json
│ │ │ ├── 688d055d3333b15b1beed13c74d14b507a1db1400918f22f01c3bc37bdb4c84e.json
│ │ │ ├── 68f74aa5f41fe3d0e77d36da903e26c49346ee9226c8218a71f7b44c2c41bd93.json
│ │ │ ├── 696bb846cc6a3735e085b502a1008e270903c81c7a7adcc7676abcf1930e926a.json
│ │ │ ├── 699dc95b512c50339855225983dce69519cdcfd21b830ef92b94c885ec7b9f6d.json
│ │ │ ├── 6a7b027b7f77736aea87ce048be3247c43cf4878e8a1105811247839427ebcf8.json
│ │ │ ├── 6b60f8a4e1c97f193edc097890ea2759ec246b8ea09035b2afe8b0b32181f345.json
│ │ │ ├── 6b88758a380fd9aba4793825f7a8296a9fc2bd40ddb5b423e330c8472c1b030f.json
│ │ │ ├── 6bd49215f8d2739a9e365dcc8855b95cfa34352c067a9e9ff48a623318c4ddaa.json
│ │ │ ├── 6c3fed09db545f5c9fe4ed1820b386f74deceb2f52e16b6d88181f36dcd09591.json
│ │ │ ├── 6c69494ba124d4bc564bcd3a3df1f33ae0ee0769001a2dfc46825ef8b44dc3e2.json
│ │ │ ├── 6c983917b91998d7130b467295146438ee4a631519fb7c54b582340f90bfc7e6.json
│ │ │ ├── 6d3b094dc56f9b26ce7374ba28e460b85248711223cf516f8e732c5d8f4d9d75.json
│ │ │ ├── 6e56bc8cfd4ce0c21b56fe2b18b7923c24b34cc09ff39276d1438666c54b074f.json
│ │ │ ├── 6ee9154979fb497a8ec229a7177660af53a487e1aa17c7b555cdc67797615162.json
│ │ │ ├── 7093cbd0a855750457814388b6e527fd3b612163f7a2b898ed6ce470dae74843.json
│ │ │ ├── 71e1e6a563535dd1761f4048b6638e891580152d186b926416df47f8f2948782.json
│ │ │ ├── 72d77ebab0d6c84d4c8e54af7eaf7fae0344a476dbc91cceedae586a5e54f3c6.json
│ │ │ ├── 72de23f55766289c980e6ccf604ca5d162e0989a222271b7496775ba4d749e3a.json
│ │ │ ├── 749d284b3c2e0b313c52afd8d5707b846e0e87e537e3c5fc2455c825f43b3947.json
│ │ │ ├── 74c2a0c0f83087673b100b7555e506c71d2a6a941677e388a1de59371b6d9afe.json
│ │ │ ├── 7592b56384d6034a7fc29e92b9e6d8b494cff9ff5536a9880a95856d28942151.json
│ │ │ ├── 75fad6124616f6d9e50ce9a475d4e989955c5fae7c5566b99992d986ef9a1f93.json
│ │ │ ├── 77916c3b035a577b24da1a1dbcf47db7307af4e0774fecc549f7c95b91994a20.json
│ │ │ ├── 77f3cfc32bc9234033fcc789ff327a4a98809057bd34d1539b7f2883ccf74ab3.json
│ │ │ ├── 7805df77950548e276a1dac439cf36a3620025bdb8a5739e7a84237f38d01a8c.json
│ │ │ ├── 780c2256439479b3eadb498309a4309b20313a36633aa8bec2a0dfcfce3a3d32.json
│ │ │ ├── 78381b49b02bd2a6248eb0363cbceaafe887dbf8e6b19dc513739b35b87cca95.json
│ │ │ ├── 791d193245824d3da6413fd877ab4a079771d29f800f41c9c4774c86f1a903df.json
│ │ │ ├── 79cb5047a177592ad737ee438a5e0de370e5dcd28b164b5a48234a6e4f554b1e.json
│ │ │ ├── 7817261e311bc9c5a0405245bad6d0f942a41193646a3d1112bb4b9fed70c08a.json
│ │ │ ├── 79ede3c31ce20a16af1a767ed248e4a7840803b6d34cf90f1205aa36f3acd7be.json
│ │ │ ├── 7a886aec3fe4d432060a7ad6c88a9e0fd810a122ad1d244456af089a828af8c1.json
│ │ │ ├── 7aff3a4d59fae95d952044a679293bff08c7305212ff12bd7be1d314e1f25a3a.json
│ │ │ ├── 7b25ccdfeab6d57509c1b8a9745fb601ab7e8fcae0e73a22932ceb725fd38c9a.json
│ │ │ ├── 7b6916f499ae0ceae3eb3d509b51f42a7eaa98e43e161fe87c44b9a5bb01287b.json
│ │ │ ├── 7c9789f4b51a967448da1de52e4ac3bfb4b342284bab1257343262aaa32b0d6d.json
│ │ │ ├── 7cd278b7ea82af8777e4da2f84fd1773dae3dd371744f66bbe12c0b925fd6486.json
│ │ │ ├── 7d004a8b515e202f3f48d5c3038f29e5562543bafdef7607cf35b22dae6fa1fd.json
│ │ │ ├── 7d276789b80c8d9d1677556b3fb33b2da05988e97f6bf28903a044bb9fd64894.json
│ │ │ ├── 7e136779a95d8e8e2ac6cad2192890254fd0d54d46b4f5514debba0f22618d75.json
│ │ │ ├── 7e1c0a38a3ed90205d1829bdef528887ef12c928d4da797e06a79b3e7a1a92ba.json
│ │ │ ├── 7fa9734f0b6c5bbde268cb55ce9ba5016e9fd7162daaa665ed305a2d7e999bbe.json
│ │ │ ├── 80468d8a995fb4092dfe5e57e25592c06713287d48367b675ae4d00955d4b25f.json
│ │ │ ├── 806a3b07b690ca3871b1854aaff6f9042e48cf7aaa9bb9b6f15ab9634eae3b37.json
│ │ │ ├── 80ee0e1cf4fb023c632197378ec4601f72c438df11001fe2648f59ae2aa89199.json
│ │ │ ├── 80f142842848c4420eeec86959e77c24566467dbece6a275dd9c6da15f98551c.json
│ │ │ ├── 8206f93340496777fb5c0c2b431493c2d243c12fb74dc8cac46123d15f9d3f44.json
│ │ │ ├── 81d5fbc2e91118aac67b18de1ab980f4dd81c8f7804a195e30017626db220ce8.json
│ │ │ ├── 82fcc152dd67510406aa894d1e38939bac365093de9f34db4dfe2485806fda4f.json
│ │ │ ├── 833fb89a8d72afc195b8f5bd7338109d8ce7be79f240dac31c4dcd36c8c674f6.json
│ │ │ ├── 835c014cd4e3398cae6e9466790acda90cd37183b9a777ddece3881351026c3c.json
│ │ │ ├── 84579b3014e1a83e4201862df1e93378f5f953fbb547fea30965e335be5e7b1e.json
│ │ │ ├── 84dedd4165eed360c4051690b2aa84f6ecc08f3f053f4129ca6f3e33c334ba35.json
│ │ │ ├── 876b8154980e87a39dbec815f7b9810aa93ab2f5af2c8c483a2dfddca662dc37.json
│ │ │ ├── 8790d1fdf629fe1dddd54df9f0f37c212fb3308b574efeada0108e83ec8b33ec.json
│ │ │ ├── 853fba8af82971f4fa56d957282d45d335d2e794f8e11001977161265af2ec79.json
│ │ │ ├── 88ade181c6611ec33d9bcf5fe739dc809d81875b6571b9f60f87dafb4f5941b2.json
│ │ │ ├── 897df8bbfa3d5a2c272916d47f3326de2b1cad7c3046015c949a46f69e9b8247.json
│ │ │ ├── 899b16856382d32c4e5b39d02cb251bdedaf35c5a81bede5c19bfaff092208e9.json
│ │ │ ├── 8a33ace67f0a10b86cbecbba57107ea42ee61fd12b496085dc98f23d8b300c99.json
│ │ │ ├── 8aeea7acc875fd642cb75f8766dc1fa84e8f91f027c3ced3bf44b8faa1a2798f.json
│ │ │ ├── 8b45a75ab824f6e8b21a94f6828c0da172fd242fadeb1f61b32de34b80f62cee.json
│ │ │ ├── 8b7bbf32006c5ceccb03e4965f57199e55c6309eddcd09e544d7e64e8e383cae.json
│ │ │ ├── 8c258f28e7a23d569a250b17e2f591ea2b1a5b9072ab1e0ddff1b535468badb7.json
│ │ │ ├── 8d4849ca8361bd159ec0aeb98091c413998455ca036cfa43f0cf63a6912f8955.json
│ │ │ ├── 8e216def3e5fce2d7f4db160b9bc86433bdb5f0638be1367af2ab095d06571b6.json
│ │ │ ├── 8fd86558bd11e7a00f6b28cfad545707ef94afd87a9095ab532588b36c49f842.json
│ │ │ ├── 8fe2c3236c2b05a4814f32898daea17b341332cd0cc887df7a154ad999480d75.json
│ │ │ ├── 9141ebe765f15cd7e3b24431f315af03e41a165ff0a54c33bec2c4884515eff2.json
│ │ │ ├── 9137c44b276781b18d9e969742f438877770ff9e8a662611892eed8fc16d93c2.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
│ │ │ ├── 9d0e2a1fb14529aabd31c82c8d8f5543c0068366932934038ba93e234f291e22.json
│ │ │ ├── 9e5144f3933fd4424f1e271f60b78ae110855d238a7921b499b1586d21ad16a9.json
│ │ │ ├── 9eb524ba1a6374b2d29429652e7e8bf087d839fecd65494380982eab349382a8.json
│ │ │ ├── 9ed5e291859abba38b07725bc6cf0275a1548e937888a5ba177674afaa48b96f.json
│ │ │ ├── 9edcaa74bc64450dff4e25af0b02a830bf940c608b2cfaf74092a2d830f8b07d.json
│ │ │ ├── 9ef0e904ba6d1b295c75a1d5741851f571339a8e22c7de96bf5e2508f764ee43.json
│ │ │ ├── a04a8ed5ca0ac9661dda4f6f369248937cf67e61cc982a1f17b24495263d53d6.json
│ │ │ ├── a05b3a5bb6e91642075fb765d43a0a6e4dc595bb9131be86643e6d06e6fc3cd9.json
│ │ │ ├── a063cce1553aca090b61f3b2b621a089d1c128ff347e11f836857b77ef5f3d56.json
│ │ │ ├── a081526ffd8f52690e0834cc71e9d2bf987f949c2d0de68a6eb024063d97e64b.json
│ │ │ ├── a09e24d5cfcfe359ea13c37261c18e592629a9aeb827faa609ccef4fe7b6a243.json
│ │ │ ├── a1bd5a32741129c967a18413ae1be6bf3e20754251066d342d652a668ef95025.json
│ │ │ ├── a1d213c52d246524be10c084f9134e77ff2e2a9aa4361b71dc4bedc89f461cdf.json
│ │ │ ├── a3c770875ffabc18c595532501255df82b1a09246ee54b831e7c2221932d4f88.json
│ │ │ ├── a3f339fe1ccb99a051c1a3bff70e7de0e2ee6b952db0aa2d6739e97331c29cd9.json
│ │ │ ├── a4a27e7b3842713c361895d6d9c5fc9e4341cce71c5842f014748e1c1c23a197.json
│ │ │ ├── a5231d68fe35e6b02ff4ff70e02a3229c08d9b6c2bae774a1b3227823b95e15a.json
│ │ │ ├── a5c3aedceeab625bb2f60c9e97163491579353e685d8b230342a4e3e30f00db6.json
│ │ │ ├── a614593a975a3af1612ede797f69fd29220ee9251ab016d34d5e612a04696ce9.json
│ │ │ ├── a6840d352ec8910ac57e79b51062d6da1cd3b277dd849073eb9c3745fafd3635.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
│ │ │ ├── af2a54bee158552858e4c0220e207ddc814460a4db1126ec08ba3dd799624e0a.json
│ │ │ ├── b390f7bae9f80c48e703a6664d9de6ec9c141cdf8ac2f012d67ac2b4d8a25b4e.json
│ │ │ ├── b39d104e50bfbd13e83fdf946668708536d98cd0c6228eded5fabd5bcf6ddd18.json
│ │ │ ├── b4446ca11a1f4f6ea45256e755ed56d6ba7c330278e15ead8cae2de28d5e18d8.json
│ │ │ ├── b4db8b4f6749eb80079ccf2d1a2e81dae05804168c415767ed58a23f16ae2ef8.json
│ │ │ ├── b51c7386a49f53f1e51e3c0534090d4b3e068752cc09b3bf1c07acae4897b44e.json
│ │ │ ├── b5349ed182daa82709b46b433d85ac4c9a610994647244f39a895138f087c4cc.json
│ │ │ ├── b58aa068f1fd9b9bbdc1f9acfd10920458e7ce8cc56676580d1970e1375e7c61.json
│ │ │ ├── b6a1d3264457641619e6f60d3f961556b765901b600688661d4795c974cc1eaa.json
│ │ │ ├── b5f9055685d5b38a3dc351cad310f1a36783f0713c3f0621681fdb43bbf90311.json
│ │ │ ├── b6e731246dc10854a0fa0455c3c5816e29887b1a8c436d518926e1c2a5e36239.json
│ │ │ ├── b7034e69b8b9437fdf34faa4adc116aaca2fcee50e966a400972c365076e88fc.json
│ │ │ ├── b736fc83f0aef50bd9c65d26cabc1cadf09c0469465e44fb8fca8b9b30bee7a4.json
│ │ │ ├── b922fd1a31f3cf602f197c872f3bac6e69c79255e502ddcbf1626f846ec3baee.json
│ │ │ ├── bafb94885159908a9055100ceaf4034f3822bbb4f78672fa552b0a276a0c895f.json
│ │ │ ├── bb3d09cc5d1bc380e841e2eb19cb497391117101c73c358e6db03db4d6ae51e7.json
│ │ │ ├── bd5b6283310ced47ad086af119cad6259d88c1d2857a6aed4f4f9d8a86233de8.json
│ │ │ ├── be6cc64f9c2a8edbdbbd10a1a31e3b3a746eb4bddf806c71d525eff5719b9217.json
│ │ │ ├── beb1b14e942397ae4b9f2e8a743282f127cc9f8c6e7222db7a28185de4493e57.json
│ │ │ ├── c1060dca2a3f437f386324975be659215fe9f7e689137dc85e823879b462d11f.json
│ │ │ ├── c10793a36312ddf2bd8ed9edbae5d67a19f54b78ac3cae01682d38c34f2bdf11.json
│ │ │ ├── c1943dfe9eee1a9f88ea571dc5aa06a3f521241fffa51d55b1414c675e63c236.json
│ │ │ ├── c12ef179e0f1696b651a9fc423a169905160de3634d480aedf6071f85a1ebb4f.json
│ │ │ ├── c199fcf03e3df0de15c0e7fa5f89fbd9649f2be4a257c8de9a7ebc69ce0d629a.json
│ │ │ ├── c1a36d35264d786572e00bd20a4d62c18000e4a5a744b4bbd585c66ba2b1afa4.json
│ │ │ ├── c251d835538bf3c08a96268c8b1feaae8084a9c298d634ce2f2ee3b4b15188ce.json
│ │ │ ├── c2b1cd2a2842bf81134b918097c420440c56e69a46b7796fa07d02b2db7a3fab.json
│ │ │ ├── c4878d8d8ae19e705821ae40afc1ede4be1a64b559dfdf89f898cb4383718c93.json
│ │ │ ├── c8c8647cd740fbc96327657baebd4b4a16bfa1d8e7d4cfe3f845a0a5ab7c98f7.json
│ │ │ ├── c6b29929825f59e03fe10ecfc6ab1c0e06594ca745b0b16bddfdbf5b69d3a32f.json
│ │ │ ├── c6d29b81d6bbf9e14c7bd3b189339b57188658ed2a270f6ba09e5b3ce071b3c1.json
│ │ │ ├── c9e9e63d70bf1ece48f627973df4a1abfd081787641e80a93f1734f30535fd99.json
│ │ │ ├── ca42945436e914d9530e20dc780794451ec6ab51e17414e8c9107ccad3125fd5.json
│ │ │ ├── ca8e9d2e76f32d42a0da88c9477956b47dca6cdb27504d8d126e0075306eae98.json
│ │ │ ├── caac8d56f4ba7b3987d86c31357c6ffd2c4125d16e4637cb5a4568ac6eee29de.json
│ │ │ ├── cb744c0a1cb5fa65e239993bee622ad4a60990c628cd1d4503f9e8806d1d2c92.json
│ │ │ ├── cab9c3edc4d8c5269c4e14324ef0f6146385bad5e4403cc989acc30c402a7409.json
│ │ │ ├── cb87cd70f59a8e0950529ae42a2229a259ff8b305e4319422fb1a9dbc41e63e2.json
│ │ │ ├── cc5e32b33a50eb2225359c15bceebc406d04a07218cda65de3e6f79603d67416.json
│ │ │ ├── ccd15ad60b63cd29e2bd2762133a74e2047a2894fd67206dff7f7c7fd082b399.json
│ │ │ ├── cd4804092002a20d5ed88c32b577b0868a0ef39e518c749dbadec455775917fb.json
│ │ │ ├── cdeaabefa47597c8be9310653d52fb6870d859162b49c8dae6db8f4f699dc4cc.json
│ │ │ ├── ce52c1ffbf8db0d0b4ba85e0a07055258a8eb179db097f1df874e5be81aae49c.json
│ │ │ ├── cf1725a1db0d7ecffa63d8b1b693854170e55385eaa4b0cc604f9d6ab204eb4e.json
│ │ │ ├── d0765ede907ec0d299b023be2178bd7c85b9d37463b94c29454a904069676cc1.json
│ │ │ ├── cec4a3cdbea8fa16c18e49426a9cc3be455cbc93cd715159c8b5b4232085f088.json
│ │ │ ├── d083dc75811629ffa77ea30c1965e67ef3bc2bf65e5b20a1a35cb0f80a46949c.json
│ │ │ ├── d210ed7569b9e06acf7037d9a7d15ccac0f6f95ef7d3a0707741db8629443177.json
│ │ │ ├── d22b1453361617f8ed7de22e5aace7a02cfe67049aad5fa4b1849d9f8b42b73e.json
│ │ │ ├── d114d2e42d000a18e9502fcb48c3c2bf04f4a6e2de34af8f951279b16a2f3784.json
│ │ │ ├── d188f9185e71f8910607d275d82fea22199ba0e6009a5b1e34e5a20f041f0d71.json
│ │ │ ├── d2513d4f5ff7e6088864ff497613f47d4083c1ca378649a3f1db6d45a3f3fb2b.json
│ │ │ ├── d26a8fc663e9e5fcf33c87592f8c0456e7fe9ec3390bcb902631144d455c2922.json
│ │ │ ├── d48290356397f2b79e05fc0b6bc6f9c327da366096ff14c15e90516fad7218a1.json
│ │ │ ├── d55e8987ca5ff35748b62290dc14150b1df53163c6f5a5cbe19d03058368f1c6.json
│ │ │ ├── d561ececcdd4e7ff59595389fa499591f4e7f3c5324a58dbdd749ccf29e90cd1.json
│ │ │ ├── d5dec4474de6c46020e987f20aa3fe54c9bb9c6726a067a75ed94091d552b87f.json
│ │ │ ├── d87c7664bacda28563f3f367221789264d43fb4a53abe3afc5692f77c75c6d98.json
│ │ │ ├── d8cda86cbe3f42bb98bb0097071d95924950dfc6ed65a7075d3b22ff1ae0bf0b.json
│ │ │ ├── d9516c3232e0f78225764bf4f9586c88932df925c45de71f4eb1c883a9ed5b5d.json
│ │ │ ├── d9c81db9cf9f2fee8a4985367d33b1c0ab1d7e6901e85473070c3bfd6bfe32c6.json
│ │ │ ├── daa187dac6877c746c02269141a7e7db178ac32a0b3a4bfeaaa8ed046b6dcffb.json
│ │ │ ├── db04dbc0e2dca073d2b145db8ba6c2c5299d3fd93742e1022c611c938fcb4d80.json
│ │ │ ├── dc90ac5e8d6766f8001bc2a40d21c203717d7a9f469956c2f6d9bedf3ae51aea.json
│ │ │ ├── dd795078450fe69f61a445d3cf1275420be57381b005597cb5003859b2025800.json
│ │ │ ├── de1655ae1d42e56f0cdb8788c0f6ba1c7c6e6725e6ef5e80969b92ec3e68ac78.json
│ │ │ ├── df2d803fc69084e91df819bda27a7e9f7303a40ff7a5d30d93fbc16daf5e2d45.json
│ │ │ ├── df8f2619ccef593ab5bc5a019792f194699a9f3014968288bb01d7773ee3a8cc.json
│ │ │ ├── dfc9279b0d9023afc1dbe55536fc1cd7a556a6a437775128b9e41615431b4733.json
│ │ │ ├── e0373f56a7e20838a75d2c1920393433e8d9a6ffa919334851b70f210ed4dbf3.json
│ │ │ ├── e15f9a7e0698366b7d8b4bee6102d351241860a078550f47a1eb27503e521bbc.json
│ │ │ ├── e2c372f92146217844d34664334cb0d7f01fe262ff98edf009a66c4bf4743b5c.json
│ │ │ ├── e2f4f5c0bcb971ba3cb08a4ba14ed5d1ba6b5899fd36db1f5dcdbd2313cd318e.json
│ │ │ ├── e359eb0e19f76b69decc0ec747a7fb4c9843046bda31c5e45841858e01bcb5a7.json
│ │ │ ├── e42f6a96ad8938a6ab248b342feca7ec6ecf2a734d25141f62dbc9a6c52ecf6b.json
@@ -563,40 +504,35 @@ customerTicket
│ │ │ ├── e53a0dfeecd1b547e38896f8e7920e6801d84dbf61bb4513541892d44d42ae99.json
│ │ │ ├── e6b4123e64b20d577378853fe71efb3743dbe9aff24ca06067b4a2c1c2efc53c.json
│ │ │ ├── e7c0ad70ad7e7f3a1f6f35e55f4cf87f6c0850abc63de71a418e81b88ec826b7.json
│ │ │ ├── e810aa586fa2e56d1c31e6940e0e10616ee52ab4bf328e928e0beabfcecd8fb1.json
│ │ │ ├── e831be2a985dcf2094a014d182ea02e4ee10b94fbd35b44e3c69a98a7acb3451.json
│ │ │ ├── e8f3b285df9ff4d30c78eb1eded3eefdd86cfe325a7c8c29fb8457b6dde5e712.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
│ │ │ ├── f3b514e12a39c61de0788da629a34cdd1359c17ebf1ff20747c7be245326f68e.json
│ │ │ ├── f41de3fc65a6805cd39a47d713e1d224ba3c4eff476f0aeaf95b5e9b44cbc7ed.json
│ │ │ ├── f4436ad45c3f2a27b4461333ae2ed21af157a3945e152fca7f43d640dc39f63b.json
│ │ │ ├── f576af4a0cecb6cbaaf8afe5097913be0b7071e200fc924c918ea93446f00c91.json
│ │ │ ├── f58927b2be96cb8769bb26a2ede3d8c05796e7a79fefd7fdcdc89d658609fc66.json
│ │ │ ├── f71ec68c7b2a4467358d618354305f3f4e99cc0dbe60fa00d39478b686fe8bb2.json
│ │ │ ├── f8769e3eeb23aebc01d72869a2bc6e635d7dc82440bc2b0b0e11e6b6b17f5109.json
│ │ │ ├── f8fc55a028176d1ea9a1b580a8653d4c45ae7466445f37e6c23867f12821c44c.json
│ │ │ ├── f92455d5b493a0d7e9565d2c7463a1aa3fe62fa20ea19e635e66c35582ab685e.json
│ │ │ ├── fa19a1bc8027144f14106a6f90d4ddab0277580b8bcdb6e9e3ba67496c043d62.json
│ │ │ ├── f9a8e68335360a72fdc96c97c3570febcdea593cf9987e3e92b769107524c4e8.json
│ │ │ ├── fb9736a4ef3aca74573f131c7cbd9f015d88dd755ffba3edd324aec95149175f.json
│ │ │ ├── fc313334fba5b89ae7b30c44e1b0bd43e185bfd06c1e9804ad1b9b6de65efad8.json
│ │ │ ├── fde577f1231f9b033560bfe075896efc6bf8d6079a030a159035584c7d39843b.json
│ │ │ ├── fe032050843e4e1c28f5712205df74fa1698390169658c4ddc4555b6bcce8872.json
│ │ │ ├── fe0c3eed2ff5c4b5d2116aba4494d599ae3c27004136e99f5d455f351281fa78.json
│ │ │ ├── fe77fbd662d91d04f2efc69788d30e9859584c8d87d1fabd60ca66661568091e.json
│ │ │ ├── fe93d0fb278c013183276f7bf5c5e2d0e62fe9c75f6695390f5994ec0b9c8dec.json
│ │ │ └── ff1bf9257c3d267b5a04560ed07ac6d4442a145b3e6db5a0f8e63e8e7f386533.json
│ │ │ └── ff809a93fdef0b7b8896c56bc9048369340da05981fc0435be179cfe930e7e4c.json
│ │ ├── semantic
│ │ │ └── pa567fc138e3a
│ │ │ ├── 00825b46909baea25186d0f8351fc2c3fae63520319b5e7bede4ed3e9f53e54d.json
@@ -687,7 +623,19 @@ customerTicket
│ │ ├── 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
│ │ ├── query_20260913_171644_3abcf227_اگر_شناسه_گروه_بله_و_هر_سه_شماره_سرپرست_واحد_خالی.md
│ │ ├── query_20260915_053301_10c27f6c_افزودن_حل_گروهی_تیکت_ها__نمایش_وابستگی_ارسال_گروهی.md
│ │ ├── query_20260915_055234_c1041465_افزودن_شماره_همراه_سرپرست_چهارم_و_انتقال_متن_پیامک.md
│ │ ├── query_20260915_055928_a33fed39_آیا_خالی_بودن_first_name_last_name_یا_سایر_فیلدهای.md
│ │ ├── query_20260915_061852_8e9d4243_اصلاح_mobile_first_پنل_ثبت_اقدام_و_مینیمال_کردن_پی.md
│ │ ├── query_20260915_083455_5d9d8069_فقط_تیکت_ثبت_شده_توسط_مشتری_باید_پیامک_اطلاع_رسانی.md
│ │ ├── query_20260916_065746_6a844b76_صفحه_جزئیات_تیکت_staff__وضعیت_دقیق_پیامک_مشتری__تض.md
│ │ ├── query_20260916_100236_c99a09d8_retry_behavior_for_sms_created_from__staff_tickets.md
│ │ ├── query_20260916_104448_13a076e4_یک_retry_خودکار_کنترل_شده_فقط_برای_عدم_تحویل_قطعی.md
│ │ ├── query_20260916_111348_12184b5e_دنبال_کدهای_خطا_سامانه_0098sms_بگرد_در_source_کد_ا.md
│ │ ├── query_20260917_060706_47ef388c_اصلاح_رابط_موبایل_صفحه_جزئیات_تیکت_کارشناس_و_magic.md
│ │ ├── query_20260917_063554_1824eb56_پس_از_باز_شدن_کیبورد_موبایل__کادر_نوشتن_پاسخ_تیکت.md
│ │ └── query_20260923_131543_e5730a0b_how_do_staff_ticket_sms_status__failed_delivery__a.md
│ ├── reflections
│ │ └── LESSONS.md
│ ├── .graphify_analysis.json
@@ -744,6 +692,7 @@ customerTicket
│ │ ├── ticket-unit-options.js
│ │ ├── ui.js
│ │ ├── voice-recorder.js
│ │ ├── voip-call.js
│ │ └── webotp.js
│ ├── src
│ │ └── tailwind.css
@@ -772,6 +721,7 @@ customerTicket
│ │ ├── _older_messages.html
│ │ ├── _staff_customer_results.html
│ │ ├── _staff_ticket_results.html
│ │ ├── _voip_call_button.html
│ │ ├── customer_detail.html
│ │ ├── customer_list.html
│ │ ├── customer_new.html
@@ -789,6 +739,15 @@ customerTicket
│ └── base.html
├── test-results
│ ├── e2e
│ │ ├── design-prototypes_ticket-new-wizard_test_prototype_e2e.py__test_ticket_wizard_progressive_flow_preview_edit_and_attachment_removal
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── design-prototypes_ticket-new-wizard_test_prototype_e2e.py__test_validation_message_is_focused_and_scrolled_into_view
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── design-prototypes_ticket-new-wizard_test_prototype_e2e.py__test_voice_recording_auto_stops_on_next_and_can_be_previewed_and_removed
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_chat_coach.py__test_customer_sees_chat_guidance_after_two_rapid_messages
│ │ │ ├── failure.png
│ │ │ └── trace.zip
@@ -807,6 +766,12 @@ customerTicket
│ │ ├── tests_e2e_test_customer_unit_categories_e2e.py__test_customer_switches_unit_and_sees_its_categories
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_mobile_ticket_viewport.py__test_magic_link_mobile_viewport_keeps_composer_messages_and_credit_visible
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_mobile_ticket_viewport.py__test_staff_ticket_mobile_viewport_survives_open_close_and_rotation
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_organization_permissions_e2e.py__test_e2e_02_contact_group_picker_excludes_other_units
│ │ │ ├── failure.png
│ │ │ └── trace.zip
@@ -858,6 +823,9 @@ customerTicket
│ │ ├── tests_e2e_test_pwa_versioning.py__test_pwa_registers_versioned_worker_and_matching_cache
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_staff_operations_ui.py__test_dispatch_report_presents_delivery_and_engagement_professionally
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ └── tests_e2e_test_staff_operations_ui.py__test_staff_operations_pages_are_authorized_and_visually_styled
│ │ ├── failure.png
│ │ └── trace.zip
@@ -877,6 +845,7 @@ customerTicket
│ │ ├── test_chat_coach.py
│ │ ├── test_customer_unit_categories_e2e.py
│ │ ├── test_external_environment_smoke.py
│ │ ├── test_mobile_ticket_viewport.py
│ │ ├── test_organization_permissions_e2e.py
│ │ ├── test_public_ticket_workflows_e2e.py
│ │ ├── test_pwa_versioning.py
@@ -895,7 +864,8 @@ customerTicket
│ ├── test_persian_text.py
│ ├── test_production_seed.py
│ ├── test_role_workflow_regression.py
│ └── test_views.py
│ ├── test_views.py
│ └── test_voip_calls.py
├── .dockerignore
├── .env
├── .env.example
@@ -905,8 +875,6 @@ customerTicket
├── .npmrc
├── AGENTS.md
├── db.sqlite3
├── db.sqlite3-shm
├── db.sqlite3-wal
├── docker-compose.yml
├── Dockerfile
├── Dockerfile.nginx
+20 -1
View File
@@ -1,4 +1,5 @@
from django.contrib import admin
from django import forms
from .models import PageTitle, ProductionSeedState, SiteBranding, VoipConfiguration
@@ -31,9 +32,27 @@ class SiteBrandingAdmin(admin.ModelAdmin):
@admin.register(VoipConfiguration)
class VoipConfigurationAdmin(admin.ModelAdmin):
fields = ("voip_call_url", "updated_at")
fields = ("voip_call_url", "announcement_url", "announcement_id", "announcement_api_key", "clear_announcement_api_key", "announcement_delay_minutes", "updated_at")
readonly_fields = ("updated_at",)
class ConfigurationForm(forms.ModelForm):
announcement_api_key = forms.CharField(
required=False, widget=forms.PasswordInput,
help_text="برای حفظ کلید فعلی خالی بگذارید؛ برای تغییر، کلید جدید را وارد کنید.",
)
clear_announcement_api_key = forms.BooleanField(required=False, label="حذف کلید فعلی")
class Meta:
model = VoipConfiguration
fields = "__all__"
def clean_announcement_api_key(self):
if self.data.get("clear_announcement_api_key"):
return ""
return self.cleaned_data["announcement_api_key"] or (self.instance.announcement_api_key if self.instance.pk else "")
form = ConfigurationForm
def has_add_permission(self, request):
return not VoipConfiguration.objects.exists()
@@ -0,0 +1,33 @@
# Generated by Django 5.1.1 on 2026-09-28 15:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0007_voipconfiguration'),
]
operations = [
migrations.AddField(
model_name='voipconfiguration',
name='announcement_api_key',
field=models.CharField(blank=True, max_length=500, verbose_name='ANNOUNCEMENT_API_KEY'),
),
migrations.AddField(
model_name='voipconfiguration',
name='announcement_delay_minutes',
field=models.PositiveIntegerField(default=90, verbose_name='تأخیر تماس اطلاع\u200cرسانی (دقیقه)'),
),
migrations.AddField(
model_name='voipconfiguration',
name='announcement_id',
field=models.CharField(blank=True, max_length=200, verbose_name='ANNOUNCEMENT_ID'),
),
migrations.AddField(
model_name='voipconfiguration',
name='announcement_url',
field=models.URLField(blank=True, verbose_name='ANNOUNCEMENT_URL'),
),
]
+4
View File
@@ -124,6 +124,10 @@ class VoipConfiguration(SingletonConfiguration):
blank=True,
help_text="نشانی کامل endpoint تماس، مانند http://127.0.0.1:8000/v1/click2call",
)
announcement_url = models.URLField("ANNOUNCEMENT_URL", blank=True)
announcement_id = models.CharField("ANNOUNCEMENT_ID", max_length=200, blank=True)
announcement_api_key = models.CharField("ANNOUNCEMENT_API_KEY", max_length=500, blank=True)
announcement_delay_minutes = models.PositiveIntegerField("تأخیر تماس اطلاع‌رسانی (دقیقه)", default=90)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
+26
View File
@@ -0,0 +1,26 @@
"""Outbound prerecorded announcement calls."""
import requests
from django.conf import settings
class AnnouncementRejected(ValueError):
"""The provider explicitly refused the requested announcement."""
def place_announcement_call(configuration, *, mobile, idempotency_key):
headers = {"Idempotency-Key": idempotency_key}
if configuration.announcement_api_key:
headers["X-API-Key"] = configuration.announcement_api_key
response = requests.post(
configuration.announcement_url,
json={"customer_number": mobile, "announcement_id": configuration.announcement_id},
headers=headers,
timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS,
)
response.raise_for_status()
if response.content:
payload = response.json()
if not isinstance(payload, dict):
raise ValueError("Invalid announcement provider response")
if payload.get("success") is False:
raise AnnouncementRejected("Announcement provider rejected the request")
+1 -1
View File
@@ -49,7 +49,7 @@ def place_voip_call(*, agent, customer, method):
response.raise_for_status()
if response.content:
body = response.json()
if isinstance(body, dict) and body.get("success") is False:
if not isinstance(body, dict) or body.get("success") is False:
return VoipCallResult(False, "سرویس تماس درخواست را نپذیرفت.")
return VoipCallResult(True)
except (requests.RequestException, ValueError):
+11 -1
View File
@@ -1,7 +1,17 @@
from django.contrib import admin
from .models import NotificationJob,NotificationPolicy,SMSTemplate
from .models import AnnouncementJob,NotificationJob,NotificationPolicy,SMSTemplate
@admin.register(NotificationJob)
class NotificationJobAdmin(admin.ModelAdmin): list_display=("channel","event_type","status","attempts","provider_status_code","delivery_status","delivery_retry_source","provider_message_id","created_at"); list_filter=("channel","status","delivery_status","delivery_is_final"); readonly_fields=("provider_message_id","provider_status_code","delivery_status_code","delivery_description","delivery_checked_at","delivery_is_final","delivery_retry_source","dedupe_key","created_at","sent_at")
@admin.register(AnnouncementJob)
class AnnouncementJobAdmin(admin.ModelAdmin):
list_display = ("message", "status", "attempts", "next_attempt_at", "sent_at")
list_filter = ("status",)
search_fields = ("message__ticket__ticket_number", "message__ticket__customer__mobile")
readonly_fields = ("message", "status", "attempts", "next_attempt_at", "idempotency_key", "last_error", "created_at", "sent_at")
def has_add_permission(self, request):
return False
@admin.register(SMSTemplate)
class SMSTemplateAdmin(admin.ModelAdmin):
list_display=("event_type","unit","title","is_active","updated_at")
+94
View File
@@ -0,0 +1,94 @@
"""Process due announcement calls from the notification worker."""
import logging
from datetime import timedelta
import requests
from django.db import transaction
from django.utils import timezone
from apps.core.models import VoipConfiguration
from apps.integrations.announcements import AnnouncementRejected, place_announcement_call
from apps.messaging.models import MessageReadReceipt
from .models import AnnouncementJob
logger = logging.getLogger(__name__)
MAX_ATTEMPTS = 3
def _failure_code(exc):
if isinstance(exc, requests.Timeout):
return "service_timeout"
if isinstance(exc, requests.ConnectionError):
return "service_unreachable"
if isinstance(exc, requests.HTTPError):
return "service_http_error"
if isinstance(exc, AnnouncementRejected):
return "service_rejected"
if isinstance(exc, ValueError):
return "invalid_service_response"
return "unexpected_error"
def _cancellation_reason(job, configuration):
message = job.message
customer = message.ticket.customer
if message.is_internal or message.deleted_at or message.author.account_type != "STAFF":
return "message_unavailable"
if MessageReadReceipt.objects.filter(message=message, user=customer).exists():
return "customer_read"
if message.ticket.ticket_messages.filter(author=customer, created_at__gt=message.created_at, is_internal=False).exists():
return "customer_replied"
if not customer.is_active or not customer.local_mobile:
return "customer_mobile_unavailable"
if not configuration.announcement_url or not configuration.announcement_id:
return "announcement_not_configured"
return ""
def _deliver(job):
try:
configuration = VoipConfiguration.load()
reason = _cancellation_reason(job, configuration)
if reason:
job.status = AnnouncementJob.Status.CANCELLED
job.last_error = reason
return
place_announcement_call(
configuration,
mobile=job.message.ticket.customer.local_mobile,
idempotency_key=job.idempotency_key,
)
except Exception as exc:
job.attempts += 1
job.last_error = _failure_code(exc)
job.status = AnnouncementJob.Status.FAILED if job.attempts >= MAX_ATTEMPTS else AnnouncementJob.Status.PENDING
job.next_attempt_at = timezone.now() + timedelta(minutes=3)
logger.exception("Announcement call failed for job %s", job.pk)
else:
job.attempts += 1
job.status = AnnouncementJob.Status.SENT
job.sent_at = timezone.now()
job.last_error = ""
def process_due_announcements():
now = timezone.now()
# A crashed worker can safely retry with the same provider idempotency key.
AnnouncementJob.objects.filter(
status=AnnouncementJob.Status.PROCESSING,
next_attempt_at__lt=now - timedelta(minutes=10),
).update(status=AnnouncementJob.Status.PENDING, next_attempt_at=now)
with transaction.atomic():
jobs = list(
AnnouncementJob.objects.select_for_update(skip_locked=True)
.filter(status=AnnouncementJob.Status.PENDING, next_attempt_at__lte=now)
.select_related("message__ticket__customer")
.order_by("next_attempt_at", "pk")[:20]
)
AnnouncementJob.objects.filter(pk__in=[job.pk for job in jobs]).update(
status=AnnouncementJob.Status.PROCESSING, next_attempt_at=now,
)
for job in jobs:
_deliver(job)
job.save(update_fields=["status", "attempts", "next_attempt_at", "sent_at", "last_error"])
@@ -14,6 +14,7 @@ from apps.integrations.bale.providers import get_bale_provider
from apps.integrations.sms.providers import get_sms_provider
from apps.messaging.models import MessageReadReceipt
from apps.notifications.models import NotificationJob,NotificationPolicy,SMSTemplate
from apps.notifications.announcement_worker import process_due_announcements
from apps.notifications.services import customer_reply_bale_policy,staff_reply_policy
from apps.tickets.models import Ticket
@@ -343,6 +344,8 @@ class Command(BaseCommand):
if job.ticket_id and job.ticket.group_dispatch_id:
from apps.tickets.services import refresh_group_dispatch_status
refresh_group_dispatch_status(job.ticket.group_dispatch_id)
if not options.get("job_id") and not options.get("channel") and not options.get("min_id"):
process_due_announcements()
_process_delivery_checks(options)
if options["once"] or options.get("job_id"): break
time.sleep(5)
@@ -0,0 +1,32 @@
# Generated by Django 5.1.1 on 2026-09-28 15:31
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('messaging', '0003_messagereadreceipt'),
('notifications', '0014_backfill_safe_0098_delivery_retries'),
]
operations = [
migrations.CreateModel(
name='AnnouncementJob',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('PENDING', 'در صف'), ('PROCESSING', 'در حال تماس'), ('SENT', 'ارسال\u200cشده'), ('FAILED', 'ناموفق'), ('CANCELLED', 'لغوشده')], db_index=True, default='PENDING', max_length=15)),
('attempts', models.PositiveSmallIntegerField(default=0)),
('next_attempt_at', models.DateTimeField(db_index=True)),
('idempotency_key', models.CharField(max_length=100, unique=True)),
('last_error', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('sent_at', models.DateTimeField(blank=True, null=True)),
('message', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='announcement_job', to='messaging.message')),
],
options={
'indexes': [models.Index(fields=['status', 'next_attempt_at'], name='announce_due_idx')],
},
),
]
+46
View File
@@ -70,3 +70,49 @@ class NotificationJob(models.Model):
)
class Meta:
indexes=[models.Index(fields=["status","next_attempt_at"],name="notif_due_idx"),models.Index(fields=["ticket","channel","status"],name="notif_ticket_chan_idx")]
class AnnouncementJob(models.Model):
"""One durable call reminder for each public message written by staff."""
class Status(models.TextChoices):
PENDING = "PENDING", "در صف"
PROCESSING = "PROCESSING", "در حال تماس"
SENT = "SENT", "ارسال‌شده"
FAILED = "FAILED", "ناموفق"
CANCELLED = "CANCELLED", "لغوشده"
message = models.OneToOneField("messaging.Message", on_delete=models.CASCADE, related_name="announcement_job")
status = models.CharField(max_length=15, choices=Status.choices, default=Status.PENDING, db_index=True)
attempts = models.PositiveSmallIntegerField(default=0)
next_attempt_at = models.DateTimeField(db_index=True)
idempotency_key = models.CharField(max_length=100, unique=True)
last_error = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
sent_at = models.DateTimeField(null=True, blank=True)
class Meta:
indexes = [models.Index(fields=["status", "next_attempt_at"], name="announce_due_idx")]
@property
def status_label(self):
if self.status == self.Status.SENT:
return "درخواست تماس پذیرفته شد"
if self.status == self.Status.PENDING and self.attempts:
return "در انتظار تلاش مجدد"
return self.get_status_display()
@property
def detail_label(self):
return {
"customer_read": "مشتری پیام را مشاهده کرد.",
"customer_replied": "مشتری پس از پیام پاسخ داد.",
"message_unavailable": "پیام برای اطلاع‌رسانی در دسترس نیست.",
"customer_mobile_unavailable": "شماره همراه مشتری در دسترس نیست.",
"announcement_not_configured": "تنظیمات تماس اطلاع‌رسانی کامل نیست.",
"service_timeout": "سرویس تماس در مهلت مقرر پاسخ نداد.",
"service_unreachable": "ارتباط با سرویس تماس برقرار نشد.",
"service_http_error": "سرویس تماس درخواست را با خطا پاسخ داد.",
"service_rejected": "سرویس تماس درخواست را نپذیرفت.",
"invalid_service_response": "پاسخ سرویس تماس معتبر نبود.",
"unexpected_error": "خطای غیرمنتظره در پردازش درخواست تماس رخ داد.",
}.get(self.last_error, "وضعیت درخواست تماس نیاز به بررسی دارد." if self.last_error else "")
+13 -1
View File
@@ -1,10 +1,22 @@
from datetime import timedelta
from uuid import uuid4
from django.utils import timezone
from .models import NotificationJob,NotificationPolicy
from apps.core.models import VoipConfiguration
from .models import AnnouncementJob,NotificationJob,NotificationPolicy
def enqueue(channel,event_type,ticket,dedupe_key,payload=None,customer=None,staff_user=None):
return NotificationJob.objects.get_or_create(dedupe_key=dedupe_key,defaults={"channel":channel,"event_type":event_type,"ticket":ticket,"payload":payload or {},"customer":customer,"staff_user":staff_user,"next_attempt_at":timezone.now()})[0]
def schedule_announcement(message):
"""The ticket transaction also commits this one-message reminder."""
delay = VoipConfiguration.load().announcement_delay_minutes
return AnnouncementJob.objects.get_or_create(
message=message,
defaults={
"next_attempt_at": message.created_at + timedelta(minutes=delay),
"idempotency_key": f"staff-message-{message.public_id.hex}",
},
)[0]
def enqueue_unit_supervisor_ticket_sms(ticket):
"""Queue one concise new-ticket SMS for each configured unit supervisor."""
unit = ticket.owning_unit
+3 -1
View File
@@ -7,7 +7,7 @@ from apps.attachments.services import save_attachments
from apps.core.persian_text import normalize_persian_text
from apps.messaging.models import Message,MessageMention
from apps.notifications.models import NotificationJob
from apps.notifications.services import cancel_staff_reply_sms,enqueue,enqueue_unit_supervisor_ticket_sms,schedule_customer_reply_bale,schedule_staff_reply_sms
from apps.notifications.services import cancel_staff_reply_sms,enqueue,enqueue_unit_supervisor_ticket_sms,schedule_announcement,schedule_customer_reply_bale,schedule_staff_reply_sms
from .models import GroupDispatch,SLAConfig,Ticket,TicketAccessGrant,TicketEvent
from .policies import DispatchPolicy, TicketPolicy
@@ -37,6 +37,7 @@ def create_ticket(*,customer,category,subject,selected_expert=None,body="",files
ticket.full_clean(); ticket.save(); ticket.tags.set(tags); message=Message.objects.create(ticket=ticket,author=actor,body=body); save_attachments(message,files,actor); TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.CREATED,actor=actor,metadata={"staff_initiated":staff_initiated,"group_dispatch_id":group_dispatch.pk if group_dispatch else None,"unit_id":owning_unit.pk if owning_unit else None})
if staff_initiated:
enqueue(NotificationJob.Channel.SMS,"STAFF_TICKET_CREATED",ticket,f"ticket:{ticket.pk}:staff-created",customer=customer)
schedule_announcement(message)
else:
enqueue(NotificationJob.Channel.SMS,"CUSTOMER_TICKET_CREATED",ticket,f"ticket:{ticket.pk}:customer-created",customer=customer)
if owning_unit and owning_unit.bale_chat_id:
@@ -214,6 +215,7 @@ def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_user
ticket.last_staff_message_at=now; ticket.status=Ticket.Status.WAITING_CUSTOMER
if not ticket.first_response_at: ticket.first_response_at=now
schedule_staff_reply_sms(ticket,msg)
schedule_announcement(msg)
ticket.save(); return msg
@transaction.atomic
+3 -2
View File
@@ -23,7 +23,7 @@ from apps.accounts.models import TicketMagicLink, User, normalize_mobile
from apps.accounts.magic_access import get_ticket_magic_access
from apps.customers.models import ContactGroup, CustomerProfile
from apps.messaging.models import Message,MessageReadReceipt
from apps.notifications.models import NotificationJob, SMSTemplate
from apps.notifications.models import AnnouncementJob, NotificationJob, SMSTemplate
from apps.core.iran_locations import location_data
from apps.core.persian_text import normalize_persian_text
from apps.integrations.voip import place_voip_call
@@ -444,6 +444,7 @@ def staff_detail(request,public_id):
last=conversation[-1] if conversation else None; TicketReadState.objects.update_or_create(ticket=ticket,user=request.user,defaults={"last_read_at":timezone.now(),"last_read_message":last})
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=list(ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at"))
announcement_jobs=list(AnnouncementJob.objects.filter(message__ticket=ticket).select_related("message").order_by("-message__created_at", "-pk"))
can_retry_sms=TicketPolicy.can_reply(request.user,ticket)
for sms_job in sms_jobs:
sms_job.sms_retryable=can_retry_sms and _sms_job_is_retryable(sms_job)
@@ -451,7 +452,7 @@ def staff_detail(request,public_id):
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,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""})
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,"announcement_jobs":announcement_jobs,"viewer_user":request.user,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""})
@staff_required
@require_POST
def staff_sms_retry(request,public_id,job_id):
@@ -0,0 +1,21 @@
{% load jalali %}
{% if announcement_jobs %}
<section class="info-card sms-delivery-card" aria-label="وضعیت اطلاع‌رسانی‌های تلفنی">
<div class="sms-delivery-heading"><div><h3>وضعیت اطلاع‌رسانی‌های تلفنی</h3><small>پیگیری درخواست‌های تماس خودکار</small></div><span aria-hidden="true">☎</span></div>
<div class="sms-delivery-list">
{% for announcement_job in announcement_jobs %}
<details class="sms-delivery-item">
<summary><span class="sms-delivery-event">پیام کارشناس · {{ announcement_job.message.created_at|jalali_datetime }}</span><b>{{ announcement_job.status_label }}</b><span class="sms-delivery-info" aria-label="نمایش جزئیات اطلاع‌رسانی تلفنی" title="نمایش جزئیات">i</span></summary>
<ol class="sms-delivery-timeline">
<li class="is-complete"><span></span><div><b>ثبت پیام کارشناس</b><time datetime="{{ announcement_job.message.created_at|date:'c' }}">{{ announcement_job.message.created_at|jalali_datetime }}</time></div></li>
<li class="{% if announcement_job.status == 'SENT' or announcement_job.status == 'PROCESSING' %}is-complete{% endif %}"><span></span><div><b>{% if announcement_job.attempts and announcement_job.status == 'PENDING' %}تلاش بعدی برای تماس{% else %}موعد بررسی پیام خوانده‌نشده{% endif %}</b><time datetime="{{ announcement_job.next_attempt_at|date:'c' }}">{{ announcement_job.next_attempt_at|jalali_datetime }}</time></div></li>
<li class="{% if announcement_job.sent_at %}is-complete{% endif %}"><span></span><div><b>پذیرش درخواست توسط سرویس تماس</b>{% if announcement_job.sent_at %}<time datetime="{{ announcement_job.sent_at|date:'c' }}">{{ announcement_job.sent_at|jalali_datetime }}</time>{% else %}<small>{{ announcement_job.status_label }}</small>{% endif %}</div></li>
</ol>
{% if announcement_job.detail_label %}<p class="sms-delivery-description">{{ announcement_job.detail_label }}</p>{% endif %}
{% if announcement_job.attempts %}<small class="sms-delivery-tracking">تعداد تلاش: {{ announcement_job.attempts }}</small>{% endif %}
</details>
{% endfor %}
</div>
<p class="sms-delivery-description">پذیرش درخواست، به‌تنهایی برقرار شدن یا پاسخ‌دادن به تماس را تأیید نمی‌کند.</p>
</section>
{% endif %}
+1
View File
@@ -18,6 +18,7 @@
<button type="button" class="panel-close" data-close-panel>×</button>
<section class="info-card"><p class="panel-index">وضعیت تیکت</p><div class="info-list"><div class="info-row"><span>وضعیت</span><b>{{ ticket.get_status_display }}</b></div><div class="info-row"><span>اولویت</span><b>{{ ticket.get_priority_display }}</b></div><div class="info-row"><span>دسته</span><b>{{ ticket.category }}</b></div><div class="info-row"><span>مالک</span><b>{{ ticket.assigned_expert_name }}</b></div>{% if ticket.first_response_due_at %}<div class="info-row"><span>مهلت پاسخ</span><b>{{ ticket.first_response_due_at|jalali_datetime }}</b></div>{% endif %}</div></section>
{% if sms_jobs %}<section class="info-card sms-delivery-card"><div class="sms-delivery-heading"><div><h3>وضعیت پیامک‌های مشتری</h3><small>خلاصه ارسال و تحویل</small></div><span aria-hidden="true">ⓘ</span></div><div class="sms-delivery-list">{% for sms_job in sms_jobs %}<details class="sms-delivery-item"><summary><span class="sms-delivery-event">{% if sms_job.event_type == 'STAFF_TICKET_CREATED' %}ثبت تیکت توسط کارشناس{% elif sms_job.event_type == 'CUSTOMER_TICKET_CREATED' %}ثبت تیکت مشتری{% elif sms_job.event_type == 'STAFF_REPLY' %}اعلان پاسخ کارشناس{% else %}پیامک سیستمی{% endif %}</span><b>{% if sms_job.delivery_status %}{{ sms_job.get_delivery_status_display }}{% else %}{{ sms_job.get_status_display }}{% endif %}</b><span class="sms-delivery-info" aria-label="نمایش جزئیات زمان‌بندی پیامک" title="نمایش جزئیات">i</span></summary><ol class="sms-delivery-timeline"><li class="is-complete"><span></span><div><b>ثبت درخواست ارسال</b><time datetime="{{ sms_job.created_at|date:'c' }}">{{ sms_job.created_at|jalali_datetime }}</time></div></li><li class="{% if sms_job.sent_at %}is-complete{% endif %}"><span></span><div><b>پذیرش توسط درگاه پیامک</b>{% if sms_job.sent_at %}<time datetime="{{ sms_job.sent_at|date:'c' }}">{{ sms_job.sent_at|jalali_datetime }}</time>{% else %}<small>{% if sms_job.status == 'FAILED' %}ناموفق{% elif sms_job.status == 'CANCELLED' %}لغوشده پیش از ارسال{% else %}در انتظار ارسال{% endif %}</small>{% endif %}</div></li><li class="{% if sms_job.delivery_status == 'DELIVERED' %}is-complete{% endif %}"><span></span><div><b>تأیید رسیدن به گوشی</b>{% if sms_job.delivery_status == 'DELIVERED' and sms_job.delivery_checked_at %}<time datetime="{{ sms_job.delivery_checked_at|date:'c' }}">{{ sms_job.delivery_checked_at|jalali_datetime }}</time>{% elif sms_job.delivery_checked_at %}<small>آخرین استعلام: {{ sms_job.delivery_checked_at|jalali_datetime }}</small>{% else %}<small>{% if sms_job.sent_at %}در انتظار نتیجه اپراتور{% else %}هنوز ارسال نشده{% endif %}</small>{% endif %}</div></li></ol>{% if sms_job.delivery_description %}<p class="sms-delivery-description">{{ sms_job.delivery_description }}</p>{% endif %}{% if sms_job.last_error %}<p class="sms-delivery-error">علت دقیق: {{ sms_job.last_error }}</p>{% endif %}{% if sms_job.provider_message_id %}<small class="sms-delivery-tracking" dir="ltr">شناسه رهگیری: {{ sms_job.provider_message_id }}</small>{% endif %}{% if sms_job.sms_retryable %}<form method="post" action="{% url 'staff_tickets:sms_retry' ticket.public_id sms_job.pk %}" class="inline-retry-form">{% csrf_token %}<button type="submit" aria-label="تلاش مجدد برای ارسال این پیامک" title="تلاش مجدد"><span aria-hidden="true">↻</span> تلاش مجدد</button></form>{% endif %}</details>{% endfor %}</div></section>{% endif %}
{% include 'tickets/_announcement_status.html' %}
<section class="info-card ticket-operations"><h3>عملیات تیکت</h3>{% if perms.tickets.change_ticket %}<form method="post" action="{% url 'staff_tickets:status' ticket.public_id %}">{% csrf_token %}<label>وضعیت<select name="status">{% for value,label in statuses %}<option value="{{ value }}" {% if ticket.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button>ثبت وضعیت</button></form>{% endif %}{% if perms.tickets.change_ticket_priority %}<form method="post" action="{% url 'staff_tickets:priority' ticket.public_id %}">{% csrf_token %}<label>اولویت<select name="priority">{% for value,label in priorities %}<option value="{{ value }}" {% if ticket.priority == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button class="secondary">ثبت اولویت</button></form>{% endif %}{% if can_transfer %}<details><summary>انتقال تیکت</summary><form method="post" action="{% url 'staff_tickets:transfer' ticket.public_id %}">{% csrf_token %}{{ transfer_form.as_p }}<button>انتقال</button></form></details>{% endif %}{% if can_share %}<details><summary>اشتراک با کارشناس</summary><form method="post" action="{% url 'staff_tickets:share' ticket.public_id %}">{% csrf_token %}{{ share_form.as_p }}<button>ایجاد دسترسی</button></form>{% for grant in grants %}<div class="grant"><span>{{ grant.user.staff_profile.display_name }}</span><form method="post" action="{% url 'staff_tickets:revoke' ticket.public_id grant.user.public_id %}">{% csrf_token %}<button class="danger-button">لغو</button></form></div>{% endfor %}</details>{% endif %}</section>
{% if can_delete %}<section class="info-card danger-zone"><h3>حذف کامل تیکت</h3><p class="muted">تمام پیام‌ها، فایل‌ها و سوابق تیکت برای همیشه حذف می‌شوند.</p><form method="post" action="{% url 'staff_tickets:delete' ticket.public_id %}" onsubmit="return confirm('آیا از حذف کامل و غیرقابل‌بازگشت این تیکت مطمئن هستید؟')">{% csrf_token %}<button type="submit" class="danger-button">حذف کامل تیکت</button></form></section>{% endif %}
</aside>
+224
View File
@@ -0,0 +1,224 @@
from datetime import timedelta
from unittest.mock import Mock, patch
import pytest
import requests
from django.core.management import call_command
from django.utils import timezone
from apps.accounts.models import User
from apps.core.admin import VoipConfigurationAdmin
from apps.core.models import VoipConfiguration
from apps.messaging.models import MessageReadReceipt
from apps.notifications.models import AnnouncementJob
from apps.staff.models import OrganizationalUnit
from apps.tickets.models import TicketCategory
from apps.tickets.services import create_ticket, reply_ticket
@pytest.fixture
def announcement_catalog(db):
unit = OrganizationalUnit.objects.create(name="واحد تماس", slug="announcement-unit")
category = TicketCategory.objects.create(unit=unit, title="پیگیری", slug="announcement-category")
return category
@pytest.fixture
def announcement_people(db):
staff = User.objects.create_superuser(username="announcement-staff", password="safe-password", account_type="STAFF")
customer = User.objects.create_user(username="announcement-customer", mobile="09120000000", account_type="CUSTOMER")
return staff, customer
@pytest.fixture
def announcement_data(announcement_catalog, announcement_people):
configuration = VoipConfiguration.objects.create(
announcement_url="http://192.168.138.215:8000/v1/announcements",
announcement_id="custom/fereshte1_G711",
announcement_delay_minutes=90,
)
return announcement_catalog, *announcement_people, configuration
def due(job):
AnnouncementJob.objects.filter(pk=job.pk).update(next_attempt_at=timezone.now() - timedelta(seconds=1))
def test_staff_creation_and_each_public_reply_queue_independent_calls(announcement_data):
category, staff, customer, _ = announcement_data
ticket = create_ticket(customer=customer, created_by=staff, category=category, subject="پیگیری مشتری", body="پیام نخست")
first = ticket.ticket_messages.get()
first_job = AnnouncementJob.objects.get(message=first)
assert timedelta(minutes=89) <= first_job.next_attempt_at - first.created_at <= timedelta(minutes=91)
reply = reply_ticket(ticket_id=ticket.pk, user=staff, body="پاسخ تازه")
assert AnnouncementJob.objects.filter(message__ticket=ticket).count() == 2
assert AnnouncementJob.objects.get(message=reply).idempotency_key != first_job.idempotency_key
reply_ticket(ticket_id=ticket.pk, user=staff, body="یادداشت داخلی", internal=True)
reply_ticket(ticket_id=ticket.pk, user=customer, body="پاسخ مشتری")
assert AnnouncementJob.objects.filter(message__ticket=ticket).count() == 2
customer_ticket = create_ticket(customer=customer, category=category, subject="درخواست مشتری", body="پیام مشتری")
assert not AnnouncementJob.objects.filter(message__ticket=customer_ticket).exists()
@pytest.mark.parametrize("api_key", ["", "secret-key"])
def test_due_unread_message_calls_once_with_optional_api_key(announcement_data, api_key):
category, staff, customer, configuration = announcement_data
configuration.announcement_api_key = api_key
configuration.save()
ticket = create_ticket(customer=customer, created_by=staff, category=category, subject="پیگیری مشتری", body="پیام نخست")
job = AnnouncementJob.objects.get(message__ticket=ticket)
response = Mock(content=b'{}')
response.json.return_value = {}
with patch("apps.integrations.announcements.requests.post", return_value=response) as post:
call_command("process_notifications", once=True)
assert not post.called
due(job)
call_command("process_notifications", once=True)
call_command("process_notifications", once=True)
job.refresh_from_db()
assert job.status == AnnouncementJob.Status.SENT and job.attempts == 1
post.assert_called_once()
assert post.call_args.args == (configuration.announcement_url,)
assert post.call_args.kwargs["json"] == {"customer_number": "09120000000", "announcement_id": configuration.announcement_id}
assert post.call_args.kwargs["headers"] == ({"Idempotency-Key": job.idempotency_key, "X-API-Key": api_key} if api_key else {"Idempotency-Key": job.idempotency_key})
def test_customer_read_cancels_only_that_message(announcement_data):
category, staff, customer, _ = announcement_data
ticket = create_ticket(customer=customer, created_by=staff, category=category, subject="پیگیری مشتری", body="پیام نخست")
second = reply_ticket(ticket_id=ticket.pk, user=staff, body="پاسخ دوم")
first_job = AnnouncementJob.objects.get(message=ticket.ticket_messages.exclude(pk=second.pk).get())
second_job = AnnouncementJob.objects.get(message=second)
MessageReadReceipt.objects.create(message=first_job.message, user=customer)
due(first_job)
due(second_job)
with patch("apps.integrations.announcements.requests.post", return_value=Mock(content=b"", success=True)) as post:
call_command("process_notifications", once=True)
first_job.refresh_from_db()
second_job.refresh_from_db()
assert first_job.status == AnnouncementJob.Status.CANCELLED and first_job.last_error == "customer_read"
assert second_job.status == AnnouncementJob.Status.SENT
post.assert_called_once()
def test_provider_failure_retries_with_same_idempotency_key(announcement_data):
category, staff, customer, _ = announcement_data
ticket = create_ticket(customer=customer, created_by=staff, category=category, subject="پیگیری مشتری", body="پیام نخست")
job = AnnouncementJob.objects.get(message__ticket=ticket)
keys = []
def fail(*args, **kwargs):
keys.append(kwargs["headers"]["Idempotency-Key"])
raise requests.Timeout("timeout")
with patch("apps.integrations.announcements.requests.post", side_effect=fail):
for attempt in range(3):
due(job)
call_command("process_notifications", once=True)
job.refresh_from_db()
assert job.attempts == attempt + 1
assert job.status == AnnouncementJob.Status.FAILED
assert keys == [job.idempotency_key] * 3
assert job.last_error == "service_timeout"
@pytest.mark.parametrize(
("provider_result", "expected_error"),
[
(requests.ConnectionError("secret-in-provider-error"), "service_unreachable"),
(requests.HTTPError("503 Service Unavailable"), "service_http_error"),
(Mock(content=b"not-json", json=Mock(side_effect=ValueError("bad JSON"))), "invalid_service_response"),
(Mock(content=b"[]", json=Mock(return_value=[])), "invalid_service_response"),
(Mock(content=b'{"success": false}', json=Mock(return_value={"success": False})), "service_rejected"),
],
)
def test_provider_errors_are_sanitized_and_do_not_stop_worker(announcement_data, provider_result, expected_error):
category, staff, customer, _ = announcement_data
ticket = create_ticket(customer=customer, created_by=staff, category=category, subject="پیگیری مشتری", body="پیام نخست")
second = reply_ticket(ticket_id=ticket.pk, user=staff, body="پیام دوم")
first_job = AnnouncementJob.objects.get(message=ticket.ticket_messages.exclude(pk=second.pk).get())
second_job = AnnouncementJob.objects.get(message=second)
due(first_job)
due(second_job)
success = Mock(content=b"")
with patch("apps.integrations.announcements.requests.post", side_effect=[provider_result, success]) as post:
call_command("process_notifications", once=True)
first_job.refresh_from_db()
second_job.refresh_from_db()
assert post.call_count == 2
assert first_job.status == AnnouncementJob.Status.PENDING
assert first_job.attempts == 1 and first_job.last_error == expected_error
assert second_job.status == AnnouncementJob.Status.SENT
assert first_job.next_attempt_at > timezone.now()
def test_customer_reply_prevents_unnecessary_call(announcement_data):
category, staff, customer, _ = announcement_data
ticket = create_ticket(customer=customer, created_by=staff, category=category, subject="پیگیری مشتری", body="پیام نخست")
job = AnnouncementJob.objects.get(message__ticket=ticket)
reply_ticket(ticket_id=ticket.pk, user=customer, body="پاسخ مشتری")
due(job)
with patch("apps.integrations.announcements.requests.post") as post:
call_command("process_notifications", once=True)
job.refresh_from_db()
assert job.status == AnnouncementJob.Status.CANCELLED and job.last_error == "customer_replied"
post.assert_not_called()
def test_missing_configuration_cancels_without_call(announcement_data):
category, staff, customer, configuration = announcement_data
ticket = create_ticket(customer=customer, created_by=staff, category=category, subject="پیگیری مشتری", body="پیام نخست")
job = AnnouncementJob.objects.get(message__ticket=ticket)
configuration.announcement_url = ""
configuration.save()
due(job)
with patch("apps.integrations.announcements.requests.post") as post:
call_command("process_notifications", once=True)
job.refresh_from_db()
assert job.status == AnnouncementJob.Status.CANCELLED and job.last_error == "announcement_not_configured"
post.assert_not_called()
def test_database_delay_and_admin_api_key_controls(announcement_data):
category, staff, customer, configuration = announcement_data
configuration.announcement_delay_minutes = 15
configuration.announcement_api_key = "stored-secret"
configuration.save()
ticket = create_ticket(customer=customer, created_by=staff, category=category, subject="پیگیری مشتری", body="پیام نخست")
job = AnnouncementJob.objects.get(message__ticket=ticket)
assert timedelta(minutes=14) <= job.next_attempt_at - job.message.created_at <= timedelta(minutes=16)
form_type = VoipConfigurationAdmin.ConfigurationForm
data = {
"announcement_url": configuration.announcement_url,
"announcement_id": configuration.announcement_id,
"announcement_delay_minutes": 15,
"voip_call_url": "",
}
unchanged = form_type(data=data, instance=configuration)
assert "stored-secret" not in unchanged.as_p()
assert unchanged.is_valid(), unchanged.errors
assert unchanged.save().announcement_api_key == "stored-secret"
cleared = form_type(data={**data, "clear_announcement_api_key": "on"}, instance=configuration)
assert cleared.is_valid(), cleared.errors
assert cleared.save().announcement_api_key == ""
def test_staff_ticket_shows_call_history_without_claiming_connection(client, announcement_data):
category, staff, customer, configuration = announcement_data
configuration.announcement_api_key = "private-provider-key"
configuration.save()
ticket = create_ticket(customer=customer, created_by=staff, category=category, subject="پیگیری مشتری", body="پیام نخست")
reply = reply_ticket(ticket_id=ticket.pk, user=staff, body="پاسخ تازه")
first_job = AnnouncementJob.objects.get(message=ticket.ticket_messages.exclude(pk=reply.pk).get())
second_job = AnnouncementJob.objects.get(message=reply)
AnnouncementJob.objects.filter(pk=first_job.pk).update(status=AnnouncementJob.Status.FAILED, attempts=3, last_error="service_timeout")
AnnouncementJob.objects.filter(pk=second_job.pk).update(status=AnnouncementJob.Status.SENT, attempts=1, sent_at=timezone.now())
client.force_login(staff)
content = client.get(f"/staff/tickets/{ticket.public_id}/").content.decode()
assert "وضعیت پیامک‌های مشتری" in content
assert "وضعیت اطلاع‌رسانی‌های تلفنی" in content
assert content.count('aria-label="نمایش جزئیات اطلاع‌رسانی تلفنی"') == 2
assert "سرویس تماس در مهلت مقرر پاسخ نداد." in content
assert "درخواست تماس پذیرفته شد" in content
assert "پذیرش درخواست، به‌تنهایی برقرار شدن" in content
assert configuration.announcement_url not in content
assert configuration.announcement_api_key not in content
+32
View File
@@ -132,3 +132,35 @@ def test_voip_error_is_minimal(client, voip_data):
content_type="application/json",
)
assert response.status_code == 403
@pytest.mark.django_db
@pytest.mark.parametrize("provider_error", [requests.Timeout("timed out"), requests.HTTPError("503 unavailable")])
def test_voip_provider_failure_does_not_raise_into_staff_page(client, voip_data, provider_error):
agent, customer, _ = voip_data
client.force_login(agent)
with patch("apps.integrations.voip.requests.post", side_effect=provider_error) as post:
response = client.post(
reverse("staff_tickets:contact_call", args=[customer.public_id]),
data=json.dumps({"method": "mobile"}),
content_type="application/json",
)
assert response.status_code == 502
assert response.json() == {"success": False, "message": "برقراری تماس ناموفق بود."}
assert post.call_args.kwargs["timeout"] > 0
@pytest.mark.django_db
def test_voip_invalid_provider_response_is_reported_as_failure(client, voip_data):
agent, customer, _ = voip_data
client.force_login(agent)
provider_response = Mock(content=b"[]")
provider_response.json.return_value = []
with patch("apps.integrations.voip.requests.post", return_value=provider_response):
response = client.post(
reverse("staff_tickets:contact_call", args=[customer.public_id]),
data=json.dumps({"method": "mobile"}),
content_type="application/json",
)
assert response.status_code == 502
assert response.json() == {"success": False, "message": "برقراری تماس ناموفق بود."}