diff --git a/PROJECT_TREE.txt b/PROJECT_TREE.txt index 9e5525d..e0cf9e1 100644 --- a/PROJECT_TREE.txt +++ b/PROJECT_TREE.txt @@ -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 diff --git a/apps/core/admin.py b/apps/core/admin.py index 179d487..624ddc9 100644 --- a/apps/core/admin.py +++ b/apps/core/admin.py @@ -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() diff --git a/apps/core/migrations/0008_voipconfiguration_announcement_api_key_and_more.py b/apps/core/migrations/0008_voipconfiguration_announcement_api_key_and_more.py new file mode 100644 index 0000000..f19c265 --- /dev/null +++ b/apps/core/migrations/0008_voipconfiguration_announcement_api_key_and_more.py @@ -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'), + ), + ] diff --git a/apps/core/models.py b/apps/core/models.py index 8fc5a78..d8f2a2c 100644 --- a/apps/core/models.py +++ b/apps/core/models.py @@ -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: diff --git a/apps/integrations/announcements.py b/apps/integrations/announcements.py new file mode 100644 index 0000000..53b7ac1 --- /dev/null +++ b/apps/integrations/announcements.py @@ -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") diff --git a/apps/integrations/voip.py b/apps/integrations/voip.py index fc6d24e..2a18577 100644 --- a/apps/integrations/voip.py +++ b/apps/integrations/voip.py @@ -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): diff --git a/apps/notifications/admin.py b/apps/notifications/admin.py index 71d9583..dc1ccd6 100644 --- a/apps/notifications/admin.py +++ b/apps/notifications/admin.py @@ -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") diff --git a/apps/notifications/announcement_worker.py b/apps/notifications/announcement_worker.py new file mode 100644 index 0000000..7da9ad4 --- /dev/null +++ b/apps/notifications/announcement_worker.py @@ -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"]) diff --git a/apps/notifications/management/commands/process_notifications.py b/apps/notifications/management/commands/process_notifications.py index 84bd0ce..4764b57 100644 --- a/apps/notifications/management/commands/process_notifications.py +++ b/apps/notifications/management/commands/process_notifications.py @@ -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) diff --git a/apps/notifications/migrations/0015_announcementjob.py b/apps/notifications/migrations/0015_announcementjob.py new file mode 100644 index 0000000..d6c5fff --- /dev/null +++ b/apps/notifications/migrations/0015_announcementjob.py @@ -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')], + }, + ), + ] diff --git a/apps/notifications/models.py b/apps/notifications/models.py index b6e6089..40c21df 100644 --- a/apps/notifications/models.py +++ b/apps/notifications/models.py @@ -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 "") diff --git a/apps/notifications/services.py b/apps/notifications/services.py index bd0d99f..78e8938 100644 --- a/apps/notifications/services.py +++ b/apps/notifications/services.py @@ -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 diff --git a/apps/tickets/services.py b/apps/tickets/services.py index 2edba9a..8dfd4c7 100644 --- a/apps/tickets/services.py +++ b/apps/tickets/services.py @@ -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 diff --git a/apps/tickets/views.py b/apps/tickets/views.py index 6281464..614a9e7 100644 --- a/apps/tickets/views.py +++ b/apps/tickets/views.py @@ -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): diff --git a/templates/tickets/_announcement_status.html b/templates/tickets/_announcement_status.html new file mode 100644 index 0000000..783f8eb --- /dev/null +++ b/templates/tickets/_announcement_status.html @@ -0,0 +1,21 @@ +{% load jalali %} +{% if announcement_jobs %} +
+

وضعیت اطلاع‌رسانی‌های تلفنی

پیگیری درخواست‌های تماس خودکار
+
+ {% for announcement_job in announcement_jobs %} +
+ پیام کارشناس · {{ announcement_job.message.created_at|jalali_datetime }}{{ announcement_job.status_label }}i +
    +
  1. ثبت پیام کارشناس
  2. +
  3. {% if announcement_job.attempts and announcement_job.status == 'PENDING' %}تلاش بعدی برای تماس{% else %}موعد بررسی پیام خوانده‌نشده{% endif %}
  4. +
  5. پذیرش درخواست توسط سرویس تماس{% if announcement_job.sent_at %}{% else %}{{ announcement_job.status_label }}{% endif %}
  6. +
