voip announcement
This commit is contained in:
1 parent
38b1cf06df
commit
c7993aae44
18 files changed
+679
-151
No files matched your search
+112
-144
@@ -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
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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 "")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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": "برقراری تماس ناموفق بود."}
|
||||
Reference in new issue
Block a user