+ {% if announcement_job.detail_label %}

{{ announcement_job.detail_label }}

{% endif %} + {% if announcement_job.attempts %}تعداد تلاش: {{ announcement_job.attempts }}{% endif %} +
+ {% endfor %} +
+

پذیرش درخواست، به‌تنهایی برقرار شدن یا پاسخ‌دادن به تماس را تأیید نمی‌کند.

+
+{% endif %} diff --git a/templates/tickets/staff_detail.html b/templates/tickets/staff_detail.html index 05cfd7e..d55d903 100644 --- a/templates/tickets/staff_detail.html +++ b/templates/tickets/staff_detail.html @@ -18,6 +18,7 @@

وضعیت تیکت

وضعیت{{ ticket.get_status_display }}
اولویت{{ ticket.get_priority_display }}
دسته{{ ticket.category }}
مالک{{ ticket.assigned_expert_name }}
{% if ticket.first_response_due_at %}
مهلت پاسخ{{ ticket.first_response_due_at|jalali_datetime }}
{% endif %}
{% if sms_jobs %}

وضعیت پیامک‌های مشتری

خلاصه ارسال و تحویل
{% for sms_job in sms_jobs %}
{% 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 %}{% if sms_job.delivery_status %}{{ sms_job.get_delivery_status_display }}{% else %}{{ sms_job.get_status_display }}{% endif %}i
  1. ثبت درخواست ارسال
  2. پذیرش توسط درگاه پیامک{% if sms_job.sent_at %}{% else %}{% if sms_job.status == 'FAILED' %}ناموفق{% elif sms_job.status == 'CANCELLED' %}لغوشده پیش از ارسال{% else %}در انتظار ارسال{% endif %}{% endif %}
  3. تأیید رسیدن به گوشی{% if sms_job.delivery_status == 'DELIVERED' and sms_job.delivery_checked_at %}{% elif sms_job.delivery_checked_at %}آخرین استعلام: {{ sms_job.delivery_checked_at|jalali_datetime }}{% else %}{% if sms_job.sent_at %}در انتظار نتیجه اپراتور{% else %}هنوز ارسال نشده{% endif %}{% endif %}
{% if sms_job.delivery_description %}

{{ sms_job.delivery_description }}

{% endif %}{% if sms_job.last_error %}

علت دقیق: {{ sms_job.last_error }}

{% endif %}{% if sms_job.provider_message_id %}شناسه رهگیری: {{ sms_job.provider_message_id }}{% endif %}{% if sms_job.sms_retryable %}
{% csrf_token %}
{% endif %}
{% endfor %}
{% endif %} + {% include 'tickets/_announcement_status.html' %}

عملیات تیکت

{% if perms.tickets.change_ticket %}
{% csrf_token %}
{% endif %}{% if perms.tickets.change_ticket_priority %}
{% csrf_token %}
{% endif %}{% if can_transfer %}
انتقال تیکت
{% csrf_token %}{{ transfer_form.as_p }}
{% endif %}{% if can_share %}
اشتراک با کارشناس
{% csrf_token %}{{ share_form.as_p }}
{% for grant in grants %}
{{ grant.user.staff_profile.display_name }}
{% csrf_token %}
{% endfor %}
{% endif %}
{% if can_delete %}

حذف کامل تیکت

تمام پیام‌ها، فایل‌ها و سوابق تیکت برای همیشه حذف می‌شوند.

{% csrf_token %}
{% endif %} diff --git a/tests/test_announcements.py b/tests/test_announcements.py new file mode 100644 index 0000000..ba9e545 --- /dev/null +++ b/tests/test_announcements.py @@ -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 diff --git a/tests/test_voip_calls.py b/tests/test_voip_calls.py index 5e15500..9630e2a 100644 --- a/tests/test_voip_calls.py +++ b/tests/test_voip_calls.py @@ -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": "برقراری تماس ناموفق بود."}