share ticket and ticket message
This commit is contained in:
1 parent
39e64854b8
commit
79fe1cddbb
35 files changed
+1742
-95
No files matched your search
+112
-48
@@ -1,13 +1,15 @@
|
||||
Project Root: customerTicket
|
||||
Generated At: 2026-09-28T15:29:03+00:00
|
||||
Python Files: 197
|
||||
Django Files: 37
|
||||
Generated At: 2026-10-04T06:10:18+00:00
|
||||
Python Files: 214
|
||||
Django Files: 42
|
||||
JavaScript/TypeScript Files: 21
|
||||
HTML Files: 31
|
||||
Total Files: 782
|
||||
Total Directories: 97
|
||||
HTML Files: 33
|
||||
Total Files: 841
|
||||
Total Directories: 102
|
||||
|
||||
customerTicket
|
||||
├── .agents
|
||||
├── .codex
|
||||
├── apps
|
||||
│ ├── accounts
|
||||
│ │ ├── migrations
|
||||
@@ -58,6 +60,7 @@ customerTicket
|
||||
│ │ │ ├── 0005_sitebranding_company_logo_and_more.py
|
||||
│ │ │ ├── 0006_productionseedstate.py
|
||||
│ │ │ ├── 0007_voipconfiguration.py
|
||||
│ │ │ ├── 0008_voipconfiguration_announcement_api_key_and_more.py
|
||||
│ │ │ └── __init__.py
|
||||
│ │ ├── templatetags
|
||||
│ │ │ ├── __init__.py
|
||||
@@ -77,6 +80,8 @@ customerTicket
|
||||
│ │ │ ├── 0002_customerprofile_city_customerprofile_province.py
|
||||
│ │ │ ├── 0003_contactgroup_customerprofile_organization_name_and_more.py
|
||||
│ │ │ ├── 0004_contactgroup_unit_customerprofile_groups_and_more.py
|
||||
│ │ │ ├── 0005_contacteditlog.py
|
||||
│ │ │ ├── 0006_backfill_customer_profiles.py
|
||||
│ │ │ └── __init__.py
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── admin.py
|
||||
@@ -96,6 +101,7 @@ customerTicket
|
||||
│ │ │ ├── providers.py
|
||||
│ │ │ └── sms0098.py
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── announcements.py
|
||||
│ │ └── voip.py
|
||||
│ ├── messaging
|
||||
│ │ ├── migrations
|
||||
@@ -131,12 +137,27 @@ customerTicket
|
||||
│ │ │ ├── 0012_supervisor_sms_template.py
|
||||
│ │ │ ├── 0013_notificationjob_definitive_delivery_retry.py
|
||||
│ │ │ ├── 0014_backfill_safe_0098_delivery_retries.py
|
||||
│ │ │ ├── 0015_announcementjob.py
|
||||
│ │ │ ├── 0016_announcementjob_requested_by_announcementjob_source_and_more.py
|
||||
│ │ │ └── __init__.py
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── admin.py
|
||||
│ │ ├── announcement_worker.py
|
||||
│ │ ├── apps.py
|
||||
│ │ ├── models.py
|
||||
│ │ └── services.py
|
||||
│ ├── phone_contacts_api
|
||||
│ │ ├── migrations
|
||||
│ │ │ ├── 0001_initial.py
|
||||
│ │ │ └── __init__.py
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── admin.py
|
||||
│ │ ├── apps.py
|
||||
│ │ ├── models.py
|
||||
│ │ └── services.py
|
||||
│ │ ├── README.md
|
||||
│ │ ├── test_api.py
|
||||
│ │ ├── urls.py
|
||||
│ │ └── views.py
|
||||
│ ├── staff
|
||||
│ │ ├── migrations
|
||||
│ │ │ ├── 0001_initial.py
|
||||
@@ -213,6 +234,7 @@ customerTicket
|
||||
│ ├── DEPLOYMENT_COOLIFY.md
|
||||
│ ├── ORGANIZATIONAL_TICKETING.md
|
||||
│ ├── ORGANIZATIONAL_TICKETING_GAP_ANALYSIS.md
|
||||
│ ├── PHONE_CONTACTS_API.md
|
||||
│ ├── PRODUCTION_SEED.md
|
||||
│ ├── QA_PERMISSION_MATRIX.md
|
||||
│ └── THEMING.md
|
||||
@@ -263,18 +285,23 @@ customerTicket
|
||||
│ │ ├── graph.json
|
||||
│ │ ├── GRAPH_REPORT.md
|
||||
│ │ └── manifest.json
|
||||
│ ├── 2026-09-29
|
||||
│ │ ├── .graphify_analysis.json
|
||||
│ │ ├── .graphify_labels.json
|
||||
│ │ ├── cost.json
|
||||
│ │ ├── graph.json
|
||||
│ │ ├── GRAPH_REPORT.md
|
||||
│ │ └── manifest.json
|
||||
│ ├── cache
|
||||
│ │ ├── ast
|
||||
│ │ │ └── v0.9.62-s3
|
||||
│ │ │ └── v0.9.68-s4
|
||||
│ │ │ ├── 00aea1c179c20548df2499948796faba373334997bfeddad9e335db138753cc5.json
|
||||
│ │ │ ├── 00afff188288984e240329931ac9e4273f5ab89be582e021e9edbdbead42ebfe.json
|
||||
│ │ │ ├── 0138bfeaa54b14916df0028a970de356913b83e8da2cd5cfc610361887baa9c2.json
|
||||
│ │ │ ├── 01d84090a3b22716edb63a5cf9c0a56cc94c0f5207cdbd5005843f858bbb292f.json
|
||||
│ │ │ ├── 01eb2378ec1b50eb9adf2efeee2c1b18bec9fdc7df91db2655076185e3e32721.json
|
||||
│ │ │ ├── 02a3519f5b471b7083c161d9d9c73ab5b0ee78cf7e2cc4d5be7c2542435aa58e.json
|
||||
│ │ │ ├── 03040dc947a4bba22aafbc6262c9db2c8549826ae5aa1ac4cc1821806f93561f.json
|
||||
│ │ │ ├── 039590a02c184d21a28caec4178b981a824ec4356a225d1ce546cc212d2f550f.json
|
||||
│ │ │ ├── 053337dddf6eb41ca0de06296d382047253aa08d9b91d61fe86923462c4e2dc8.json
|
||||
│ │ │ ├── 0571f3a7d38f1c019d2c0f1102e0444f8bd8fc429dbe5f7ba51774d3eda0fedc.json
|
||||
│ │ │ ├── 058b4e36240cd25d158647c300472baad5e7101ebde0319883abb0edc8c9c74d.json
|
||||
│ │ │ ├── 07e174cf218887aa0f8747199874faf6bc8af9347e43ff9b5a2073c089637f6f.json
|
||||
│ │ │ ├── 08ff4daae812563107028890c2ae7257bb5f9a376a978244c814b7a4c1d10a98.json
|
||||
@@ -286,57 +313,54 @@ customerTicket
|
||||
│ │ │ ├── 0d5f84af41009fdecae2a46d4ad5fab38a79c1a5dde262e9c7f8d35707a35092.json
|
||||
│ │ │ ├── 0ef31d053655ee64599a0246f26c8eb61f27654ae1ffbd41b87500438fc9b06a.json
|
||||
│ │ │ ├── 11d2b71feb1480622fb18ee3795cf070421b146e4273b5016fad81e7cc8e2c7e.json
|
||||
│ │ │ ├── 11ff4da3afbce9cae19102862deefdf725fa363130fad753fa189fa4f6c8c4de.json
|
||||
│ │ │ ├── 12db2ab20d1e0be8080b5edd6cc40cacfdff350dd585126a0e0ce144073ad12f.json
|
||||
│ │ │ ├── 12f95828a2c1818402459627e36481199020e31faa280d214f81700fb9ad520d.json
|
||||
│ │ │ ├── 1436cce90bf2f00f050c9eb67cddf5672ed6a3db185ef663c257ee7936b175ae.json
|
||||
│ │ │ ├── 143ae363276b4a1db64805529e618db71c204aa56bc8b54761693a9a2060336c.json
|
||||
│ │ │ ├── 15539048155c760644fc70dd1fbff3855ceedf11bc6ef7b270d1c7280cb4df48.json
|
||||
│ │ │ ├── 1619d65041f501eb2172113b207c505582df29a0e5bfc3c681d8ec075f0bcb56.json
|
||||
│ │ │ ├── 17ab5521ea572ea85443db030bd5085f1443c20b20da9cbf808f21ad695eb319.json
|
||||
│ │ │ ├── 18ba25713068baf53992eb4ea94f95212ecaeb931fdcf6769970fc2fd19b2f93.json
|
||||
│ │ │ ├── 19ed1276256eb4d44b9cea2dddac4de3801d67a08cf381a55466e82ee6f2bbc6.json
|
||||
│ │ │ ├── 1bfc839bf502b97d0b70b0bacc9781cae7511d27d078ed6ac6978fd438833dd1.json
|
||||
│ │ │ ├── 1d6b17be6b2c0e7d25b4622b4a8c09ab705cecf9db09e2f86c35a4a564485791.json
|
||||
│ │ │ ├── 1db075565e2fe1027675b7ee03d055150e6b741f3e9865bcd46d9a6113c538a8.json
|
||||
│ │ │ ├── 1e2dc326e510df1ed3d23fabdf289d6a9448275816a15bc94becafa5563de2c7.json
|
||||
│ │ │ ├── 1f440edc6feb5bf8c2a462b1b4a0b44bd22b4a763286fb2b340c797c282b5bcd.json
|
||||
│ │ │ ├── 1f6cfd55d13bc48aa6c68558090ceeaf24a54c7bec690d51d95243c904daf0a6.json
|
||||
│ │ │ ├── 1fabd63a56e826ff351d3edca222f5f958004c01f0bd16aed30080e8b13da33f.json
|
||||
│ │ │ ├── 21f38c2f6a379ab402b4d3bd1b0809129c8554ebb01202a160c42a1e9b49b717.json
|
||||
│ │ │ ├── 22534d744aadf9c9b61b908eccea0e9b3eaa30bc381bace54b3221ee0861a0ec.json
|
||||
│ │ │ ├── 22df765bbeaca09caaf2fbcddcdd3ef1d4c0800f0c545f963bd91e85c33fe7ec.json
|
||||
│ │ │ ├── 22e2f3678f112281b64af6f72a60ae03b15b47bbe45a17282e95d5fcf8b7c3c5.json
|
||||
│ │ │ ├── 2396802d9ca82a055f431939b4cd95eea6c29556956075d1d1ff35e4bca3b7c6.json
|
||||
│ │ │ ├── 244419b0eb45bd51409a2284de828aa5de674be78dd572f04c911b105a6c9ee7.json
|
||||
│ │ │ ├── 2609e627752bd20df923f6c69e090173a368757b3dbd2ce418e3cc9f67c697ba.json
|
||||
│ │ │ ├── 26a41d5fda805c545065f2da12215d350587a61aff951937f54d64bb713f0fab.json
|
||||
│ │ │ ├── 26c6832fa65fe38fa62f98bbfd8d544cf6a470cce547579e1d650390e8b9fded.json
|
||||
│ │ │ ├── 26e3b3874f2dc2c3709945e2f6cf050e5c525933de9f09a8c91b244e3bfa8ca7.json
|
||||
│ │ │ ├── 279274c19bf4156f8f924fa837acf99d1ed42d6d57ec7cd1af194fe3b12c07da.json
|
||||
│ │ │ ├── 28cefeff27c9cb5760aead796c37afdb1311bff0a5ff759136f0473ffa0152ff.json
|
||||
│ │ │ ├── 298888276da87757f59b0e0280ea46039c0ee05945961c3e00b287abcbc6a2f0.json
|
||||
│ │ │ ├── 2b7cc5d0dec7234082c1797ab637b916ac7df39277f8fbf109ae90190fc1e900.json
|
||||
│ │ │ ├── 2c8d91fbfca457646cf60edb03fd3a89a2494a6b6612087854edeac5a119739a.json
|
||||
│ │ │ ├── 2df1edd1fd32c467f237babe4a94f2ed73b67270ede6fa8298b32eab7c3eb80b.json
|
||||
│ │ │ ├── 2e219918c1c8880ae7c2e8bdd1c670a74327780e63d81ae4561475832eea2a37.json
|
||||
│ │ │ ├── 2fd5953e7926f320bdeac2d559967758aef4632bf7dfa26a5ee50ad386fc9b67.json
|
||||
│ │ │ ├── 3142f9d4517f137483c716dd61985811bbf006b85506791e9a2ee37d58b2f53e.json
|
||||
│ │ │ ├── 32a4505d16f4fd1efc5bc0c2ea7c8900419856b21a41b06d988cc424258ea1c9.json
|
||||
│ │ │ ├── 32c40d17e90ccf2511e124b4fcc5b3674a7200dd5215ee8742d319f8090b04ba.json
|
||||
│ │ │ ├── 336d5e1042b05e1f144a030b9f84836283398076b9f90d8cd4dc37622ab00caf.json
|
||||
│ │ │ ├── 33fc3acd587f6970b19f708df61db16a5256a1914954829e2c622dacb8026331.json
|
||||
│ │ │ ├── 345cea9e55c5f1954119121edf5ec28f1514b11d83de6c5bddf80d014bd98c94.json
|
||||
│ │ │ ├── 34b48eae711a65d522f81081262d437469f08182368d46643dc80eadd96bbdb5.json
|
||||
│ │ │ ├── 34cbb71eca44ad5849b04248e513e7cc33c3d3f1a1f66cc27f732666e585bf19.json
|
||||
│ │ │ ├── 36000938c9c3b13fbd92622f905f8c4f4bfbfdabe66e3357e88c162a4fb198ce.json
|
||||
│ │ │ ├── 360e95dd7f9428a51961a1f4ae7968a6e3101b8df00027432c6c743f64ccf9f8.json
|
||||
│ │ │ ├── 365fb94a688dcb602d723765e4b798e0631f6385cdd5fd410b083136521c1a3c.json
|
||||
│ │ │ ├── 367d8bf55d8d85048571009c40b04ef43211cc9c630af6222fc5db65fb382cc8.json
|
||||
│ │ │ ├── 36c68425778aff8c4320f570cc4e4ec8b3c40094abdeffc556f5488085c587e5.json
|
||||
│ │ │ ├── 393b2ca571bf671f26f243678a1be73266fe7435c43d7c837f1b53d4dc54cc66.json
|
||||
│ │ │ ├── 3948717314f47afa835e760073df4764ac427f4858f5ec0ee6a2f298ac393d49.json
|
||||
│ │ │ ├── 3b878a979cc2ea2ac926f8a72f24dbef940bbaafdfbdc76679f4054a3ebf791a.json
|
||||
│ │ │ ├── 3b9252049dbf8ef67e64a756bb41325a6cf50190004d4442119d438620bacc72.json
|
||||
│ │ │ ├── 3c4a42b0b79b95acf81967479efdc4cd0a369e3b53b771fe87478d6327ddf0eb.json
|
||||
│ │ │ ├── 3cec8055583c7d94e03ced535376a8bd9d3d335cd6c7e328f38dc2a9e6b1e397.json
|
||||
│ │ │ ├── 3f0cad4a4a6eb440a2b133bf72f2e72d6e7ef3668f4c8dbc2c759dbd1a41eb6f.json
|
||||
│ │ │ ├── 401b3df2aa1a5c618d917ae2422e552c59dfbc5220b312fe7977d751168f6987.json
|
||||
│ │ │ ├── 408f6eb568e45e950fdc170776dabf9087829df0db630e16643fba8995318d26.json
|
||||
│ │ │ ├── 4177b5c9bcdd159b716f3e956852b337fc54e7e597b5631d0f0e9a4039011901.json
|
||||
│ │ │ ├── 42a296078b0eb6bea6dd15ebc5799c252901ba9e001290df5d3a9dfae0e4e4d5.json
|
||||
@@ -346,25 +370,25 @@ customerTicket
|
||||
│ │ │ ├── 478a054f8292cd197743a61b5e3e04487f67f8db76ae658a28b04e4aef670ab2.json
|
||||
│ │ │ ├── 479992e96c0c5fac0a00bd4d9e0822dd52273158f5364c26884649b017e5b874.json
|
||||
│ │ │ ├── 47fb67d01a9006c7f835ad87b75b95bb1365c27b342144f081d858e07fd309b3.json
|
||||
│ │ │ ├── 485d542ea0237235e2d09999130ffa61c11da13873b62ca3e9228896f29da35f.json
|
||||
│ │ │ ├── 48f2dd332e3e37756440716095b3b0ba781ebd5abae50130c6b8bfee5a0468cd.json
|
||||
│ │ │ ├── 4993b53d428cbbb1e334ea396aae6397586a1295a35f39da038cfe72f0b89fd4.json
|
||||
│ │ │ ├── 4ae084687e4372e69f349e8bbc5cf603768a032d3d9ee4d2504e6709ed8ee440.json
|
||||
│ │ │ ├── 4ae419f04a3a3b1221faef43b8526910f1958336162fbce432bab0e596ef2aa1.json
|
||||
│ │ │ ├── 4bf46c6c0f27e14312f7c1311bca1bf5e1bfee29ea5cb35e6f94142141985d23.json
|
||||
│ │ │ ├── 4cf64fe0b79aec18c49355f7d82ab04ae66067042a6c364e89842109427ffb5d.json
|
||||
│ │ │ ├── 4e71fd2d8486dc3333b9dcdea79eb80552dc8db2aa07ef721173618dd4dfb8e2.json
|
||||
│ │ │ ├── 4f4799a971a5d6701087428e8c709396c0d947f7ce5bcf7a048cd542d5551c57.json
|
||||
│ │ │ ├── 4fd59b7f74524c4eb0b9293df963b8aa007039c53e88fef4ca2bd9b61197a114.json
|
||||
│ │ │ ├── 502d00f961089b9a706153667a4767dbf3463eabb1d9c636ab92197cfebaaf20.json
|
||||
│ │ │ ├── 508a0054ec5c5b3353e94566a0f728d533ec47721e20e6037aa55e2d665a5143.json
|
||||
│ │ │ ├── 52cd74ad5803b940489587043429b741434ae931fa4d244f5b0dc53582cee050.json
|
||||
│ │ │ ├── 5323b09e3c325ba8c161f5e2cab233b25cfa1b96ff042c6ae79ae1b029e2b1bc.json
|
||||
│ │ │ ├── 57bc96866536f0d67300ed29e56bd0f45fa9119c787fac805f228adcab18dc6f.json
|
||||
│ │ │ ├── 582c91a8ddabbea5521fc65d724e4fb3672c070f9213fae7b58595fcf62f3c85.json
|
||||
│ │ │ ├── 584447cf308657a696ef8465181b12d2c06f9dc5ff69752bea66bd0b9631feec.json
|
||||
│ │ │ ├── 596a13e9b322f8c3332a3ae214ca7bf368ba6158ff3adcc7db4582f102ea51dc.json
|
||||
│ │ │ ├── 5a34ded8b2dd4c10c4251d287580918b88690c7058dd2299d2d670170104241d.json
|
||||
│ │ │ ├── 5cb59d9c1e81aa29d98ffc905bee257072403820c1612faa5513af553ce9af07.json
|
||||
│ │ │ ├── 5d43f2c9afc3451745d2a1b5f1ed17bc138c9b19caff61beadbc40a4e970b525.json
|
||||
│ │ │ ├── 5f945cb173f2e6eea3ee99955caddb779d2910ca41a82c20e12965b94528a15c.json
|
||||
│ │ │ ├── 5fcd0fdc47a301cbd07130dfa7545017b5975c29b9d7458ec65938ed503506a6.json
|
||||
│ │ │ ├── 60ef2d6fded9e070e35cfed119e8325010aa76bce63aa74ca0f25005d8ed9517.json
|
||||
│ │ │ ├── 62dab578f0debe93a8df61a1776953980abd254ae6f1eea77afb00dfefd28f15.json
|
||||
@@ -373,18 +397,17 @@ customerTicket
|
||||
│ │ │ ├── 63a85c116feeb64b68cb48c92e34e48a89fc4ed443f40594b62751711b6e13e0.json
|
||||
│ │ │ ├── 63d01f1af1f4f0c9ad42a8c4d310a5b9df5547d5526c49284255fc6ff15ace9b.json
|
||||
│ │ │ ├── 6506009802a6a268be82b680ace7580613f18dc702ed27aaff929398a85760f3.json
|
||||
│ │ │ ├── 659df1da012e33aa3c92c4d36817034e6ec1a4d5d7ae94e6c2328f9aaa607b6b.json
|
||||
│ │ │ ├── 65c9b0921f2ad9b0228f5685c40d5b341da9604f8cec59339fded4cf0bfa40f4.json
|
||||
│ │ │ ├── 6802a4e7d920a2a5ce48a61989565f6fa769c314a01b9179cb9d44653b9467ae.json
|
||||
│ │ │ ├── 682101a547823a97feec07270aac7a9176109731e376f49d3429fe01db3e8719.json
|
||||
│ │ │ ├── 68ac29aaf1f897ccccd83e9f158a14b1ba6e1baef666230ae35de023dc6c30a7.json
|
||||
│ │ │ ├── 696bb846cc6a3735e085b502a1008e270903c81c7a7adcc7676abcf1930e926a.json
|
||||
│ │ │ ├── 6b88758a380fd9aba4793825f7a8296a9fc2bd40ddb5b423e330c8472c1b030f.json
|
||||
│ │ │ ├── 6bd49215f8d2739a9e365dcc8855b95cfa34352c067a9e9ff48a623318c4ddaa.json
|
||||
│ │ │ ├── 6c3fed09db545f5c9fe4ed1820b386f74deceb2f52e16b6d88181f36dcd09591.json
|
||||
│ │ │ ├── 6c69494ba124d4bc564bcd3a3df1f33ae0ee0769001a2dfc46825ef8b44dc3e2.json
|
||||
│ │ │ ├── 6c983917b91998d7130b467295146438ee4a631519fb7c54b582340f90bfc7e6.json
|
||||
│ │ │ ├── 6d3b094dc56f9b26ce7374ba28e460b85248711223cf516f8e732c5d8f4d9d75.json
|
||||
│ │ │ ├── 6ee9154979fb497a8ec229a7177660af53a487e1aa17c7b555cdc67797615162.json
|
||||
│ │ │ ├── 7093cbd0a855750457814388b6e527fd3b612163f7a2b898ed6ce470dae74843.json
|
||||
│ │ │ ├── 72ae8a21c9106c639725190cc02f4e07efe7c645896cc4410923ad36751fbb2e.json
|
||||
│ │ │ ├── 72de23f55766289c980e6ccf604ca5d162e0989a222271b7496775ba4d749e3a.json
|
||||
│ │ │ ├── 74c2a0c0f83087673b100b7555e506c71d2a6a941677e388a1de59371b6d9afe.json
|
||||
│ │ │ ├── 75fad6124616f6d9e50ce9a475d4e989955c5fae7c5566b99992d986ef9a1f93.json
|
||||
@@ -396,7 +419,6 @@ customerTicket
|
||||
│ │ │ ├── 7b25ccdfeab6d57509c1b8a9745fb601ab7e8fcae0e73a22932ceb725fd38c9a.json
|
||||
│ │ │ ├── 7b6916f499ae0ceae3eb3d509b51f42a7eaa98e43e161fe87c44b9a5bb01287b.json
|
||||
│ │ │ ├── 7cd278b7ea82af8777e4da2f84fd1773dae3dd371744f66bbe12c0b925fd6486.json
|
||||
│ │ │ ├── 7d004a8b515e202f3f48d5c3038f29e5562543bafdef7607cf35b22dae6fa1fd.json
|
||||
│ │ │ ├── 7d276789b80c8d9d1677556b3fb33b2da05988e97f6bf28903a044bb9fd64894.json
|
||||
│ │ │ ├── 7e136779a95d8e8e2ac6cad2192890254fd0d54d46b4f5514debba0f22618d75.json
|
||||
│ │ │ ├── 7e1c0a38a3ed90205d1829bdef528887ef12c928d4da797e06a79b3e7a1a92ba.json
|
||||
@@ -410,17 +432,18 @@ customerTicket
|
||||
│ │ │ ├── 84579b3014e1a83e4201862df1e93378f5f953fbb547fea30965e335be5e7b1e.json
|
||||
│ │ │ ├── 84dedd4165eed360c4051690b2aa84f6ecc08f3f053f4129ca6f3e33c334ba35.json
|
||||
│ │ │ ├── 853fba8af82971f4fa56d957282d45d335d2e794f8e11001977161265af2ec79.json
|
||||
│ │ │ ├── 8595a5d566d43428818ee229e64f6b56453cc9ca61fd68be0b1c3c783d326315.json
|
||||
│ │ │ ├── 88ade181c6611ec33d9bcf5fe739dc809d81875b6571b9f60f87dafb4f5941b2.json
|
||||
│ │ │ ├── 899b16856382d32c4e5b39d02cb251bdedaf35c5a81bede5c19bfaff092208e9.json
|
||||
│ │ │ ├── 8d4849ca8361bd159ec0aeb98091c413998455ca036cfa43f0cf63a6912f8955.json
|
||||
│ │ │ ├── 8e216def3e5fce2d7f4db160b9bc86433bdb5f0638be1367af2ab095d06571b6.json
|
||||
│ │ │ ├── 9137c44b276781b18d9e969742f438877770ff9e8a662611892eed8fc16d93c2.json
|
||||
│ │ │ ├── 8e4cb8d07f4d43e171550ff2fc9423efd032b911d7ffee51259e437d4dc32d4f.json
|
||||
│ │ │ ├── 91d83c62f39f219ccb6125502bf8e0a71a587dfc0a66d977a9bb93c8f2dc5dc6.json
|
||||
│ │ │ ├── 9301321aa7fb1a7beff5518eb54fbf1bf7145e0aa76d3ed29ae4cb38f1057da9.json
|
||||
│ │ │ ├── 9326d87e36b2dc24e6173aa3346f46e7df9fc3353fdb409304af4363c286a41e.json
|
||||
│ │ │ ├── 9697c37cf3a88d2b4eb5419b2381951a9aca966172316db79e7b4c32557d5e87.json
|
||||
│ │ │ ├── 97506546de142970349e57e8a14064280e66a4fcdbced5dac55fc263630a2247.json
|
||||
│ │ │ ├── 97d8ebb860cbcd63be9ef80c6e2094e4a7ccba668b291d3c4815133d5fb8f37e.json
|
||||
│ │ │ ├── 980f1be05d605b9093619724462c9881a8be063a5439567866172a35f8d22316.json
|
||||
│ │ │ ├── 98ca8ab4bab0509a82824f0275ca7d1e9e80f5c8189579ae410a115c62126652.json
|
||||
│ │ │ ├── 9931a5b7d7ab58794d003cce1d0719c201c623a844c7f834f66c88e9390c824e.json
|
||||
│ │ │ ├── 996411678d23c3cf8a0e6c1c5076701e8bf5f3dad21ca907ec4116d15366a668.json
|
||||
│ │ │ ├── 99f94b7b3b60f627c482cc195709b98cd2faa55f14c19c4bf5e0af8a88f34b7a.json
|
||||
@@ -429,64 +452,59 @@ customerTicket
|
||||
│ │ │ ├── 9ce16f69c6b04f703adb24ec7498a2d2ecbf6f7524c459deb25111247e550698.json
|
||||
│ │ │ ├── 9d0e2a1fb14529aabd31c82c8d8f5543c0068366932934038ba93e234f291e22.json
|
||||
│ │ │ ├── 9ed5e291859abba38b07725bc6cf0275a1548e937888a5ba177674afaa48b96f.json
|
||||
│ │ │ ├── 9edcaa74bc64450dff4e25af0b02a830bf940c608b2cfaf74092a2d830f8b07d.json
|
||||
│ │ │ ├── 9ef0e904ba6d1b295c75a1d5741851f571339a8e22c7de96bf5e2508f764ee43.json
|
||||
│ │ │ ├── a04a8ed5ca0ac9661dda4f6f369248937cf67e61cc982a1f17b24495263d53d6.json
|
||||
│ │ │ ├── a081526ffd8f52690e0834cc71e9d2bf987f949c2d0de68a6eb024063d97e64b.json
|
||||
│ │ │ ├── a09e24d5cfcfe359ea13c37261c18e592629a9aeb827faa609ccef4fe7b6a243.json
|
||||
│ │ │ ├── a130f47959c058751c2afdefa119ce88d7c59fa65883f7e0a69099a022033d4d.json
|
||||
│ │ │ ├── a1bd5a32741129c967a18413ae1be6bf3e20754251066d342d652a668ef95025.json
|
||||
│ │ │ ├── a3c770875ffabc18c595532501255df82b1a09246ee54b831e7c2221932d4f88.json
|
||||
│ │ │ ├── a3f339fe1ccb99a051c1a3bff70e7de0e2ee6b952db0aa2d6739e97331c29cd9.json
|
||||
│ │ │ ├── a4a27e7b3842713c361895d6d9c5fc9e4341cce71c5842f014748e1c1c23a197.json
|
||||
│ │ │ ├── a4a568f58efea076f16698bb9bb6279a92ab44167a6e97d424fff44900ff973e.json
|
||||
│ │ │ ├── a5231d68fe35e6b02ff4ff70e02a3229c08d9b6c2bae774a1b3227823b95e15a.json
|
||||
│ │ │ ├── a5c3aedceeab625bb2f60c9e97163491579353e685d8b230342a4e3e30f00db6.json
|
||||
│ │ │ ├── a614593a975a3af1612ede797f69fd29220ee9251ab016d34d5e612a04696ce9.json
|
||||
│ │ │ ├── a6840d352ec8910ac57e79b51062d6da1cd3b277dd849073eb9c3745fafd3635.json
|
||||
│ │ │ ├── a7729f0b6bf20ded62ff7b6ee678fdaae9516dcd80c2a8f9e9f3d9299f6e22a0.json
|
||||
│ │ │ ├── a7b036e5bd51876694ebd883bd1b0a527c5f8409b48d4716612311e954a8cc0f.json
|
||||
│ │ │ ├── a8274003d8d4ef62b23c66e3baecf14eea260f99a4509411d58506f560c2b474.json
|
||||
│ │ │ ├── a836a06de0905683ff2aee057760fe6e4a3f9161d0cb60544fb7afc9ce782671.json
|
||||
│ │ │ ├── a93b84a0def4d2e0e97ff35032871b0ce64786ff468912aed21fd958b9fe782b.json
|
||||
│ │ │ ├── ab1648cc3b4d4bba5754e19eeb80ea160fd20207d5530ac371647168f9ed211a.json
|
||||
│ │ │ ├── ac454f6bfed5913148eb8c9caaa0e30f99bac381936b44612bdee8274bb27b29.json
|
||||
│ │ │ ├── ad1175b041ab42a2615856a41fbab394ae63ef9dd991901c0658c3fb0846ad3f.json
|
||||
│ │ │ ├── ae4d845b7155a0e8a73ab943ab7c4524cd6e73c3f90be008edb478c968d4fd9c.json
|
||||
│ │ │ ├── aeb59c5e1447023f9c49003f6bf4eb26a7b9a34ca5658f7ba2cde1b99e25ec76.json
|
||||
│ │ │ ├── aee78d4e87210ef46180d47e7015345bd66a7763cd140d84cd86539e720e8e67.json
|
||||
│ │ │ ├── af2a54bee158552858e4c0220e207ddc814460a4db1126ec08ba3dd799624e0a.json
|
||||
│ │ │ ├── b137ac0b92c8567809fb0323cd320fbf60c45ae6fad1bbdfb10d6efb22989f0b.json
|
||||
│ │ │ ├── b390f7bae9f80c48e703a6664d9de6ec9c141cdf8ac2f012d67ac2b4d8a25b4e.json
|
||||
│ │ │ ├── b396b497b11e135cfc19ccbee84e3e67df0e401a412f51c0336c89cb3609b5f5.json
|
||||
│ │ │ ├── b4446ca11a1f4f6ea45256e755ed56d6ba7c330278e15ead8cae2de28d5e18d8.json
|
||||
│ │ │ ├── b5f9055685d5b38a3dc351cad310f1a36783f0713c3f0621681fdb43bbf90311.json
|
||||
│ │ │ ├── b6e731246dc10854a0fa0455c3c5816e29887b1a8c436d518926e1c2a5e36239.json
|
||||
│ │ │ ├── b922fd1a31f3cf602f197c872f3bac6e69c79255e502ddcbf1626f846ec3baee.json
|
||||
│ │ │ ├── bb3d09cc5d1bc380e841e2eb19cb497391117101c73c358e6db03db4d6ae51e7.json
|
||||
│ │ │ ├── be6cc64f9c2a8edbdbbd10a1a31e3b3a746eb4bddf806c71d525eff5719b9217.json
|
||||
│ │ │ ├── c1060dca2a3f437f386324975be659215fe9f7e689137dc85e823879b462d11f.json
|
||||
│ │ │ ├── c10793a36312ddf2bd8ed9edbae5d67a19f54b78ac3cae01682d38c34f2bdf11.json
|
||||
│ │ │ ├── c12ef179e0f1696b651a9fc423a169905160de3634d480aedf6071f85a1ebb4f.json
|
||||
│ │ │ ├── c199fcf03e3df0de15c0e7fa5f89fbd9649f2be4a257c8de9a7ebc69ce0d629a.json
|
||||
│ │ │ ├── c1a36d35264d786572e00bd20a4d62c18000e4a5a744b4bbd585c66ba2b1afa4.json
|
||||
│ │ │ ├── c251d835538bf3c08a96268c8b1feaae8084a9c298d634ce2f2ee3b4b15188ce.json
|
||||
│ │ │ ├── c478d961844d415bd36c75ab4921d7bafdc9419a9e0bf6a1efc44f14130e50fe.json
|
||||
│ │ │ ├── c4878d8d8ae19e705821ae40afc1ede4be1a64b559dfdf89f898cb4383718c93.json
|
||||
│ │ │ ├── c6b29929825f59e03fe10ecfc6ab1c0e06594ca745b0b16bddfdbf5b69d3a32f.json
|
||||
│ │ │ ├── c6d29b81d6bbf9e14c7bd3b189339b57188658ed2a270f6ba09e5b3ce071b3c1.json
|
||||
│ │ │ ├── c9e9e63d70bf1ece48f627973df4a1abfd081787641e80a93f1734f30535fd99.json
|
||||
│ │ │ ├── c4a56e0890c5b5bdc963949bff54e8a268081652516ec17e155781a93c56b390.json
|
||||
│ │ │ ├── ca42945436e914d9530e20dc780794451ec6ab51e17414e8c9107ccad3125fd5.json
|
||||
│ │ │ ├── ca8e9d2e76f32d42a0da88c9477956b47dca6cdb27504d8d126e0075306eae98.json
|
||||
│ │ │ ├── caac8d56f4ba7b3987d86c31357c6ffd2c4125d16e4637cb5a4568ac6eee29de.json
|
||||
│ │ │ ├── cab9c3edc4d8c5269c4e14324ef0f6146385bad5e4403cc989acc30c402a7409.json
|
||||
│ │ │ ├── cb87cd70f59a8e0950529ae42a2229a259ff8b305e4319422fb1a9dbc41e63e2.json
|
||||
│ │ │ ├── ccd15ad60b63cd29e2bd2762133a74e2047a2894fd67206dff7f7c7fd082b399.json
|
||||
│ │ │ ├── cd4804092002a20d5ed88c32b577b0868a0ef39e518c749dbadec455775917fb.json
|
||||
│ │ │ ├── ce52c1ffbf8db0d0b4ba85e0a07055258a8eb179db097f1df874e5be81aae49c.json
|
||||
│ │ │ ├── cec4a3cdbea8fa16c18e49426a9cc3be455cbc93cd715159c8b5b4232085f088.json
|
||||
│ │ │ ├── d083dc75811629ffa77ea30c1965e67ef3bc2bf65e5b20a1a35cb0f80a46949c.json
|
||||
│ │ │ ├── d114d2e42d000a18e9502fcb48c3c2bf04f4a6e2de34af8f951279b16a2f3784.json
|
||||
│ │ │ ├── d188f9185e71f8910607d275d82fea22199ba0e6009a5b1e34e5a20f041f0d71.json
|
||||
│ │ │ ├── d2513d4f5ff7e6088864ff497613f47d4083c1ca378649a3f1db6d45a3f3fb2b.json
|
||||
│ │ │ ├── d26a8fc663e9e5fcf33c87592f8c0456e7fe9ec3390bcb902631144d455c2922.json
|
||||
│ │ │ ├── d55e8987ca5ff35748b62290dc14150b1df53163c6f5a5cbe19d03058368f1c6.json
|
||||
│ │ │ ├── d561ececcdd4e7ff59595389fa499591f4e7f3c5324a58dbdd749ccf29e90cd1.json
|
||||
│ │ │ ├── d5dec4474de6c46020e987f20aa3fe54c9bb9c6726a067a75ed94091d552b87f.json
|
||||
│ │ │ ├── d87c7664bacda28563f3f367221789264d43fb4a53abe3afc5692f77c75c6d98.json
|
||||
│ │ │ ├── d6e446c4c97c9aa2b6efbd6042496ca45d8403aacb591adc76e2454b959bc725.json
|
||||
│ │ │ ├── d8c4256d4b9c925575ecabdd7e45c9c95e6fdb32bcfd2d9bb50a05ede9365ece.json
|
||||
│ │ │ ├── d8cda86cbe3f42bb98bb0097071d95924950dfc6ed65a7075d3b22ff1ae0bf0b.json
|
||||
│ │ │ ├── d9516c3232e0f78225764bf4f9586c88932df925c45de71f4eb1c883a9ed5b5d.json
|
||||
│ │ │ ├── d9c81db9cf9f2fee8a4985367d33b1c0ab1d7e6901e85473070c3bfd6bfe32c6.json
|
||||
@@ -495,7 +513,6 @@ customerTicket
|
||||
│ │ │ ├── dd795078450fe69f61a445d3cf1275420be57381b005597cb5003859b2025800.json
|
||||
│ │ │ ├── de1655ae1d42e56f0cdb8788c0f6ba1c7c6e6725e6ef5e80969b92ec3e68ac78.json
|
||||
│ │ │ ├── df2d803fc69084e91df819bda27a7e9f7303a40ff7a5d30d93fbc16daf5e2d45.json
|
||||
│ │ │ ├── df8f2619ccef593ab5bc5a019792f194699a9f3014968288bb01d7773ee3a8cc.json
|
||||
│ │ │ ├── dfc9279b0d9023afc1dbe55536fc1cd7a556a6a437775128b9e41615431b4733.json
|
||||
│ │ │ ├── e2f4f5c0bcb971ba3cb08a4ba14ed5d1ba6b5899fd36db1f5dcdbd2313cd318e.json
|
||||
│ │ │ ├── e359eb0e19f76b69decc0ec747a7fb4c9843046bda31c5e45841858e01bcb5a7.json
|
||||
@@ -510,6 +527,7 @@ customerTicket
|
||||
│ │ │ ├── e90aa709aa08fcf3294352b588ce8c8c902f3ef59a503d4b8d9a292a9c478096.json
|
||||
│ │ │ ├── e974cd0006d8160a6ef1316083bac9e699adfd87acc7edb404ae7eba1c799b68.json
|
||||
│ │ │ ├── ea56445d1314587e18b1992d9599da03e1419ea7bc4929854b9039d6947de33b.json
|
||||
│ │ │ ├── ea8564b376c7156927c3f412f1a7173248d6b2fd7dcfc3492c4c9d7c44dae889.json
|
||||
│ │ │ ├── eb1dd65efa5a99811ccca80ff03a73cb155ad86d13635010a44c09c13953604d.json
|
||||
│ │ │ ├── eb782e7a5b5695997a5bf628a22f4d36f5ddb886e5bdf4b2f4ae53ecc46fbca1.json
|
||||
│ │ │ ├── ec7939ed122c473ca1a4eee50f1c564e04e95ddced18cca56ed83efe5d4071dd.json
|
||||
@@ -518,14 +536,17 @@ customerTicket
|
||||
│ │ │ ├── f06f026d4fb736e9628a73bfaa9ccfe00efc006be79cb1c27f4a77ede5874f52.json
|
||||
│ │ │ ├── f12416bacd2eea21842fe89fcfc2c02ffe84619d7ce380d13679b64c6ec7496d.json
|
||||
│ │ │ ├── f2069429b170b19df531b051a2f5fa81854a7bedf1a036aab1a46c89a9ba4c92.json
|
||||
│ │ │ ├── f20cbddf54e0b52165032d0edf3a66eaafe52ecbf3563e67ea419e7f3ee160f5.json
|
||||
│ │ │ ├── f2287c55ccdc0e5e9ab12f79895b1a1f46b65b2d14f70915ceb09adfc624e3b8.json
|
||||
│ │ │ ├── f3b514e12a39c61de0788da629a34cdd1359c17ebf1ff20747c7be245326f68e.json
|
||||
│ │ │ ├── f41de3fc65a6805cd39a47d713e1d224ba3c4eff476f0aeaf95b5e9b44cbc7ed.json
|
||||
│ │ │ ├── f4436ad45c3f2a27b4461333ae2ed21af157a3945e152fca7f43d640dc39f63b.json
|
||||
│ │ │ ├── f4780f9612e45a5bf32eb07fd006b874586bf10ee1528219fcad4f91304a6640.json
|
||||
│ │ │ ├── f542bc3aa4ea54e09e2fb68ecc937eed71e4307283d9cf7c2d94755c6937b7ad.json
|
||||
│ │ │ ├── f576af4a0cecb6cbaaf8afe5097913be0b7071e200fc924c918ea93446f00c91.json
|
||||
│ │ │ ├── f7e200f94ac2080e0885bf4a03e8afdb5cea2684a31a7c09e67dbf2f4fee6f51.json
|
||||
│ │ │ ├── f8769e3eeb23aebc01d72869a2bc6e635d7dc82440bc2b0b0e11e6b6b17f5109.json
|
||||
│ │ │ ├── f8fc55a028176d1ea9a1b580a8653d4c45ae7466445f37e6c23867f12821c44c.json
|
||||
│ │ │ ├── f9a8e68335360a72fdc96c97c3570febcdea593cf9987e3e92b769107524c4e8.json
|
||||
│ │ │ ├── fb9736a4ef3aca74573f131c7cbd9f015d88dd755ffba3edd324aec95149175f.json
|
||||
│ │ │ ├── fde577f1231f9b033560bfe075896efc6bf8d6079a030a159035584c7d39843b.json
|
||||
│ │ │ ├── fe032050843e4e1c28f5712205df74fa1698390169658c4ddc4555b6bcce8872.json
|
||||
@@ -536,10 +557,16 @@ customerTicket
|
||||
│ │ ├── semantic
|
||||
│ │ │ └── pa567fc138e3a
|
||||
│ │ │ ├── 00825b46909baea25186d0f8351fc2c3fae63520319b5e7bede4ed3e9f53e54d.json
|
||||
│ │ │ ├── 067b09f64665879ffd6e39e47f23d7d969c007079c810536f77d816af6713957.json
|
||||
│ │ │ ├── 09f927cff0791d0e91365f50b4dba1a6c8cc0496ef70847d2ba9f1726092d00c.json
|
||||
│ │ │ ├── 0c8c80793f32ac4d0097cfee25dc9fdf0774d8ff334a3b2c4595e73bec49c4d5.json
|
||||
│ │ │ ├── 1285fb34ce6f72c9ab1cb3259622f3914330beab20c8e0404dbe1f46b9fd374a.json
|
||||
│ │ │ ├── 1516a3814f7846c4b73dd288d7a594bbdc6c4b72a9c177d3a169036715c0d8b0.json
|
||||
│ │ │ ├── 1753a19b402251a9e18e9c4113a5f1750a4168e9b0bedb06b4d6f35f2aa5980c.json
|
||||
│ │ │ ├── 195617b9b33d83896a4ffa16edb1d849e0da5cb7a1aa2b64d82d1bafed070f80.json
|
||||
│ │ │ ├── 1df43c63669132137181dca6f618ab85cd204fd28ef87c08d62a553efdfa06cd.json
|
||||
│ │ │ ├── 1e1ba446fa646a9dbd2244d5138ad84e217e8f238f2508603cfe4c290423dde8.json
|
||||
│ │ │ ├── 1f440edc6feb5bf8c2a462b1b4a0b44bd22b4a763286fb2b340c797c282b5bcd.json
|
||||
│ │ │ ├── 1f916be2dc79752fb2aa8e0c386470f43cc983c9021de885b62ca962ffea960f.json
|
||||
│ │ │ ├── 2233df96365dd9e332d4744056d02289ae3a003a6fb8f3342a205860e03f8665.json
|
||||
│ │ │ ├── 22569dac279084bd62c922dbc391f9a5a7cc2e1051ef043bde5cfc72df0ad864.json
|
||||
@@ -547,34 +574,67 @@ customerTicket
|
||||
│ │ │ ├── 28f2ce9d1005a70b7f624b04888ce9c511251d8da243d6c1c57a630eb14c1220.json
|
||||
│ │ │ ├── 29a4f053d88deb1406e9e7fd20dac4fc1a3c518382af3adcb25fc0d6415e6c31.json
|
||||
│ │ │ ├── 2b0ca95b4d2205396255ef9ad6880126d69de168f4349406796caf7cee711791.json
|
||||
│ │ │ ├── 2db4afd2f04cb1be70a822a9dfde1cbc197dea9ecc8d39cabf5eab10b778f10d.json
|
||||
│ │ │ ├── 3134c8aa643b2f1de403eb947f71f0c404194bf0c036212aed1bbf18972de75f.json
|
||||
│ │ │ ├── 3451c7089bb943a2571e2fd451467aa5e18fc642a64edaee34380e7b59f54baa.json
|
||||
│ │ │ ├── 379031c1044f245cdf1ded7504608f5861a11d5110845436678be4e121251062.json
|
||||
│ │ │ ├── 4466b520ab1a4eb44abdd915e8a1cba43d72c8d08bf93ebea81d8e8feadef93e.json
|
||||
│ │ │ ├── 481e0ef213e9eb7ac3679e2fdf54cfe7bafde152b3de57b0c9306416529ab904.json
|
||||
│ │ │ ├── 485a9d1cf66322f5cb2886aac1f388a2846aba6fd444b87b251edb03c51ff00a.json
|
||||
│ │ │ ├── 4a72da5bbf738460d04c3c799474a4a7093821d69fff2b2efcca1dd78f947652.json
|
||||
│ │ │ ├── 4bf46c6c0f27e14312f7c1311bca1bf5e1bfee29ea5cb35e6f94142141985d23.json
|
||||
│ │ │ ├── 527fef297bee36f729186cbfe4bee9b0a41ad7bc98896815e2058c5348b76706.json
|
||||
│ │ │ ├── 5533327da089db48f0050a2d48af495de46f562ea2e29fcfdf3a527ece013770.json
|
||||
│ │ │ ├── 57240e5b1ee2390b9f26b44f11253b97b1c6b279affdedc34cbeeadc74181504.json
|
||||
│ │ │ ├── 5aa8fb6425a50cdd0fdd3fe862960efbb179a8098df347292940f564737404ef.json
|
||||
│ │ │ ├── 5b5bd6e41d3308af6e19908a392f853875cd8a1f1a10f6e195d21aeab1a6a586.json
|
||||
│ │ │ ├── 660dafe22e562cecf6aaf4afdf658996dd024a1436c57e9815c82caa0c3e1782.json
|
||||
│ │ │ ├── 697c16d4c7fadca75d73c67dcfea3aefc8c3b91394718b1d09188e7e41d8811a.json
|
||||
│ │ │ ├── 6d5ac5656791048028b6bec9a94588cede8731bd7ca2151f13f7819e2819f2e7.json
|
||||
│ │ │ ├── 7322f205d129029de2b4bb0be04fc6de2798fa17122738ae5d5f300e66ee5409.json
|
||||
│ │ │ ├── 74b74470177ae910424d02aaef051f094b6bd2d41b2f68dca025f2c11c0cfe2c.json
|
||||
│ │ │ ├── 77b69c31898165440ab0d856d50bbe29495f04c804d5ca4104be340e0ed39cc7.json
|
||||
│ │ │ ├── 78aa9c4767c326ca3364fd87dfabda624b9bdeb9a14dfc7b678d0bcd9c00e6f7.json
|
||||
│ │ │ ├── 7c1e7a5a501e84e9b782f755ecd9bb13ffce492706d674fc0069d7fa30fe1936.json
|
||||
│ │ │ ├── 7e136779a95d8e8e2ac6cad2192890254fd0d54d46b4f5514debba0f22618d75.json
|
||||
│ │ │ ├── 8042771a592072bef73f5634ecc39cd2b83ba3d808e806a69bacde795ff1d2dc.json
|
||||
│ │ │ ├── 8080490dca9a7ff5f1d9e3724b914ecd44bed3bf66b784be7a32521a7e935a25.json
|
||||
│ │ │ ├── 80cbf2282ae0d907d07bf110760fa70e985fa748f41bcc90533f6ff46eaf46c9.json
|
||||
│ │ │ ├── 853a41742ac5792c2f6d6762d095b7170499a35c16efaa4fa567ee936b4ccb3c.json
|
||||
│ │ │ ├── 87083d9b896625e7efbbfda3a8b4c0983a3914779adc1a9f1fd4775200a98843.json
|
||||
│ │ │ ├── 8a7c3cffb298a6af2f6f497e70513330244117b87649ee61c964461dbcaa05e8.json
|
||||
│ │ │ ├── 8d6f6490e7bc2e9660f5e0cc8ce68371ec092276a3eca9876d198989ed1d3ffb.json
|
||||
│ │ │ ├── 8e62122fceb99e49a7dd46a86cc46cbe3218cd86773d310fe15135a6ad4d6998.json
|
||||
│ │ │ ├── 902240b9e3adc33b9d30ac42dfffbe44368e357a3658728c8d535e47fa5650a6.json
|
||||
│ │ │ ├── 908e0129cefe129d0b562243460fe20793c84876244cef2e99ca1becf8cf6a1f.json
|
||||
│ │ │ ├── 9281775c35930ece9d1382446b2e7d54cb3ec777fbc045ce46cdce5287f589d9.json
|
||||
│ │ │ ├── 95dc06be19ee8e1bbc725384f6e55bbafaab3b6c9f8deea714356fbd0669545a.json
|
||||
│ │ │ ├── 9971de985c218541e881238138359e768e52fb3ef3ce4a1175f38f874a9dac30.json
|
||||
│ │ │ ├── 9da63fa44ddde6838a14da323f4c234ff1498cc0fbc619236aea004b5f556a19.json
|
||||
│ │ │ ├── a150400e1f3f43364a1399b8552647351b4ebba1e26a55efd71c5d4a0fb5b3e1.json
|
||||
│ │ │ ├── a7b56fb6165dabba4d71f0757463855dfca3d85741975d62820b05abee14890f.json
|
||||
│ │ │ ├── a8274003d8d4ef62b23c66e3baecf14eea260f99a4509411d58506f560c2b474.json
|
||||
│ │ │ ├── a9e40d5d4e9e6a566950d6e8fad456d9703444e6e6a3aaac4022462fd2a6c45d.json
|
||||
│ │ │ ├── ab73eedab77367896980e569b7dc44bfc0cd32b02e172584ff2a649948a24a80.json
|
||||
│ │ │ ├── b22c98e94a1e8c2b87a433d7ed250dd2fecd29126618e1846df822e951fd9a95.json
|
||||
│ │ │ ├── b631ef1705c4bbcc263ee039213e6929538d85401b648d0a675849fbeb60db11.json
|
||||
│ │ │ ├── bc16443a4c12a2ca6a8a74c6378c2f66b27425fd52be8ca5c938c2141c886a53.json
|
||||
│ │ │ ├── bd8de3be72dbe84774acd42c873075c4ca1cf14924cc0e20baa5b04a5853065d.json
|
||||
│ │ │ ├── c09151acb3bce9f36e01fecc46548c72a08e152ed0b7402dbc04fc2bdf7f11e5.json
|
||||
│ │ │ ├── c931d64d02e6800ca7dcb354e7043c889c2ab0f9c6bd72723e09da0b754501ef.json
|
||||
│ │ │ ├── ca98ecfba0b2ad600c63c4f743e41528e2add0a2b48774b065aa0f60dcd905e3.json
|
||||
│ │ │ ├── cf5a494e78799d9b7557e6021056f1f896a5e8d8378b85d5a4879eeaceb8bec7.json
|
||||
│ │ │ ├── ddf9f939efdbd2f15fe815fd608b3048eb2c439343c9700f446739f72582a91b.json
|
||||
│ │ │ ├── e00a1732e6e7df91a7aabb9bd609e2a5985d5cf7215635cde4a7495a0112efec.json
|
||||
│ │ │ ├── e10d4f4e825fa6fd025ab71d55be4304798b707016c9b0b0f6cc0da6589a8435.json
|
||||
│ │ │ ├── e19ef9e0e5e0e7fe3db5c64063dd6ba581a9e2e0a578b1c010e83c1e001eea95.json
|
||||
│ │ │ ├── e3fb6d5f912be572c627f840a143729a58015403907a2616b32d3ebbe9933763.json
|
||||
│ │ │ ├── e5f6e9aa1524a7e8c45c4ba6043bbf5798a8f8bec760ed991d69359cd416a08c.json
|
||||
│ │ │ └── ec9174249dd1b030999fd9c188e1713659650afd92dbe8fcd7a1f29666023a81.json
|
||||
│ │ │ ├── ea56445d1314587e18b1992d9599da03e1419ea7bc4929854b9039d6947de33b.json
|
||||
│ │ │ ├── eb0b530666894887e1b06f70f6dc329de81cf71265ae51d4a8ec138a41d67d86.json
|
||||
│ │ │ ├── ec9174249dd1b030999fd9c188e1713659650afd92dbe8fcd7a1f29666023a81.json
|
||||
│ │ │ ├── f60e55eafdca5a62a95829310f4cc0a2a33e91d90b93f95c5b88fe3d6deb13c5.json
|
||||
│ │ │ └── ffc1f756026bddae9a6ad7a82aca9345a23c2762d06107b344bf35f35092653d.json
|
||||
│ │ ├── last_query_stamp
|
||||
│ │ └── stat-index.json
|
||||
│ ├── memory
|
||||
@@ -716,7 +776,9 @@ customerTicket
|
||||
│ ├── pwa
|
||||
│ │ └── service-worker.js
|
||||
│ ├── tickets
|
||||
│ │ ├── _announcement_status.html
|
||||
│ │ ├── _image_viewer.html
|
||||
│ │ ├── _immediate_announcement.html
|
||||
│ │ ├── _messages.html
|
||||
│ │ ├── _older_messages.html
|
||||
│ │ ├── _staff_customer_results.html
|
||||
@@ -852,6 +914,7 @@ customerTicket
|
||||
│ │ └── test_staff_operations_ui.py
|
||||
│ ├── js
|
||||
│ │ └── chat-coach.test.mjs
|
||||
│ ├── test_announcements.py
|
||||
│ ├── test_branding_operations.py
|
||||
│ ├── test_customer_category_catalog.py
|
||||
│ ├── test_customer_unit_categories.py
|
||||
@@ -878,6 +941,7 @@ customerTicket
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── Dockerfile.nginx
|
||||
├── EXTERNAL_CONTACT_APIS.md
|
||||
├── manage.py
|
||||
├── package-lock.json
|
||||
├── package.json
|
||||
|
||||
@@ -1,3 +1,57 @@
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from .models import Attachment,AttachmentPolicy
|
||||
admin.site.register([Attachment,AttachmentPolicy])
|
||||
|
||||
from .models import Attachment, AttachmentPolicy
|
||||
|
||||
|
||||
class AttachmentPolicyAdminForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = AttachmentPolicy
|
||||
fields = "__all__"
|
||||
labels = {
|
||||
"attachment_type": "نوع پیوست (Attachment type)",
|
||||
"is_enabled": "فعال است",
|
||||
"max_file_size": "حداکثر حجم هر فایل (بایت)",
|
||||
"max_files_per_message": "حداکثر تعداد فایل از این نوع در هر پیام",
|
||||
"allowed_extensions": "پسوندهای مجاز (Allowed extensions)",
|
||||
"allowed_mime_types": "نوع محتوای مجاز (Allowed mime types)",
|
||||
"sort_order": "ترتیب نمایش",
|
||||
}
|
||||
help_texts = {
|
||||
"attachment_type": (
|
||||
"شناسه نوع پیوست را با حروف بزرگ وارد کنید: IMAGE، VIDEO، VOICE، PDF، "
|
||||
"WORD، EXCEL، ZIP، RAR یا OTHER. هر نوع فقط یک سیاست دارد."
|
||||
),
|
||||
"is_enabled": "با غیرفعال کردن، آپلود جدید این نوع فایل برای مشتری و کارکنان رد میشود.",
|
||||
"max_file_size": (
|
||||
"واحد بایت است و این سقف برای هر فایل جداگانه اعمال میشود، نه مجموع پیوستها. "
|
||||
"برای تبدیل حجم بر حسب مِبیبایت (MiB) به بایت، عدد را در ۱۰۲۴ × ۱۰۲۴ ضرب کنید؛ "
|
||||
"مثلاً ۳۰ × ۱۰۲۴ × ۱۰۲۴ = 31457280 بایت. فقط عدد نهایی را وارد کنید."
|
||||
),
|
||||
"max_files_per_message": (
|
||||
"سقف تعداد پیوستهای همین نوع در یک پیام است؛ "
|
||||
"مثلاً مقدار ۱۰ برای PDF یعنی حداکثر ۱۰ فایل PDF در هر پیام."
|
||||
),
|
||||
"allowed_extensions": (
|
||||
'فهرست JSON با نقلقول دوتایی، نقطه و حروف کوچک وارد کنید؛ '
|
||||
'برای PDF: [".pdf"] و برای تصویر، مثلاً: [".jpg", ".jpeg", ".png"]. '
|
||||
"این فیلد پسوند نام فایل را کنترل میکند. فرم ادمین فهرست خالی را نمیپذیرد."
|
||||
),
|
||||
"allowed_mime_types": (
|
||||
'فهرست JSON از نوع محتوای واقعی فایل وارد کنید؛ برای PDF: ["application/pdf"] '
|
||||
'و برای تصویر، مثلاً: ["image/jpeg", "image/png"]. '
|
||||
"نوع محتوا از خود فایل تشخیص داده میشود و باید دقیقاً در این فهرست باشد؛ "
|
||||
"مقادیر کلی مثل image/* پشتیبانی نمیشوند. هر دو فهرست پسوند و محتوا باید "
|
||||
"با فایل سازگار باشند. این سیاست برای مشتری و کارکنان مشترک است؛ "
|
||||
"فرم ادمین فهرست خالی را نمیپذیرد."
|
||||
),
|
||||
"sort_order": "عدد صحیح نامنفی برای ثبت ترتیب سیاست؛ مثلاً ۰، ۱ یا ۲.",
|
||||
}
|
||||
|
||||
|
||||
@admin.register(AttachmentPolicy)
|
||||
class AttachmentPolicyAdmin(admin.ModelAdmin):
|
||||
form = AttachmentPolicyAdminForm
|
||||
|
||||
|
||||
admin.site.register(Attachment)
|
||||
@@ -8,6 +8,10 @@ def attachment_path(instance,filename):
|
||||
return f"tickets/{t.created_at:%Y/%m}/{t.public_id}/{m.public_id}/{uuid.uuid4().hex}{ext}"
|
||||
class AttachmentPolicy(models.Model):
|
||||
attachment_type=models.CharField(max_length=20,unique=True); is_enabled=models.BooleanField(default=True); max_file_size=models.PositiveBigIntegerField(); max_files_per_message=models.PositiveSmallIntegerField(default=10); allowed_extensions=models.JSONField(default=list); allowed_mime_types=models.JSONField(default=list); sort_order=models.PositiveIntegerField(default=0); updated_at=models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.attachment_type
|
||||
|
||||
class Attachment(PublicIdModel,models.Model):
|
||||
class Type(models.TextChoices): IMAGE="IMAGE","تصویر"; VIDEO="VIDEO","ویدئو"; VOICE="VOICE","صدا"; PDF="PDF","PDF"; WORD="WORD","Word"; EXCEL="EXCEL","Excel"; ZIP="ZIP","ZIP"; RAR="RAR","RAR"; OTHER="OTHER","سایر"
|
||||
message=models.ForeignKey("messaging.Message",on_delete=models.PROTECT,related_name="attachments"); file=models.FileField(upload_to=attachment_path,max_length=500); original_name=models.CharField(max_length=255); stored_name=models.CharField(max_length=255); relative_path=models.CharField(max_length=500); attachment_type=models.CharField(max_length=20,choices=Type.choices); mime_type=models.CharField(max_length=150); extension=models.CharField(max_length=20); file_size=models.PositiveBigIntegerField(); uploaded_by=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT); created_at=models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@@ -56,10 +56,11 @@ def staff_operations_access(request):
|
||||
if not getattr(user, "is_authenticated", False) or getattr(user, "account_type", "") != "STAFF":
|
||||
return {}
|
||||
from apps.tickets.policies import TicketPolicy
|
||||
from apps.tickets.staff_conversation import unread_shares
|
||||
memberships = list(user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit"))
|
||||
def allowed(permission):
|
||||
return user.is_superuser or user.has_perm(permission) or any(TicketPolicy.has_unit_permission(user, item.unit, permission) for item in memberships)
|
||||
return {"staff_operations_access": {
|
||||
return {"staff_unread_shares": unread_shares(user), "staff_operations_access": {
|
||||
"create_ticket": user.has_perm("tickets.add_ticket") or allowed("tickets.add_ticket"),
|
||||
"contacts": True,
|
||||
"contact_groups": allowed("customers.view_contactgroup") or allowed("customers.add_contactgroup"),
|
||||
|
||||
@@ -67,6 +67,20 @@ class TransferForm(forms.Form):
|
||||
class ShareForm(TransferForm):
|
||||
permission_level=forms.ChoiceField(label="سطح دسترسی",choices=TicketAccessGrant.Level.choices)
|
||||
|
||||
|
||||
class StaffMessageShareForm(TransferForm):
|
||||
note = forms.CharField(label="توضیح برای گیرنده", required=False, max_length=500, widget=forms.Textarea(attrs={"rows": 2}))
|
||||
|
||||
def __init__(self, *args, actor, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
from .sharing import share_recipients
|
||||
self.fields["expert"].label = "کارشناس گیرنده"
|
||||
self.fields["expert"].queryset = share_recipients(actor)
|
||||
|
||||
|
||||
class StaffTicketShareForm(StaffMessageShareForm):
|
||||
permission_level = forms.ChoiceField(label="سطح دسترسی", choices=TicketAccessGrant.Level.choices, initial=TicketAccessGrant.Level.VIEW_ONLY)
|
||||
|
||||
class StaffCreateTicketForm(forms.Form):
|
||||
unit=forms.ModelChoiceField(label="واحد ایجادکننده",queryset=OrganizationalUnit.objects.none(),required=False,widget=forms.HiddenInput())
|
||||
customer_id=forms.UUIDField(required=False,widget=forms.HiddenInput(attrs={"data-customer-id":""}))
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Generated by Django 5.1.1 on 2026-10-04 07:56
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('messaging', '0003_messagereadreceipt'),
|
||||
('tickets', '0016_scope_legacy_public_tickets_to_units'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='ticketaccessgrant',
|
||||
name='first_viewed_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticketaccessgrant',
|
||||
name='last_viewed_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticketaccessgrant',
|
||||
name='note',
|
||||
field=models.CharField(blank=True, max_length=500),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticketaccessgrant',
|
||||
name='revoked_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='revoked_ticket_grants', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticketaccessgrant',
|
||||
name='view_count',
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ticketevent',
|
||||
name='event_type',
|
||||
field=models.CharField(choices=[('CREATED', 'ایجاد'), ('CLAIMED', 'برداشت'), ('TRANSFERRED', 'انتقال'), ('SHARED', 'اشتراک تیکت'), ('ACCESS_REVOKED', 'لغو دسترسی تیکت'), ('SHARE_OPENED', 'مشاهده تیکت اشتراکی'), ('MESSAGE_SHARED', 'اشتراک یک پیام'), ('MESSAGE_SHARE_REVOKED', 'لغو اشتراک پیام'), ('MESSAGE_SHARE_OPENED', 'مشاهده پیام اشتراکی'), ('STATUS_CHANGED', 'تغییر وضعیت'), ('PRIORITY_CHANGED', 'تغییر اولویت'), ('REOPENED', 'بازگشایی'), ('RESOLVED', 'حل'), ('CLOSED', 'بستن')], max_length=30),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MessageShare',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('note', models.CharField(blank=True, max_length=500)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('first_viewed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('last_viewed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('view_count', models.PositiveIntegerField(default=0)),
|
||||
('revoked_at', models.DateTimeField(blank=True, null=True)),
|
||||
('granted_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sent_message_shares', to=settings.AUTH_USER_MODEL)),
|
||||
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='staff_shares', to='messaging.message')),
|
||||
('revoked_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='revoked_message_shares', to=settings.AUTH_USER_MODEL)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_message_shares', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at', '-pk'],
|
||||
'constraints': [models.UniqueConstraint(condition=models.Q(('revoked_at__isnull', True)), fields=('message', 'user'), name='unique_active_message_share')],
|
||||
},
|
||||
),
|
||||
]
|
||||
+39
-1
@@ -128,13 +128,51 @@ class Ticket(PersianTextNormalizationMixin, PublicIdModel, TimeStampedModel):
|
||||
profile=getattr(expert,"staff_profile",None) if expert else None
|
||||
return getattr(profile,"display_name","") or "صف عمومی"
|
||||
class TicketAccessGrant(models.Model):
|
||||
note = models.CharField(max_length=500, blank=True)
|
||||
first_viewed_at = models.DateTimeField(null=True, blank=True)
|
||||
last_viewed_at = models.DateTimeField(null=True, blank=True)
|
||||
view_count = models.PositiveIntegerField(default=0)
|
||||
revoked_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="revoked_ticket_grants")
|
||||
class Level(models.TextChoices): VIEW_ONLY="VIEW_ONLY","فقط مشاهده"; REPLY="REPLY","پاسخ"; FULL="FULL","کامل"
|
||||
ticket=models.ForeignKey(Ticket,on_delete=models.CASCADE,related_name="access_grants"); user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,related_name="ticket_grants"); granted_by=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT,related_name="granted_ticket_access"); permission_level=models.CharField(max_length=15,choices=Level.choices,default=Level.FULL); created_at=models.DateTimeField(auto_now_add=True); revoked_at=models.DateTimeField(null=True,blank=True)
|
||||
class Meta:
|
||||
constraints=[models.UniqueConstraint(fields=["ticket","user"],condition=Q(revoked_at__isnull=True),name="unique_active_ticket_grant")]
|
||||
class TicketEvent(models.Model):
|
||||
class Type(models.TextChoices): CREATED="CREATED","ایجاد"; CLAIMED="CLAIMED","برداشت"; TRANSFERRED="TRANSFERRED","انتقال"; SHARED="SHARED","اشتراک"; ACCESS_REVOKED="ACCESS_REVOKED","لغو دسترسی"; STATUS_CHANGED="STATUS_CHANGED","تغییر وضعیت"; PRIORITY_CHANGED="PRIORITY_CHANGED","تغییر اولویت"; REOPENED="REOPENED","بازگشایی"; RESOLVED="RESOLVED","حل"; CLOSED="CLOSED","بستن"
|
||||
class Type(models.TextChoices):
|
||||
CREATED="CREATED","ایجاد"
|
||||
CLAIMED="CLAIMED","برداشت"
|
||||
TRANSFERRED="TRANSFERRED","انتقال"
|
||||
SHARED="SHARED","اشتراک تیکت"
|
||||
ACCESS_REVOKED="ACCESS_REVOKED","لغو دسترسی تیکت"
|
||||
SHARE_OPENED="SHARE_OPENED","مشاهده تیکت اشتراکی"
|
||||
MESSAGE_SHARED="MESSAGE_SHARED","اشتراک یک پیام"
|
||||
MESSAGE_SHARE_REVOKED="MESSAGE_SHARE_REVOKED","لغو اشتراک پیام"
|
||||
MESSAGE_SHARE_OPENED="MESSAGE_SHARE_OPENED","مشاهده پیام اشتراکی"
|
||||
STATUS_CHANGED="STATUS_CHANGED","تغییر وضعیت"
|
||||
PRIORITY_CHANGED="PRIORITY_CHANGED","تغییر اولویت"
|
||||
REOPENED="REOPENED","بازگشایی"
|
||||
RESOLVED="RESOLVED","حل"
|
||||
CLOSED="CLOSED","بستن"
|
||||
ticket=models.ForeignKey(Ticket,on_delete=models.CASCADE,related_name="events"); event_type=models.CharField(max_length=30,choices=Type.choices); actor=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT,null=True); metadata=models.JSONField(default=dict,blank=True); created_at=models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class MessageShare(PublicIdModel, models.Model):
|
||||
"""A staff-only grant to one message; never confers ticket access."""
|
||||
|
||||
message = models.ForeignKey("messaging.Message", on_delete=models.CASCADE, related_name="staff_shares")
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="received_message_shares")
|
||||
granted_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="sent_message_shares")
|
||||
note = models.CharField(max_length=500, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
first_viewed_at = models.DateTimeField(null=True, blank=True)
|
||||
last_viewed_at = models.DateTimeField(null=True, blank=True)
|
||||
view_count = models.PositiveIntegerField(default=0)
|
||||
revoked_at = models.DateTimeField(null=True, blank=True)
|
||||
revoked_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True, related_name="revoked_message_shares")
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at", "-pk"]
|
||||
constraints = [models.UniqueConstraint(fields=["message", "user"], condition=Q(revoked_at__isnull=True), name="unique_active_message_share")]
|
||||
class TicketReadState(models.Model):
|
||||
ticket=models.ForeignKey(Ticket,on_delete=models.CASCADE,related_name="read_states"); user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE); last_read_at=models.DateTimeField(); last_read_message=models.ForeignKey("messaging.Message",on_delete=models.SET_NULL,null=True,blank=True)
|
||||
class Meta: constraints=[models.UniqueConstraint(fields=["ticket","user"],name="unique_ticket_read_state")]
|
||||
@@ -4,6 +4,11 @@ from .models import TicketAccessGrant
|
||||
|
||||
|
||||
class TicketPolicy:
|
||||
@classmethod
|
||||
def can_operate(cls, user, ticket):
|
||||
profile = getattr(user, "staff_profile", None)
|
||||
return bool(user.is_authenticated and user.is_active and getattr(user, "account_type", None) == "STAFF"
|
||||
and (profile is None or profile.is_active) and cls.can_view(user, ticket))
|
||||
@staticmethod
|
||||
def is_customer(user): return getattr(user,"account_type",None)=="CUSTOMER"
|
||||
@staticmethod
|
||||
@@ -118,12 +123,10 @@ class TicketPolicy:
|
||||
return permitted and cls.can_view(user, ticket) and ticket.owner_id is None and ticket.selected_expert_id is None
|
||||
@classmethod
|
||||
def can_transfer(cls,user,ticket):
|
||||
permitted=user.has_perm("tickets.transfer_ticket") or cls.has_unit_permission(user,ticket.owning_unit,"tickets.transfer_ticket")
|
||||
return permitted and cls.can_view(user,ticket) and (ticket.owner_id==user.id or (ticket.owner_id is None and ticket.selected_expert_id==user.id) or user.has_perm("tickets.view_all_tickets"))
|
||||
return cls.can_operate(user, ticket)
|
||||
@classmethod
|
||||
def can_share(cls,user,ticket):
|
||||
permitted=user.has_perm("tickets.share_ticket") or cls.has_unit_permission(user,ticket.owning_unit,"tickets.share_ticket")
|
||||
return permitted and cls.can_view(user,ticket) and (ticket.owner_id==user.id or (ticket.owner_id is None and ticket.selected_expert_id==user.id) or user.has_perm("tickets.view_all_tickets"))
|
||||
return cls.can_operate(user, ticket)
|
||||
@classmethod
|
||||
def can_delete(cls, user, ticket):
|
||||
if not user.is_authenticated or cls.is_customer(user):
|
||||
|
||||
@@ -12,7 +12,11 @@ def staff_ticket_rows(user):
|
||||
def filter_staff_tickets(qs, user, params):
|
||||
search=params.get("q", "").strip()
|
||||
if search:
|
||||
qs=qs.filter(Q(ticket_number__icontains=search)|Q(subject__icontains=search)|Q(customer__mobile__icontains=search)|Q(customer__customer_profile__first_name__icontains=search)|Q(customer__customer_profile__last_name__icontains=search))
|
||||
matches=Q(ticket_number__icontains=search)|Q(subject__icontains=search)|Q(customer__mobile__icontains=search)|Q(customer__customer_profile__first_name__icontains=search)|Q(customer__customer_profile__last_name__icontains=search)
|
||||
if params.get("tab") == "shared":
|
||||
from .staff_conversation import received_messages
|
||||
matches |= Q(pk__in=received_messages(user).filter(message__body__icontains=search).values("message__ticket_id"))
|
||||
qs=qs.filter(matches)
|
||||
if params.get("status") in Ticket.Status.values: qs=qs.filter(status=params["status"])
|
||||
if params.get("priority") in Ticket.Priority.values: qs=qs.filter(priority=params["priority"])
|
||||
if params.get("category", "").isdigit(): qs=qs.filter(category_id=params["category"])
|
||||
@@ -41,13 +45,18 @@ def filter_staff_tickets(qs, user, params):
|
||||
if tab=="new": qs=qs.filter(status=Ticket.Status.NEW,owner__isnull=True,selected_expert__isnull=True)
|
||||
elif tab=="selected": qs=qs.filter(selected_expert=user,owner__isnull=True)
|
||||
elif tab=="mine": qs=qs.filter(owner=user)
|
||||
elif tab=="shared": qs=qs.filter(access_grants__user=user,access_grants__revoked_at__isnull=True)
|
||||
elif tab=="shared":
|
||||
from .staff_conversation import received_messages
|
||||
qs=qs.filter(Q(access_grants__user=user,access_grants__revoked_at__isnull=True) | Q(pk__in=received_messages(user).values("message__ticket_id")))
|
||||
elif tab=="waiting_staff": qs=qs.filter(status__in=[Ticket.Status.WAITING_STAFF,Ticket.Status.REOPENED])
|
||||
elif tab=="waiting_customer": qs=qs.filter(status=Ticket.Status.WAITING_CUSTOMER)
|
||||
elif tab=="resolved": qs=qs.filter(status=Ticket.Status.RESOLVED)
|
||||
elif tab=="closed": qs=qs.filter(status=Ticket.Status.CLOSED)
|
||||
if params.get("unread"):
|
||||
qs=qs.filter(Q(read_states__user=user,read_states__last_read_at__lt=F("last_activity_at"))|Q(read_states__isnull=True))
|
||||
if tab == "shared":
|
||||
qs=qs.filter(Q(access_grants__user=user,access_grants__revoked_at__isnull=True,access_grants__first_viewed_at__isnull=True) | Q(pk__in=received_messages(user).filter(first_viewed_at__isnull=True).values("message__ticket_id")))
|
||||
else:
|
||||
qs=qs.filter(Q(read_states__user=user,read_states__last_read_at__lt=F("last_activity_at"))|Q(read_states__isnull=True))
|
||||
qs=qs.distinct()
|
||||
if tab in ("", "waiting_staff"):
|
||||
return qs.annotate(
|
||||
|
||||
@@ -183,7 +183,14 @@ def share_ticket(ticket,user,target,level=TicketAccessGrant.Level.FULL):
|
||||
@transaction.atomic
|
||||
def revoke_access(ticket,user,target):
|
||||
if not TicketPolicy.can_share(user,ticket): raise PermissionDenied
|
||||
count=ticket.access_grants.filter(user=target,revoked_at__isnull=True).update(revoked_at=timezone.now()); TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.ACCESS_REVOKED,actor=user,metadata={"user":target.pk}); return count
|
||||
from .sharing import log_share
|
||||
grants=list(ticket.access_grants.select_for_update().filter(user=target,revoked_at__isnull=True))
|
||||
for grant in grants:
|
||||
grant.revoked_at=timezone.now()
|
||||
grant.revoked_by=user
|
||||
grant.save(update_fields=["revoked_at","revoked_by"])
|
||||
log_share(grant,user,TicketEvent.Type.ACCESS_REVOKED,"revoked")
|
||||
return len(grants)
|
||||
|
||||
@transaction.atomic
|
||||
def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_users=()):
|
||||
@@ -236,8 +243,7 @@ def delete_ticket(*, ticket_id, user):
|
||||
|
||||
@transaction.atomic
|
||||
def change_status(ticket,user,status):
|
||||
permitted=user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(user,ticket.owning_unit,"tickets.change_ticket")
|
||||
if not (TicketPolicy.can_view(user,ticket) and permitted): raise PermissionDenied
|
||||
if not TicketPolicy.can_operate(user,ticket): raise PermissionDenied
|
||||
if status not in Ticket.Status.values: raise ValidationError("وضعیت نامعتبر است.")
|
||||
old=ticket.status; now=timezone.now(); ticket.status=status
|
||||
if status==Ticket.Status.RESOLVED: ticket.resolved_at=now
|
||||
@@ -246,8 +252,7 @@ def change_status(ticket,user,status):
|
||||
|
||||
@transaction.atomic
|
||||
def change_priority(ticket,user,priority):
|
||||
permitted=user.has_perm("tickets.change_ticket_priority") or TicketPolicy.has_unit_permission(user,ticket.owning_unit,"tickets.change_ticket_priority")
|
||||
if not (TicketPolicy.can_view(user,ticket) and permitted): raise PermissionDenied
|
||||
if not TicketPolicy.can_operate(user,ticket): raise PermissionDenied
|
||||
if priority not in Ticket.Priority.values: raise ValidationError("اولویت نامعتبر است.")
|
||||
old=ticket.priority; ticket.priority=priority; ticket.save(update_fields=["priority","updated_at"])
|
||||
TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.PRIORITY_CHANGED,actor=user,metadata={"from":old,"to":priority})
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Staff sharing and audit services, isolated from customer workflows."""
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.staff.models import StaffProfile
|
||||
from .models import MessageShare, Ticket, TicketAccessGrant, TicketEvent
|
||||
from .policies import TicketPolicy
|
||||
|
||||
|
||||
def staff_name(user):
|
||||
profile = getattr(user, "staff_profile", None)
|
||||
return str(profile) if profile else user.username
|
||||
|
||||
|
||||
def share_recipients(actor):
|
||||
return StaffProfile.objects.filter(is_active=True, user__is_active=True, user__account_type="STAFF").exclude(user=actor).select_related("user", "primary_unit")
|
||||
|
||||
|
||||
def require_share_authority(actor, ticket):
|
||||
if actor.account_type != "STAFF" or not actor.is_active or not TicketPolicy.can_share(actor, ticket):
|
||||
raise PermissionDenied
|
||||
|
||||
|
||||
def validate_recipient(actor, target):
|
||||
if not share_recipients(actor).filter(user=target).exists():
|
||||
raise ValidationError("گیرنده باید یک کارشناس فعال دیگر باشد.")
|
||||
|
||||
|
||||
def log_share(grant, actor, event_type, action):
|
||||
is_message = isinstance(grant, MessageShare)
|
||||
ticket = grant.message.ticket if is_message else grant.ticket
|
||||
metadata = {"scope": "message" if is_message else "ticket", "grant_id": grant.pk,
|
||||
"user": grant.user_id, "recipient_name": staff_name(grant.user),
|
||||
"action": action, "note": grant.note}
|
||||
if is_message:
|
||||
metadata["message_public_id"] = str(grant.message.public_id)
|
||||
else:
|
||||
metadata["level"] = grant.permission_level
|
||||
return TicketEvent.objects.create(ticket=ticket, actor=actor, event_type=event_type, metadata=metadata)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def share_staff_ticket(ticket, actor, target, level, note=""):
|
||||
ticket = Ticket.objects.select_for_update().get(pk=ticket.pk)
|
||||
require_share_authority(actor, ticket)
|
||||
validate_recipient(actor, target)
|
||||
if level not in TicketAccessGrant.Level.values or len(note) > 500:
|
||||
raise ValidationError("سطح دسترسی یا توضیح اشتراک نامعتبر است.")
|
||||
grant = ticket.access_grants.filter(user=target, revoked_at__isnull=True).first()
|
||||
if grant and grant.permission_level == level and grant.note == note:
|
||||
return grant
|
||||
action = "updated" if grant else "created"
|
||||
if grant:
|
||||
grant.permission_level, grant.note = level, note
|
||||
grant.save(update_fields=["permission_level", "note"])
|
||||
else:
|
||||
grant = TicketAccessGrant.objects.create(ticket=ticket, user=target, granted_by=actor, permission_level=level, note=note)
|
||||
event = log_share(grant, actor, TicketEvent.Type.SHARED, action)
|
||||
# Preserve the existing staff ticket-sharing notification behavior.
|
||||
from apps.notifications.models import NotificationJob
|
||||
from apps.notifications.services import enqueue
|
||||
enqueue(NotificationJob.Channel.BALE, "SHARE", ticket, f"ticket:{ticket.pk}:share-event:{event.pk}", staff_user=target)
|
||||
return grant
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def share_staff_message(message, actor, target, note=""):
|
||||
ticket = Ticket.objects.select_for_update().get(pk=message.ticket_id)
|
||||
require_share_authority(actor, ticket)
|
||||
validate_recipient(actor, target)
|
||||
message.refresh_from_db()
|
||||
if message.deleted_at or len(note) > 500:
|
||||
raise ValidationError("پیام حذفشده قابل اشتراک نیست یا توضیح بیش از حد طولانی است.")
|
||||
grant = MessageShare.objects.filter(message=message, user=target, revoked_at__isnull=True).first()
|
||||
if grant and grant.note == note:
|
||||
return grant
|
||||
action = "updated" if grant else "created"
|
||||
if grant:
|
||||
grant.note = note
|
||||
grant.save(update_fields=["note"])
|
||||
else:
|
||||
grant = MessageShare.objects.create(message=message, user=target, granted_by=actor, note=note)
|
||||
log_share(grant, actor, TicketEvent.Type.MESSAGE_SHARED, action)
|
||||
return grant
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def revoke_staff_share(grant, actor):
|
||||
ticket_id = grant.message.ticket_id if isinstance(grant, MessageShare) else grant.ticket_id
|
||||
ticket = Ticket.objects.select_for_update().get(pk=ticket_id)
|
||||
require_share_authority(actor, ticket)
|
||||
grant.refresh_from_db()
|
||||
if grant.revoked_at:
|
||||
return False
|
||||
if isinstance(grant, TicketAccessGrant):
|
||||
from .services import revoke_access
|
||||
return bool(revoke_access(ticket, actor, grant.user))
|
||||
grant.revoked_at, grant.revoked_by = timezone.now(), actor
|
||||
grant.save(update_fields=["revoked_at", "revoked_by"])
|
||||
log_share(grant, actor, TicketEvent.Type.MESSAGE_SHARE_REVOKED, "revoked")
|
||||
return True
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def mark_share_opened(grant, actor):
|
||||
grant = type(grant).objects.select_for_update().get(pk=grant.pk)
|
||||
if grant.user_id != actor.pk or grant.revoked_at or (isinstance(grant, MessageShare) and grant.message.deleted_at):
|
||||
raise PermissionDenied
|
||||
now = timezone.now()
|
||||
if grant.first_viewed_at is None:
|
||||
grant.first_viewed_at = now
|
||||
event_type = TicketEvent.Type.MESSAGE_SHARE_OPENED if isinstance(grant, MessageShare) else TicketEvent.Type.SHARE_OPENED
|
||||
log_share(grant, actor, event_type, "opened")
|
||||
grant.last_viewed_at = now
|
||||
grant.view_count += 1
|
||||
grant.save(update_fields=["first_viewed_at", "last_viewed_at", "view_count"])
|
||||
return grant
|
||||
|
||||
|
||||
def mark_ticket_share_opened(ticket, actor):
|
||||
grant = ticket.access_grants.filter(user=actor, revoked_at__isnull=True).first()
|
||||
if grant:
|
||||
mark_share_opened(grant, actor)
|
||||
|
||||
|
||||
def staff_message_context(ticket, actor, conversation=()):
|
||||
can_share = TicketPolicy.can_share(actor, ticket)
|
||||
context = {"can_share_message": can_share, "share_experts": share_recipients(actor) if can_share else []}
|
||||
if not can_share:
|
||||
return context
|
||||
selected = {item.pk: item for item in conversation}
|
||||
for item in selected.values():
|
||||
item.staff_message_shares = []
|
||||
item.staff_share_events = []
|
||||
if not selected:
|
||||
return context
|
||||
grants = MessageShare.objects.filter(message__ticket=ticket, message_id__in=selected).select_related(
|
||||
"message", "user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile")
|
||||
for grant in grants:
|
||||
selected[grant.message_id].staff_message_shares.append(grant)
|
||||
by_public_id = {str(item.public_id): item for item in selected.values() if item.staff_message_shares}
|
||||
if by_public_id:
|
||||
for event in sharing_events(ticket, message_ids=list(by_public_id)):
|
||||
by_public_id[event.metadata["message_public_id"]].staff_share_events.append(event)
|
||||
return context
|
||||
|
||||
|
||||
def sharing_context(ticket, actor):
|
||||
if not TicketPolicy.can_share(actor, ticket):
|
||||
return {}
|
||||
return {
|
||||
"grants": ticket.access_grants.select_related("user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile").order_by("-created_at", "-pk"),
|
||||
"message_shares": MessageShare.objects.filter(message__ticket=ticket).select_related("message", "user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile"),
|
||||
"sharing_events": sharing_events(ticket),
|
||||
}
|
||||
|
||||
|
||||
def sharing_events(ticket, message_ids=None):
|
||||
event_types = [TicketEvent.Type.SHARED, TicketEvent.Type.ACCESS_REVOKED, TicketEvent.Type.SHARE_OPENED,
|
||||
TicketEvent.Type.MESSAGE_SHARED, TicketEvent.Type.MESSAGE_SHARE_REVOKED,
|
||||
TicketEvent.Type.MESSAGE_SHARE_OPENED, TicketEvent.Type.TRANSFERRED]
|
||||
query = ticket.events.filter(event_type__in=event_types).select_related("actor__staff_profile").order_by("-created_at", "-pk")
|
||||
if message_ids is not None:
|
||||
query = query.filter(metadata__message_public_id__in=message_ids)
|
||||
else:
|
||||
query = query[:100]
|
||||
events = list(query)
|
||||
from apps.accounts.models import User
|
||||
user_ids = {e.metadata.get(key) for e in events for key in ("user", "from", "to") if e.metadata.get(key)}
|
||||
names = {u.pk: staff_name(u) for u in User.objects.filter(pk__in=user_ids).select_related("staff_profile")}
|
||||
for event in events:
|
||||
event.recipient_name = event.metadata.get("recipient_name") or names.get(event.metadata.get("user") or event.metadata.get("to"), "")
|
||||
event.previous_owner_name = names.get(event.metadata.get("from"), "")
|
||||
event.actor_name = staff_name(event.actor) if event.actor else "سامانه"
|
||||
return events
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Routes available only to staff, including isolated message attachment delivery."""
|
||||
import mimetypes
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.http import FileResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
|
||||
from apps.attachments.delivery import signed_media_redirect
|
||||
from apps.attachments.models import Attachment
|
||||
from .models import MessageShare, Ticket
|
||||
from .staff_conversation import received_messages, unread_shares, visible_messages
|
||||
from .sharing import mark_share_opened, revoke_staff_share
|
||||
from .views import staff_required
|
||||
|
||||
|
||||
def received_message_share(request, share_id):
|
||||
grant = get_object_or_404(MessageShare.objects.select_related("message__author__staff_profile", "granted_by__staff_profile"), public_id=share_id)
|
||||
profile = getattr(request.user, "staff_profile", None)
|
||||
if (grant.user_id != request.user.pk or grant.revoked_at or grant.message.deleted_at
|
||||
or not request.user.is_active or not profile or not profile.is_active):
|
||||
raise PermissionDenied
|
||||
return grant
|
||||
|
||||
|
||||
def submit_share_form(request, form, save, success_message):
|
||||
if not form.is_valid():
|
||||
messages.error(request, "گیرنده، سطح دسترسی یا توضیح اشتراک معتبر نیست.")
|
||||
return
|
||||
try:
|
||||
save()
|
||||
except ValidationError as error:
|
||||
messages.error(request, " ".join(error.messages))
|
||||
else:
|
||||
messages.success(request, success_message)
|
||||
|
||||
|
||||
@staff_required
|
||||
@require_GET
|
||||
@never_cache
|
||||
def inbox(request):
|
||||
from django.urls import reverse
|
||||
return redirect(reverse("staff_tickets:list") + "?tab=shared")
|
||||
|
||||
|
||||
@staff_required
|
||||
@require_POST
|
||||
def revoke_message(request, public_id, share_id):
|
||||
grant = get_object_or_404(MessageShare.objects.select_related("message__ticket"), public_id=share_id, message__ticket__public_id=public_id)
|
||||
revoke_staff_share(grant, request.user)
|
||||
messages.success(request, "دسترسی به پیام اشتراکی لغو شد؛ سابقه آن حفظ شده است.")
|
||||
return redirect("staff_tickets:detail", public_id=public_id)
|
||||
|
||||
|
||||
@staff_required
|
||||
@require_GET
|
||||
@never_cache
|
||||
def shared_message(request, share_id):
|
||||
grant = received_message_share(request, share_id)
|
||||
return redirect("staff_tickets:detail", public_id=grant.message.ticket.public_id)
|
||||
|
||||
|
||||
@staff_required
|
||||
@require_POST
|
||||
@never_cache
|
||||
def read_shared_messages(request, public_id):
|
||||
ticket = get_object_or_404(Ticket, public_id=public_id)
|
||||
allowed = visible_messages(ticket, request.user)
|
||||
ids = request.POST.getlist("message_ids")[:100]
|
||||
# Filter strings without parsing attacker-controlled UUIDs in the ORM.
|
||||
visible = {str(value) for value in allowed.values_list("public_id", flat=True)}
|
||||
accepted = set(ids) & visible
|
||||
grants = received_messages(request.user, ticket).filter(message__public_id__in=accepted)
|
||||
for grant in grants:
|
||||
if grant.first_viewed_at is None:
|
||||
mark_share_opened(grant, request.user)
|
||||
return JsonResponse({"unread_shares": unread_shares(request.user)})
|
||||
|
||||
|
||||
@staff_required
|
||||
@require_GET
|
||||
@never_cache
|
||||
def unread_count(request):
|
||||
return JsonResponse({"unread_shares": unread_shares(request.user)})
|
||||
|
||||
|
||||
@staff_required
|
||||
@require_GET
|
||||
@never_cache
|
||||
def shared_attachment(request, share_id, attachment_id, download=False):
|
||||
grant = received_message_share(request, share_id)
|
||||
attachment = get_object_or_404(Attachment, public_id=attachment_id, message_id=grant.message_id)
|
||||
if settings.USE_NGINX_MEDIA:
|
||||
return signed_media_redirect(attachment.relative_path, download=download)
|
||||
content_type = attachment.mime_type or mimetypes.guess_type(attachment.original_name)[0] or "application/octet-stream"
|
||||
response = FileResponse(attachment.file.open("rb"), content_type=content_type, as_attachment=download, filename=attachment.original_name)
|
||||
response["X-Content-Type-Options"] = "nosniff"
|
||||
return response
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Staff conversation access; message grants never become ticket grants."""
|
||||
import hashlib
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import Q
|
||||
from django.http import JsonResponse
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from .models import MessageShare, TicketAccessGrant
|
||||
from .policies import TicketPolicy
|
||||
from .sharing import staff_message_context
|
||||
|
||||
|
||||
def received_messages(user, ticket=None):
|
||||
grants = MessageShare.objects.filter(user=user, revoked_at__isnull=True, message__deleted_at__isnull=True)
|
||||
return grants.filter(message__ticket=ticket) if ticket is not None else grants
|
||||
|
||||
|
||||
def unread_shares(user):
|
||||
profile = getattr(user, "staff_profile", None)
|
||||
if not user.is_authenticated or not user.is_active or user.account_type != "STAFF" or not profile or not profile.is_active:
|
||||
return 0
|
||||
return (received_messages(user).filter(first_viewed_at__isnull=True).count()
|
||||
+ TicketAccessGrant.objects.filter(user=user, revoked_at__isnull=True, first_viewed_at__isnull=True).count())
|
||||
|
||||
|
||||
def visible_messages(ticket, user):
|
||||
profile = getattr(user, "staff_profile", None)
|
||||
if not user.is_active or (profile is not None and not profile.is_active):
|
||||
raise PermissionDenied
|
||||
query = ticket.ticket_messages.all()
|
||||
if TicketPolicy.can_view(user, ticket):
|
||||
return query
|
||||
grants = received_messages(user, ticket)
|
||||
if not grants.exists():
|
||||
raise PermissionDenied
|
||||
return query.filter(pk__in=grants.values("message_id"))
|
||||
|
||||
|
||||
def message_context(ticket, user, conversation):
|
||||
context = staff_message_context(ticket, user, conversation)
|
||||
grants = {g.message_id: g for g in received_messages(user, ticket).select_related("granted_by__staff_profile")}
|
||||
for item in conversation:
|
||||
item.received_share = grants.get(item.pk)
|
||||
context["limited_share"] = not TicketPolicy.can_view(user, ticket)
|
||||
return context
|
||||
|
||||
|
||||
def shared_ticket_rows(user, params):
|
||||
"""All received shares use ordinary ticket metadata and filters."""
|
||||
from .models import Ticket
|
||||
from .selectors import filter_staff_tickets, staff_ticket_rows
|
||||
full = staff_ticket_rows(user)
|
||||
query = Ticket.objects.select_related("customer__customer_profile", "category", "owner__staff_profile", "selected_expert__staff_profile", "owning_unit", "assigned_unit", "group_dispatch").filter(
|
||||
Q(pk__in=full.values("pk")) | Q(pk__in=received_messages(user).values("message__ticket_id")))
|
||||
rows = list(filter_staff_tickets(query, user, params)[:100])
|
||||
full_ids = set(full.filter(pk__in=[ticket.pk for ticket in rows]).values_list("pk", flat=True))
|
||||
for ticket in rows:
|
||||
ticket.limited_share = ticket.pk not in full_ids
|
||||
return rows
|
||||
|
||||
|
||||
def limited_poll(request, ticket):
|
||||
"""Version depends exclusively on authorized messages and their active grants."""
|
||||
from .views import conversation_page
|
||||
query = visible_messages(ticket, request.user)
|
||||
grants = list(received_messages(request.user, ticket).values_list("pk", "note"))
|
||||
source = repr((list(query.order_by("pk").values_list("pk", "updated_at")), grants,
|
||||
ticket.status, ticket.priority, ticket.subject, ticket.province, ticket.city))
|
||||
version = hashlib.sha256(source.encode()).hexdigest()[:20]
|
||||
latest = query.order_by("-created_at", "-pk").first()
|
||||
cursor = str(latest.public_id) if latest else ""
|
||||
data = {"changed": False, "version": version, "cursor": cursor}
|
||||
if request.GET.get("version") != version or request.GET.get("cursor", "") != cursor:
|
||||
conversation, has_older, older_cursor = conversation_page(query.select_related("author").prefetch_related("attachments"))
|
||||
context = {"ticket": ticket, "viewer_user": request.user, "conversation": conversation,
|
||||
"has_older": has_older, "older_cursor": older_cursor,
|
||||
**message_context(ticket, request.user, conversation)}
|
||||
data.update(changed=True, replace=True, staff_limited=True, count=query.count(), status=ticket.get_status_display(),
|
||||
html=render_to_string("tickets/_staff_older_messages.html", context, request=request))
|
||||
response = JsonResponse(data)
|
||||
response["Cache-Control"] = "private, no-store"
|
||||
return response
|
||||
@@ -1,4 +1,15 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
from . import views, sharing_views
|
||||
app_name="staff_tickets"
|
||||
urlpatterns=[path("",views.staff_list,name="list"),path("customers/",views.staff_customers,name="customers"),path("customers/search/",views.staff_customer_search,name="customer_search"),path("contacts/",views.staff_contacts,name="contacts"),path("contacts/new/",views.staff_contact_create,name="contact_create"),path("contacts/<uuid:public_id>/edit/",views.staff_contact_edit,name="contact_edit"),path("contacts/<uuid:public_id>/call/",views.staff_contact_call,name="contact_call"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups/<int:group_id>/",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links/<int:link_id>/revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates/<int:template_id>/",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches/<uuid:public_id>/",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches/<uuid:public_id>/retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("dispatches/<uuid:public_id>/resolve/",views.staff_dispatch_resolve,name="dispatch_resolve"),path("experts/<uuid:public_id>/avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets/<uuid:public_id>/messages/older/",views.staff_messages,name="messages"),path("tickets/<uuid:public_id>/messages/poll/",views.staff_poll,name="poll"),path("tickets/<uuid:public_id>/sms/<int:job_id>/retry/",views.staff_sms_retry,name="sms_retry"),path("tickets/<uuid:public_id>/announcement/now/",views.staff_announcement_now,name="announcement_now"),path("tickets/<uuid:public_id>/",views.staff_detail,name="detail"),path("tickets/<uuid:public_id>/claim/",views.staff_claim,name="claim"),path("tickets/<uuid:public_id>/reply/",views.staff_reply,name="reply"),path("tickets/<uuid:public_id>/note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets/<uuid:public_id>/transfer/",views.staff_transfer,name="transfer"),path("tickets/<uuid:public_id>/share/",views.staff_share,name="share"),path("tickets/<uuid:public_id>/revoke/<uuid:user_id>/",views.staff_revoke,name="revoke"),path("tickets/<uuid:public_id>/status/",views.staff_status,name="status"),path("tickets/<uuid:public_id>/priority/",views.staff_priority,name="priority"),path("tickets/<uuid:public_id>/delete/",views.staff_delete,name="delete")]
|
||||
|
||||
urlpatterns += [
|
||||
path("shares/count/", sharing_views.unread_count, name="shares_count"),
|
||||
path("tickets/<uuid:public_id>/shares/read/", sharing_views.read_shared_messages, name="read_shares"),
|
||||
path("shares/", sharing_views.inbox, name="shares"),
|
||||
path("tickets/<uuid:public_id>/messages/<uuid:message_id>/share/", views.staff_share, name="share_message"),
|
||||
path("tickets/<uuid:public_id>/message-shares/<uuid:share_id>/revoke/", sharing_views.revoke_message, name="revoke_message_share"),
|
||||
path("shared-messages/<uuid:share_id>/", sharing_views.shared_message, name="shared_message"),
|
||||
path("shared-messages/<uuid:share_id>/attachments/<uuid:attachment_id>/", sharing_views.shared_attachment, name="shared_message_attachment"),
|
||||
path("shared-messages/<uuid:share_id>/attachments/<uuid:attachment_id>/download/", sharing_views.shared_attachment, {"download": True}, name="shared_message_attachment_download"),
|
||||
]
|
||||
+49
-23
@@ -14,6 +14,7 @@ from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_date
|
||||
from django.views.decorators.http import require_http_methods, require_POST
|
||||
from django.views.decorators.cache import never_cache
|
||||
from uuid import uuid4
|
||||
import hashlib
|
||||
import json
|
||||
@@ -30,11 +31,14 @@ 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
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from .forms import ContactGroupForm,GroupDispatchForm,ReplyForm,ShareForm,StaffContactForm,StaffCreateTicketForm,StaffReplyForm,TicketForm,TransferForm,UnitSMSTemplateForm
|
||||
from .models import GroupDispatch,Ticket,TicketCategory,TicketReadState,TicketTag
|
||||
from .forms import ContactGroupForm,GroupDispatchForm,ReplyForm,StaffContactForm,StaffCreateTicketForm,StaffReplyForm,TicketForm,TransferForm,UnitSMSTemplateForm
|
||||
from .models import GroupDispatch,MessageShare,Ticket,TicketCategory,TicketReadState,TicketTag
|
||||
from .forms import StaffMessageShareForm, StaffTicketShareForm
|
||||
from .sharing import mark_ticket_share_opened, require_share_authority, revoke_staff_share, share_staff_message, share_staff_ticket, sharing_context, staff_message_context
|
||||
from .policies import DispatchPolicy, TicketPolicy
|
||||
from .staff_conversation import limited_poll, message_context, shared_ticket_rows, visible_messages
|
||||
from .selectors import customer_tickets,dashboard_counts,filter_staff_tickets,staff_ticket_rows
|
||||
from .services import change_priority,change_status,claim_ticket,create_ticket,dispatch_group_tickets,refresh_group_dispatch_status,reply_ticket,resolve_group_dispatch_tickets,revoke_access,share_ticket,transfer_ticket
|
||||
from .services import change_priority,change_status,claim_ticket,create_ticket,dispatch_group_tickets,refresh_group_dispatch_status,reply_ticket,resolve_group_dispatch_tickets,transfer_ticket
|
||||
MESSAGE_PAGE_SIZE=50
|
||||
SMS_RETRYABLE_DELIVERY_STATUSES=(
|
||||
NotificationJob.DeliveryStatus.UNDELIVERED,
|
||||
@@ -145,7 +149,12 @@ def conversation_poll_response(request,ticket,actor,include_internal=False):
|
||||
else:
|
||||
conversation=list(messages_qs[:MESSAGE_PAGE_SIZE])
|
||||
if include_internal: mark_messages_read(conversation,actor)
|
||||
html=render_to_string("tickets/_messages.html",{"conversation":conversation,"ticket":ticket,"viewer_user":actor},request=request)
|
||||
context={"conversation":conversation,"ticket":ticket,"viewer_user":actor}
|
||||
template="tickets/_messages.html"
|
||||
if include_internal:
|
||||
context.update(message_context(ticket,actor,conversation))
|
||||
template="tickets/_staff_messages.html"
|
||||
html=render_to_string(template,context,request=request)
|
||||
response=JsonResponse({"changed":True,"version":version,"cursor":str(latest.public_id) if latest else "","replace":replace,"html":html,"count":qs.count(),"status":ticket.get_status_display()})
|
||||
response["Cache-Control"]="private, no-cache, no-store, must-revalidate"
|
||||
return response
|
||||
@@ -343,7 +352,7 @@ def customer_reply(request,public_id):
|
||||
@staff_required
|
||||
def staff_list(request):
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
qs=filter_staff_tickets(staff_ticket_rows(request.user),request.user,request.GET)
|
||||
qs=shared_ticket_rows(request.user,request.GET) if request.GET.get("tab") == "shared" else filter_staff_tickets(staff_ticket_rows(request.user),request.user,request.GET)
|
||||
tab=request.GET.get("tab","")
|
||||
tab_titles={"":"میز کار","new":"صف جدید","selected":"انتخابی من","mine":"تیکتهای من","shared":"اشتراکها","waiting_staff":"در انتظار اقدام من","waiting_customer":"در انتظار مشتری","resolved":"حلشدهها","closed":"بستهشدهها"}
|
||||
if tab not in tab_titles: tab=""
|
||||
@@ -352,7 +361,7 @@ def staff_list(request):
|
||||
selected_group_ids=[int(value) for value in request.GET.getlist("contact_group") if value.isdigit()]
|
||||
categories=TicketCategory.objects.filter(is_active=True,unit_id__in=unit_ids) if unit_ids else TicketCategory.objects.filter(is_active=True)
|
||||
experts=StaffProfile.objects.filter(is_active=True,user__unit_memberships__unit_id__in=unit_ids,user__unit_memberships__is_active=True).select_related("user").distinct() if unit_ids else StaffProfile.objects.filter(is_active=True).select_related("user")
|
||||
context={"tickets":qs[:100],"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"delivery_types":Ticket.DeliveryType.choices,"stakeholder_types":CustomerProfile.StakeholderType.choices,"categories":categories,"experts":experts,"units":OrganizationalUnit.objects.filter(pk__in=unit_ids),"contact_groups":ContactGroup.objects.filter(unit_id__in=unit_ids,is_active=True),"selected_group_ids":selected_group_ids,"ticket_tags":TicketTag.objects.filter(is_active=True),"selected_tag_ids":selected_tag_ids,"counts":dashboard_counts(request.user),"active_tab":tab,"active_tab_title":tab_titles[tab]}
|
||||
context={"tickets":qs[:100],"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"delivery_types":Ticket.DeliveryType.choices,"stakeholder_types":CustomerProfile.StakeholderType.choices,"categories":categories,"experts":experts,"units":OrganizationalUnit.objects.filter(pk__in=unit_ids),"contact_groups":ContactGroup.objects.filter(unit_id__in=unit_ids,is_active=True),"selected_group_ids":selected_group_ids,"ticket_tags":TicketTag.objects.filter(is_active=True),"selected_tag_ids":selected_tag_ids,"counts":dashboard_counts(request.user),"active_tab":tab,"active_tab_title":tab_titles[tab],"unread_message_shares":MessageShare.objects.filter(user=request.user,revoked_at__isnull=True,first_viewed_at__isnull=True,message__deleted_at__isnull=True).count()}
|
||||
return render(request,"tickets/_staff_ticket_results.html" if request.headers.get("HX-Request") else "tickets/staff_list.html",context)
|
||||
@staff_required
|
||||
def staff_create(request):
|
||||
@@ -433,15 +442,20 @@ def staff_customer_search(request):
|
||||
results.append({"id":str(customer.public_id),"mobile":customer.local_mobile,"name":str(profile) if profile else customer.local_mobile,"first_name":getattr(profile,"first_name","") or "","last_name":getattr(profile,"last_name","") or "","province":getattr(profile,"province","") or "","city":getattr(profile,"city","") or "","customer_type":profile.get_customer_type_display() if profile and profile.customer_type else "ثبت نشده","ticket_count":customer.ticket_count,"active_count":customer.active_count})
|
||||
return JsonResponse({"results":results})
|
||||
@staff_required
|
||||
@never_cache
|
||||
def staff_detail(request,public_id):
|
||||
ticket=get_object_or_404(Ticket.objects.select_related("customer__customer_profile","category","owner__staff_profile","selected_expert__staff_profile","group_dispatch__unit","group_dispatch__contact_group"),public_id=public_id)
|
||||
if not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied
|
||||
conversation,has_older,older_cursor=conversation_page(ticket.ticket_messages.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"))
|
||||
visible=visible_messages(ticket,request.user)
|
||||
conversation,has_older,older_cursor=conversation_page(visible.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"))
|
||||
mark_messages_read(conversation,request.user)
|
||||
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})
|
||||
mark_ticket_share_opened(ticket,request.user)
|
||||
message_sharing=message_context(ticket,request.user,conversation)
|
||||
if TicketPolicy.can_view(request.user,ticket):
|
||||
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(Q(message__ticket=ticket) | Q(ticket=ticket)).select_related("message").order_by("-created_at", "-pk"))
|
||||
announcement_jobs=list(AnnouncementJob.objects.filter(Q(message_id__in=visible.values("pk")) | Q(ticket=ticket)).select_related("message").order_by("-created_at", "-pk"))
|
||||
can_retry_sms=TicketPolicy.can_reply(request.user,ticket)
|
||||
can_request_announcement=request.user.account_type==User.AccountType.STAFF and can_retry_sms
|
||||
configuration=VoipConfiguration.load() if can_request_announcement else None
|
||||
@@ -452,7 +466,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,"announcement_jobs":announcement_jobs,"can_request_announcement":can_request_announcement,"announcement_ready":announcement_ready,"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":StaffTicketShareForm(actor=request.user),"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,"can_request_announcement":can_request_announcement,"announcement_ready":announcement_ready,"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 "",**message_sharing,**sharing_context(ticket,request.user)})
|
||||
|
||||
|
||||
@staff_required
|
||||
@@ -493,14 +507,14 @@ def staff_sms_retry(request,public_id,job_id):
|
||||
@staff_required
|
||||
def staff_messages(request,public_id):
|
||||
ticket=get_object_or_404(Ticket,public_id=public_id)
|
||||
if not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied
|
||||
conversation,has_older,older_cursor=conversation_page(ticket.ticket_messages.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"),request.GET.get("before"))
|
||||
conversation,has_older,older_cursor=conversation_page(visible_messages(ticket,request.user).select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"),request.GET.get("before"))
|
||||
mark_messages_read(conversation,request.user)
|
||||
return render(request,"tickets/_older_messages.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"older_route":"staff_tickets:messages","viewer_user":request.user})
|
||||
return render(request,"tickets/_staff_older_messages.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"viewer_user":request.user,**message_context(ticket,request.user,conversation)})
|
||||
@staff_required
|
||||
def staff_poll(request,public_id):
|
||||
ticket=get_object_or_404(Ticket,public_id=public_id)
|
||||
if not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied
|
||||
visible_messages(ticket,request.user)
|
||||
if not TicketPolicy.can_view(request.user,ticket): return limited_poll(request,ticket)
|
||||
return conversation_poll_response(request,ticket,request.user,include_internal=True)
|
||||
@staff_required
|
||||
@require_POST
|
||||
@@ -532,19 +546,31 @@ def staff_transfer(request,public_id):
|
||||
return redirect("staff_tickets:detail",public_id=public_id)
|
||||
@staff_required
|
||||
@require_POST
|
||||
def staff_share(request,public_id):
|
||||
ticket=get_object_or_404(Ticket,public_id=public_id); form=ShareForm(request.POST)
|
||||
if form.is_valid():
|
||||
try: share_ticket(ticket,request.user,form.cleaned_data["expert"],form.cleaned_data["permission_level"]); messages.success(request,"دسترسی ایجاد شد.")
|
||||
except PermissionDenied: messages.error(request,"اجازه اشتراک این تیکت را ندارید.")
|
||||
def staff_share(request,public_id,message_id=None):
|
||||
from .sharing_views import submit_share_form
|
||||
ticket=get_object_or_404(Ticket,public_id=public_id)
|
||||
require_share_authority(request.user,ticket)
|
||||
if message_id is not None:
|
||||
selected_message=get_object_or_404(Message,public_id=message_id,ticket=ticket)
|
||||
form=StaffMessageShareForm(request.POST,actor=request.user)
|
||||
save=lambda: share_staff_message(selected_message,request.user,form.cleaned_data["expert"],form.cleaned_data["note"])
|
||||
success="فقط همین پیام و پیوستهای آن در صندوق اشتراکهای کارشناس قرار گرفت."
|
||||
else:
|
||||
form=StaffTicketShareForm(request.POST,actor=request.user)
|
||||
save=lambda: share_staff_ticket(ticket,request.user,form.cleaned_data["expert"],form.cleaned_data["permission_level"],form.cleaned_data["note"])
|
||||
success="اشتراک تیکت ثبت شد؛ وضعیت مشاهده در سوابق قابل پیگیری است."
|
||||
submit_share_form(request,form,save,success)
|
||||
return redirect("staff_tickets:detail",public_id=public_id)
|
||||
|
||||
@staff_required
|
||||
@require_POST
|
||||
def staff_revoke(request,public_id,user_id):
|
||||
ticket=get_object_or_404(Ticket,public_id=public_id); target=get_object_or_404(User,public_id=user_id)
|
||||
try: revoke_access(ticket,request.user,target); messages.success(request,"دسترسی کارشناس لغو شد.")
|
||||
except PermissionDenied: messages.error(request,"اجازه لغو این دسترسی را ندارید.")
|
||||
ticket=get_object_or_404(Ticket,public_id=public_id)
|
||||
require_share_authority(request.user,ticket)
|
||||
grant=ticket.access_grants.filter(user__public_id=user_id,revoked_at__isnull=True).first()
|
||||
if grant:
|
||||
revoke_staff_share(grant,request.user)
|
||||
messages.success(request,"دسترسی اشتراکی لغو شد؛ مجوزهای مستقل کارشناس تغییر نمیکنند.")
|
||||
return redirect("staff_tickets:detail",public_id=public_id)
|
||||
|
||||
@staff_required
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# اشتراک در پنل کارشناسان
|
||||
|
||||
اشتراک کل تیکت و پیامهای تکی در همان صف `/staff/?tab=shared` قرار دارند. هر تیکت یک ردیف دارد، حتی اگر چند پیام آن اشتراک گذاشته شده باشد. دکمه و قالب صندوق مستقل حذف شدهاند؛ `/staff/shares/` و لینک قدیمی پیام به مسیر یکپارچه هدایت میشوند.
|
||||
|
||||
## مرز دسترسی
|
||||
|
||||
`TicketPolicy.can_view` و مجوزهای سازمانی مشاهده کل مکاتبات تغییر نکردهاند. `MessageShare` مستقل از `TicketAccessGrant` است. دریافتکننده پیام، عنوان اصلی تیکت، شماره، وضعیت، اولویت، دسته، مالک، تاریخها، نام و شماره مشتری، استان و شهر و ستونهای اطلاعات دو طرف را در همان قالب معمول staff میبیند. ردیف صف اشتراکها و فیلترهای آن نیز از مشخصات عادی تیکت استفاده میکنند. اطلاعات مشتری و تیکت محدود نیستند؛ محدودیت فقط روی محتوای پیامهای اشتراکگذاشتهنشده و پیوستهای آنهاست. جستوجوی متن، فقط پیامهای اشتراکی مجاز را بررسی میکند. پیوست پیام از مسیر مخصوص همان اشتراک دریافت میشود؛ مسیر عمومی پیوست همچنان نیازمند دسترسی کل مکاتبات است.
|
||||
|
||||
اگر کارشناس مجوز مستقلی برای کل تیکت داشته باشد، اشتراک یک پیام آن را محدود نمیکند. لغو اشتراک نیز مجوز مستقل را حذف نمیکند. حذف پیام یا لغو اشتراک، دسترسی بعدی را میبندد. polling محدود فقط از پیامها و اشتراکهای مجاز نسخه میسازد و هنگام لغو، نمای پیامها را جایگزین میکند. فایل دانلودشده قابل پسگرفتن نیست؛ لینک امضاشده Nginx مطابق مهلت موجود تحویل فایل اعتبار دارد.
|
||||
|
||||
## قابلیتها و رابط
|
||||
|
||||
تمام کارشناسان دارای حساب و پروفایل فعال، بدون محدودیت نقش یا permission اشتراک/انتقال، میتوانند روی تیکتهای دارای دسترسی کامل، اشتراک پیام و کل تیکت، انتقال، تغییر وضعیت و اولویت انجام دهند. دسترسی محدود به یک پیام، مجوز عملیات روی کل پرونده ایجاد نمیکند. مجوز حذف دائمی، پاسخ، یادداشت، ارسال گروهی و دسترسی به مخاطبان مطابق سیاستهای قبلی باقی میماند.
|
||||
|
||||
کنترل پیام فقط آیکون اشتراک است؛ گیرندگان، وضعیت مشاهده، لغو و سوابق در جزئیات جمعشونده همان پیام قرار دارند. دریافتکننده کنار پیام نام فرستنده اشتراک و زمان آن را ظریف میبیند؛ توضیح اختیاری با آیکون کوچک باز میشود.
|
||||
|
||||
## شمارنده و وضعیت مشاهده
|
||||
|
||||
نشان نارنجی منوی «اشتراکها» تعداد اشتراکهای فعال خواندهنشده تیکت و پیام را نمایش میدهد. ورود به فهرست یا هدایت لینک قدیمی، مشاهده محسوب نمیشود. برای پیام، اسکریپت اختصاصی staff فقط شناسه پیامهای داخل محدوده قابل مشاهده گفتگو را با POST و CSRF ارسال میکند؛ سرور دوباره مجوز را بررسی میکند. تأیید پیام دیگر یا شناسه نامعتبر، شمارنده را تغییر نمیدهد. پیام قدیمی تا هنگام نمایش در صفحه خواندهنشده میماند. اعلان شمارنده در صفحات staff نیز دورهای تازه میشود.
|
||||
|
||||
اولین مشاهده پیام یک رویداد پایدار ایجاد میکند؛ تأییدهای دورهای رویداد یا شمارنده مشاهده تکراری نمیسازند. مشاهده به معنای نمایش است، نه بررسی یا اقدام کارشناس. مشاهده کل تیکت، مانند قبل با بازشدن صفحه ثبت میشود. اعطای مجدد پس از لغو، سابقه مستقل دارد.
|
||||
|
||||
## دامنه و بررسی
|
||||
|
||||
قالبهای مشتری، `_messages.html`، کد ورود، مسیرهای عمومی پیوست و رفتار مشتری در JavaScript گفتگو تغییر نکردهاند. در `base.html` فقط شاخه staff توسعه یافته است. فایلهای CSS و JavaScript جدید نسخه `static_asset_version` دارند. در JavaScript گفتگو دو شرط مخصوص پاسخ محدود staff، دکمه صفحهبندی قدیمی را تازه میکنند؛ شاخه مشتری تحت تأثیر نیست. schema شماره 0017 افزایشی است؛ برای این یکپارچهسازی migration تازهای لازم نیست.
|
||||
|
||||
تستهای `test_staff_sharing.py`، `test_staff_inline_sharing.py` و `test_staff_shared_queue.py` جداسازی داده، شمارنده، لغو، پیوست و نقشها را بررسی میکنند. سناریوی مرورگر در `tests/e2e/test_staff_sharing_e2e.py` دسکتاپ و موبایل را پوشش میدهد. مجموعههای pytest را بهدلیل دیتابیس آزمایشی مشترک، همزمان اجرا نکنید. از `.venv/Scripts/python.exe` در WSL استفاده کنید.
|
||||
|
||||
صفحه staff به migrationهای `notifications.0015`، `notifications.0016` و `core.0008` نیز وابسته است؛ migration تیکت بهتنهایی جدول اعلانها را ایجاد نمیکند.
|
||||
@@ -0,0 +1,254 @@
|
||||
/* Staff-only sharing controls; native details also work without JavaScript. */
|
||||
.staff-share-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 16px;
|
||||
}
|
||||
.staff-share-chevron {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex: 0 0 6px;
|
||||
margin-inline-start: auto;
|
||||
border-inline-end: 1.5px solid currentColor;
|
||||
border-bottom: 1.5px solid currentColor;
|
||||
transform: rotate(45deg);
|
||||
opacity: .65;
|
||||
}
|
||||
.staff-message-share[open] > summary .staff-share-chevron,
|
||||
.staff-ticket-share[open] > summary .staff-share-chevron,
|
||||
.staff-sharing-audit[open] > summary .staff-share-chevron,
|
||||
.staff-share-group[open] > summary .staff-share-chevron,
|
||||
.share-audit-row[open] > summary .staff-share-chevron {
|
||||
transform: rotate(225deg);
|
||||
}
|
||||
.staff-message-share > summary,
|
||||
.staff-ticket-share > summary,
|
||||
.staff-sharing-audit summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
color: var(--staff-text, #123a63);
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.staff-message-share > summary::-webkit-details-marker,
|
||||
.staff-ticket-share > summary::-webkit-details-marker,
|
||||
.staff-sharing-audit summary::-webkit-details-marker { display: none; }
|
||||
.staff-message-share > summary:focus-visible,
|
||||
.staff-ticket-share > summary:focus-visible,
|
||||
.staff-sharing-audit summary:focus-visible,
|
||||
.staff-sharing-link:focus-visible {
|
||||
outline: 2px solid var(--color-sea, #0693e3);
|
||||
outline-offset: 3px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.staff-message-share { margin-top: 10px; min-width: 0; }
|
||||
.staff-message-share > summary {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid #cbd8e6;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
font-size: .6875rem;
|
||||
}
|
||||
.staff-message-share[open] > summary,
|
||||
.staff-message-share > summary:hover { background: #f0f6fc; border-color: #98b8d7; }
|
||||
.staff-share-content {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid #dce5ee;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: var(--staff-text, #123a63);
|
||||
min-width: 0;
|
||||
}
|
||||
.staff-message-share .staff-share-hint,
|
||||
.staff-ticket-share .staff-share-hint,
|
||||
.staff-sharing-audit .staff-share-hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: .6875rem;
|
||||
line-height: 1.9;
|
||||
color: var(--staff-muted, #526b85);
|
||||
white-space: normal;
|
||||
}
|
||||
.staff-share-content .staff-share-form {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.staff-share-form label { font-size: .75rem; font-weight: 600; margin: 3px 0 0; }
|
||||
.staff-share-form select,
|
||||
.staff-share-form textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
border: 1px solid #cbd8e6;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--staff-text, #123a63);
|
||||
font-size: .75rem;
|
||||
line-height: 1.8;
|
||||
padding: 7px 9px;
|
||||
}
|
||||
.staff-share-form textarea { resize: vertical; min-height: 64px; }
|
||||
.staff-share-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 5px; }
|
||||
.staff-share-footer small { color: var(--staff-muted, #526b85); font-size: .625rem; }
|
||||
.staff-share-form .staff-share-submit {
|
||||
width: auto;
|
||||
margin-inline-start: auto;
|
||||
padding: 7px 11px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-ink, #08376b);
|
||||
color: #fff;
|
||||
font-size: .6875rem;
|
||||
line-height: 1.8;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ticket-operations .staff-ticket-share { padding: 0; border: 0; border-radius: 0; margin-top: 12px; }
|
||||
.staff-ticket-share > summary { min-height: 36px; padding: 8px 10px; border: 1px solid #d2dfeb; border-radius: 9px; background: #f6f9fc; }
|
||||
.staff-ticket-share > summary:hover { background: #eef4fa; }
|
||||
.workspace-side .staff-sharing-audit { padding: 0; overflow: hidden; }
|
||||
.staff-sharing-audit > .staff-sharing-audit-toggle { padding: 12px; min-height: 54px; }
|
||||
.staff-sharing-audit-toggle > span:not(.staff-share-chevron) { display: grid; gap: 3px; }
|
||||
.staff-sharing-audit-toggle strong { font-size: .8125rem; }
|
||||
.staff-sharing-audit-toggle small { font-size: .625rem; color: var(--staff-muted, #526b85); font-weight: 400; }
|
||||
.staff-sharing-audit > summary:hover { background: #f6f9fc; }
|
||||
.staff-sharing-audit-content { padding: 0 12px 12px; }
|
||||
.staff-share-group { margin-top: 8px; border-top: 1px solid #e4ebf2; }
|
||||
.staff-share-group > summary { min-height: 40px; padding: 8px 0; }
|
||||
.staff-share-count { border-radius: 6px; background: #eef3f8; color: #526b85; padding: 1px 6px; font-size: .625rem; font-weight: 500; }
|
||||
.share-audit-row { border: 1px solid #dce5ee; border-radius: 9px; margin-top: 7px; overflow-wrap: anywhere; }
|
||||
.share-audit-row > summary { padding: 9px; gap: 6px; font-weight: 400; }
|
||||
.staff-share-person { display: grid; gap: 3px; min-width: 0; flex: 1; }
|
||||
.staff-share-person b { font-size: .6875rem; font-weight: 600; }
|
||||
.staff-share-person small { font-size: .625rem; color: var(--staff-muted, #526b85); }
|
||||
.staff-share-status { display: inline-flex; align-items: center; gap: 4px; padding: 3px 6px; border-radius: 6px; font-size: .5625rem; line-height: 1.6; max-width: 94px; flex-shrink: 0; }
|
||||
.staff-share-status::before { content: ''; width: 4px; height: 4px; border-radius: 50%; background: currentColor; flex-shrink: 0; }
|
||||
.staff-share-status.is-seen { color: #176548; background: #edf7f1; }
|
||||
.staff-share-status.is-pending { color: #805b17; background: #fff7e8; }
|
||||
.staff-share-status.is-revoked { color: #637287; background: #f0f2f5; }
|
||||
.staff-share-row-content { display: grid; gap: 7px; padding: 10px; border-top: 1px solid #e4ebf2; }
|
||||
.staff-share-row-content small,
|
||||
.share-event-row small { font-size: .625rem; line-height: 1.9; color: var(--staff-muted, #526b85); }
|
||||
.staff-share-row-content p,
|
||||
.share-event-row p { font-size: .6875rem; line-height: 1.9; white-space: pre-wrap; }
|
||||
.staff-share-preview { color: var(--staff-text, #123a63); }
|
||||
.staff-share-note { padding: 8px; background: #f5f8fb; border-radius: 7px; }
|
||||
.staff-share-reference { overflow-wrap: anywhere; font-size: .5625rem !important; }
|
||||
.staff-share-row-content .staff-share-revoke {
|
||||
padding: 5px 9px;
|
||||
border: 1px solid #ead5d5;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
color: #9c3636;
|
||||
font-size: .625rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.staff-share-row-content .staff-share-revoke:hover { background: #fff4f4; border-color: #cf9898; }
|
||||
.staff-sharing-audit .staff-share-empty { margin: 4px 0 8px; font-size: .6875rem; color: var(--staff-muted, #526b85); }
|
||||
.share-event-row { display: grid; gap: 4px; padding: 8px 10px; margin: 8px 0; border-inline-start: 2px solid #d2dfeb; overflow-wrap: anywhere; }
|
||||
.share-event-row b,
|
||||
.share-event-row > span { font-size: .6875rem; }
|
||||
.staff-sharing-link { display: inline-flex; align-items: center; gap: 7px; padding: 8px 10px; margin-bottom: 12px; border-radius: 8px; color: var(--staff-text, #123a63); font-size: .75rem; }
|
||||
.staff-sharing-link:hover { background: #fff; }
|
||||
.staff-sharing-page { max-width: 960px; margin: 0 auto; display: grid; gap: 20px; }
|
||||
.staff-sharing-page > header { display: grid; gap: 8px; }
|
||||
.staff-sharing-page h1 { font-size: 1.7rem; font-weight: 800; }
|
||||
.staff-sharing-page h2 { font-weight: 700; margin-bottom: 16px; }
|
||||
.share-inbox-row { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 16px 0; border-bottom: 1px solid #dce5ee; }
|
||||
.share-inbox-row p { margin: 6px 0; white-space: pre-wrap; }
|
||||
.share-inbox-row small { color: #52677d; }
|
||||
.staff-sharing-page .button { display: inline-flex; justify-content: center; padding: 8px 12px; border-radius: 9px; font-size: .75rem; flex-shrink: 0; }
|
||||
.isolated-shared-message > header { display: flex; justify-content: space-between; gap: 12px; }
|
||||
.isolated-shared-message > p { margin: 18px 0; line-height: 2; overflow-wrap: anywhere; }
|
||||
.isolated-shared-message img { max-width: 100%; height: auto; }
|
||||
@media (max-width: 640px) {
|
||||
.share-inbox-row { align-items: stretch; flex-direction: column; }
|
||||
.isolated-shared-message > header { flex-direction: column; }
|
||||
.staff-sharing-page { gap: 14px; }
|
||||
.staff-message-share > summary { min-height: 40px; }
|
||||
.staff-share-form .staff-share-submit,
|
||||
.staff-share-row-content .staff-share-revoke { min-height: 40px; }
|
||||
}
|
||||
|
||||
/* One compact footer and one icon; management opens only for this message. */
|
||||
.staff-message-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 6px; }
|
||||
.staff-message-footer:has(.message-receipts) { border-top: 1px solid #e4ebf2; padding-top: 4px; }
|
||||
.staff-message-footer .message-receipts { flex: 1; min-width: 0; margin: 0 !important; padding: 0; border: 0; }
|
||||
.message-bubble:has(.message-menu) .staff-message-footer { padding-inline-end: 24px; }
|
||||
.staff-message-footer .staff-message-share { margin: 0; }
|
||||
.staff-message-share[open] { flex-basis: 100%; min-width: 0; }
|
||||
.staff-message-share > summary {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.staff-message-share > summary .staff-share-icon { width: 15px; height: 15px; flex-basis: 15px; }
|
||||
.staff-share-dot { position: absolute; top: 3px; inset-inline-end: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--color-sea, #0693e3); }
|
||||
.staff-message-share .staff-share-content { max-width: 360px; padding: 10px; margin-top: 5px; }
|
||||
.staff-inline-share-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px; margin-bottom: 8px; }
|
||||
.staff-inline-share-heading b { font-size: .75rem; }
|
||||
.staff-inline-share-heading small { font-size: .625rem; color: var(--staff-muted, #526b85); }
|
||||
.staff-message-share .share-audit-row { margin-top: 4px; }
|
||||
.staff-message-share .share-audit-row > summary,
|
||||
.staff-inline-share-new > summary,
|
||||
.staff-inline-share-history > summary,
|
||||
.staff-share-note-field > summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
color: var(--staff-text, #123a63);
|
||||
font-size: .6875rem;
|
||||
}
|
||||
.staff-message-share .share-audit-row > summary { padding: 6px 8px; min-height: 32px; }
|
||||
.staff-message-share .share-audit-row > summary::-webkit-details-marker,
|
||||
.staff-inline-share-new > summary::-webkit-details-marker,
|
||||
.staff-inline-share-history > summary::-webkit-details-marker,
|
||||
.staff-share-note-field > summary::-webkit-details-marker { display: none; }
|
||||
.staff-inline-share-new,
|
||||
.staff-inline-share-history { margin-top: 8px; padding-top: 5px; border-top: 1px solid #e4ebf2; }
|
||||
.staff-inline-share-new > summary,
|
||||
.staff-inline-share-history > summary,
|
||||
.staff-share-note-field > summary { min-height: 28px; }
|
||||
.staff-share-note-field label { display: block; margin-bottom: 4px; }
|
||||
.staff-message-share .staff-share-footer { margin-top: 0; }
|
||||
.staff-message-share .staff-share-submit { padding: 5px 12px; min-height: 28px; }
|
||||
.staff-message-share .staff-share-row-content { padding: 8px; gap: 4px; }
|
||||
.staff-message-share .staff-share-row-content p { margin: 0; }
|
||||
.staff-inline-share-new[open] > summary .staff-share-chevron,
|
||||
.staff-inline-share-history[open] > summary .staff-share-chevron,
|
||||
.staff-share-note-field[open] > summary .staff-share-chevron { transform: rotate(225deg); }
|
||||
.staff-message-share summary:focus-visible { outline: 2px solid var(--color-sea, #0693e3); outline-offset: 2px; border-radius: 7px; }
|
||||
@media (max-width: 640px) {
|
||||
.staff-message-share > summary { width: 32px; height: 32px; min-height: 32px; }
|
||||
.staff-message-share .staff-share-submit { min-height: 32px; }
|
||||
.staff-message-share .staff-share-row-content .staff-share-revoke { min-height: 32px; }
|
||||
}
|
||||
|
||||
.staff-menu [data-share-unread-count], .staff-mobile-dock [data-share-unread-count] {
|
||||
display: inline-flex; align-items: center; justify-content: center; min-width: 20px;
|
||||
height: 20px; border-radius: 7px; padding: 0 5px; background: #f59e0b;
|
||||
color: #08376b; font-size: 11px; font-weight: 800; font-style: normal;
|
||||
}
|
||||
.staff-menu [data-share-unread-count] { margin-inline-start: auto; }
|
||||
.staff-mobile-dock a { position: relative; }
|
||||
.staff-mobile-dock [data-share-unread-count] { position: absolute; top: 2px; inset-inline-end: 3px; min-width: 16px; height: 16px; font-size: 9px; }
|
||||
[data-share-unread-count][hidden] { display: none; }
|
||||
.received-share-attribution { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin-top: 8px; color: #64748b; font-size: 10px; }
|
||||
.received-share-attribution svg { width: 12px; height: 12px; }
|
||||
.received-share-attribution details { max-width: 100%; }
|
||||
.received-share-attribution summary { cursor: pointer; }
|
||||
@@ -115,9 +115,11 @@ document.querySelectorAll('[data-conversation-workspace]').forEach(workspace=>{
|
||||
if(!data.changed)return;
|
||||
const wasAtBottom=atBottom();
|
||||
const fragment=document.createRange().createContextualFragment(data.html||'');
|
||||
if(data.staff_limited)scroller.querySelectorAll('[data-load-older]').forEach(item=>item.remove());
|
||||
if(data.replace)scroller.querySelectorAll('[data-message],.date-divider,.conversation-empty').forEach(item=>item.remove());
|
||||
else scroller.querySelectorAll('.conversation-empty').forEach(item=>item.remove());
|
||||
scroller.insertBefore(fragment,conversationEnd);
|
||||
if(data.staff_limited)window.htmx?.process(scroller);
|
||||
workspace.dataset.pollVersion=data.version||'';workspace.dataset.pollCursor=data.cursor||'';
|
||||
const count=workspace.querySelector('[data-conversation-count]');if(count)count.textContent=String(data.count??'');
|
||||
workspace.querySelectorAll('[data-ticket-status]').forEach(item=>item.textContent=data.status||item.textContent);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
(() => {
|
||||
const badges = document.querySelectorAll('[data-share-unread-count]');
|
||||
if (!badges.length) return;
|
||||
const workspace = document.querySelector('[data-share-read-url]');
|
||||
const scroller = workspace?.querySelector('[data-conversation]');
|
||||
const countUrl = badges[0].dataset.countUrl;
|
||||
let running = false;
|
||||
const cookie = name => document.cookie.split('; ').find(row => row.startsWith(`${name}=`))?.split('=').slice(1).join('=') || '';
|
||||
const refresh = async () => {
|
||||
if (running || document.visibilityState !== 'visible') return;
|
||||
running = true;
|
||||
try {
|
||||
const ids = [];
|
||||
if (scroller) {
|
||||
const bounds = scroller.getBoundingClientRect();
|
||||
scroller.querySelectorAll('[data-message-id]').forEach(message => {
|
||||
const box = message.getBoundingClientRect();
|
||||
if (box.bottom > Math.max(bounds.top, 0) && box.top < Math.min(bounds.bottom, innerHeight)) ids.push(message.dataset.messageId);
|
||||
});
|
||||
}
|
||||
const body = new URLSearchParams();
|
||||
ids.forEach(id => body.append('message_ids', id));
|
||||
const response = await fetch(workspace?.dataset.shareReadUrl || countUrl, workspace ? {
|
||||
method: 'POST', body, headers: {'X-CSRFToken': cookie('csrftoken')}, cache: 'no-store', credentials: 'same-origin'
|
||||
} : {cache: 'no-store', credentials: 'same-origin'});
|
||||
if (response.status === 403 && workspace) {
|
||||
scroller.querySelectorAll('[data-message], .date-divider, .conversation-empty').forEach(item => item.remove());
|
||||
const notice = document.createElement('p');
|
||||
notice.className = 'conversation-empty';
|
||||
notice.textContent = 'دسترسی به این اشتراک لغو شده است.';
|
||||
scroller.append(notice);
|
||||
workspace.removeAttribute('data-poll-url');
|
||||
const counts = await fetch(countUrl, {cache: 'no-store', credentials: 'same-origin'});
|
||||
if (counts.ok) {
|
||||
const data = await counts.json();
|
||||
badges.forEach(badge => {badge.textContent = data.unread_shares; badge.hidden = !data.unread_shares;});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
badges.forEach(badge => {badge.textContent = data.unread_shares; badge.hidden = !data.unread_shares;});
|
||||
} catch (_) {
|
||||
// A failed request leaves the count and read status available for retry.
|
||||
} finally { running = false; }
|
||||
};
|
||||
scroller?.addEventListener('scroll', refresh, {passive: true});
|
||||
document.addEventListener('visibilitychange', refresh);
|
||||
window.addEventListener('pageshow', refresh);
|
||||
window.addEventListener('focus', refresh);
|
||||
document.body.addEventListener('htmx:afterSwap', refresh);
|
||||
setInterval(refresh, workspace ? 3000 : 15000);
|
||||
refresh();
|
||||
})();
|
||||
+3
-2
@@ -29,7 +29,7 @@
|
||||
<a class="{% if request.resolver_match.url_name == 'list' and active_tab == 'new' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=new" {% if request.resolver_match.url_name == 'list' and active_tab == 'new' %}aria-current="page"{% endif %}><span>◌</span><b>صف جدید</b></a>
|
||||
<a class="staff-action-link {% if request.resolver_match.url_name == 'list' and active_tab == 'waiting_staff' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=waiting_staff" {% if request.resolver_match.url_name == 'list' and active_tab == 'waiting_staff' %}aria-current="page"{% endif %}><span>↺</span><b>در انتظار پاسخ من</b>{% if counts.waiting %}<em>{{ counts.waiting }}</em>{% endif %}</a>
|
||||
<a class="{% if request.resolver_match.url_name == 'list' and active_tab == 'mine' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=mine" {% if request.resolver_match.url_name == 'list' and active_tab == 'mine' %}aria-current="page"{% endif %}><span>◎</span><b>تیکتهای من</b></a>
|
||||
<a class="{% if request.resolver_match.url_name == 'list' and active_tab == 'shared' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=shared" {% if request.resolver_match.url_name == 'list' and active_tab == 'shared' %}aria-current="page"{% endif %}><span>◇</span><b>اشتراکها</b></a>
|
||||
<a class="{% if request.resolver_match.url_name == 'list' and active_tab == 'shared' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=shared" {% if request.resolver_match.url_name == 'list' and active_tab == 'shared' %}aria-current="page"{% endif %}><span>◇</span><b>اشتراکها</b><em data-share-unread-count data-count-url="{% url 'staff_tickets:shares_count' %}"{% if not staff_unread_shares %} hidden{% endif %} aria-label="اشتراک خواندهنشده">{{ staff_unread_shares }}</em></a>
|
||||
{% if staff_operations_access.contacts %}<a class="{% if active_staff_section == 'contacts' %}active{% endif %}" href="{% url 'staff_tickets:contacts' %}" {% if active_staff_section == 'contacts' %}aria-current="page"{% endif %}><span>♧</span><b>مخاطبان</b></a>{% endif %}
|
||||
{% if staff_operations_access.dispatches %}<a class="{% if active_staff_section == 'dispatches' %}active{% endif %}" href="{% url 'staff_tickets:dispatch_create' %}" {% if active_staff_section == 'dispatches' %}aria-current="page"{% endif %}><span>⇶</span><b>ارسال گروهی</b></a>{% endif %}
|
||||
{% if staff_operations_access.audit %}<a class="{% if active_staff_section == 'audit' %}active{% endif %}" href="{% url 'staff_tickets:magic_audit' %}" {% if active_staff_section == 'audit' %}aria-current="page"{% endif %}><span>⌁</span><b>ممیزی لینکها</b></a>{% endif %}
|
||||
@@ -53,7 +53,7 @@
|
||||
<a class="{% if request.resolver_match.url_name == 'list' and active_tab == 'new' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=new" {% if request.resolver_match.url_name == 'list' and active_tab == 'new' %}aria-current="page"{% endif %}><span class="dock-icon">◌</span>صف جدید</a>
|
||||
<a class="{% if request.resolver_match.url_name == 'list' and active_tab == 'waiting_staff' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=waiting_staff" {% if request.resolver_match.url_name == 'list' and active_tab == 'waiting_staff' %}aria-current="page"{% endif %}><span class="dock-icon">↺</span>پاسخ من</a>
|
||||
<a class="{% if request.resolver_match.url_name == 'list' and active_tab == 'mine' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=mine" {% if request.resolver_match.url_name == 'list' and active_tab == 'mine' %}aria-current="page"{% endif %}><span class="dock-icon">◎</span>تیکتهای من</a>
|
||||
<a class="{% if request.resolver_match.url_name == 'list' and active_tab == 'shared' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=shared" {% if request.resolver_match.url_name == 'list' and active_tab == 'shared' %}aria-current="page"{% endif %}><span class="dock-icon">◇</span>اشتراکها</a>
|
||||
<a class="{% if request.resolver_match.url_name == 'list' and active_tab == 'shared' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=shared" {% if request.resolver_match.url_name == 'list' and active_tab == 'shared' %}aria-current="page"{% endif %}><span class="dock-icon">◇</span>اشتراکها<em data-share-unread-count data-count-url="{% url 'staff_tickets:shares_count' %}"{% if not staff_unread_shares %} hidden{% endif %}>{{ staff_unread_shares }}</em></a>
|
||||
{% if staff_operations_access.contacts %}<a class="{% if active_staff_section == 'contacts' %}active{% endif %}" href="{% url 'staff_tickets:contacts' %}" {% if active_staff_section == 'contacts' %}aria-current="page"{% endif %}><span class="dock-icon">♧</span>مخاطبان</a>{% endif %}
|
||||
{% if staff_operations_access.dispatches %}<a class="{% if active_staff_section == 'dispatches' %}active{% endif %}" href="{% url 'staff_tickets:dispatch_create' %}" {% if active_staff_section == 'dispatches' %}aria-current="page"{% endif %}><span class="dock-icon">⇶</span>گروهی</a>{% endif %}
|
||||
</nav>
|
||||
@@ -64,6 +64,7 @@
|
||||
{% block pwa_install_prompt %}{% endblock %}
|
||||
<script src="{% static 'js/voice-recorder.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/ui.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/chat-coach.js' %}?v={{ static_asset_version }}"></script>
|
||||
{% if user.is_authenticated %}<script defer src="{% static 'js/pwa.js' %}?v={{ static_asset_version }}"></script>{% endif %}
|
||||
{% if user.is_authenticated and user.account_type == 'STAFF' %}<link rel="stylesheet" href="{% static 'css/staff-sharing.css' %}?v={{ static_asset_version }}"><script defer src="{% static 'js/staff-sharing.js' %}?v={{ static_asset_version }}"></script>{% endif %}
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
{% block otp_script %}{% endblock %}
|
||||
</body></html>
|
||||
@@ -0,0 +1,18 @@
|
||||
{% if can_share_message and not item.deleted_at %}
|
||||
<details class="staff-message-share" data-share-message="{{ item.public_id }}">
|
||||
<summary aria-label="اشتراک فقط این پیام؛ گیرندگان و سوابق" title="اشتراک و پیگیری این پیام">{% include 'tickets/_staff_share_icon.html' %}{% if item.staff_message_shares %}<span class="staff-share-dot" aria-hidden="true"></span>{% endif %}</summary>
|
||||
<div class="staff-share-content">
|
||||
<header class="staff-inline-share-heading"><b>اشتراک پیام</b><small>فقط همین پیام و پیوستها</small></header>
|
||||
{% if item.staff_message_shares %}
|
||||
<div class="staff-inline-share-grants">{% for grant in item.staff_message_shares %}{% include 'tickets/_staff_share_audit_row.html' with is_message=True inline=True %}{% endfor %}</div>
|
||||
<details class="staff-inline-share-new"><summary>+ اشتراک جدید<span class="staff-share-chevron" aria-hidden="true"></span></summary>{% include 'tickets/_staff_message_share_form.html' %}</details>
|
||||
{% else %}{% include 'tickets/_staff_message_share_form.html' %}{% endif %}
|
||||
{% if item.staff_share_events %}
|
||||
{% load jalali %}
|
||||
<details class="staff-inline-share-history"><summary>سوابق<span class="staff-share-count">{{ item.staff_share_events|length }}</span><span class="staff-share-chevron" aria-hidden="true"></span></summary>
|
||||
{% for event in item.staff_share_events %}<article class="share-event-row"><b>{{ event.get_event_type_display }}</b><small>{{ event.actor_name }} · {{ event.created_at|jalali_datetime }}</small>{% if event.recipient_name %}<small>گیرنده: {{ event.recipient_name }}</small>{% endif %}{% if event.metadata.action == 'updated' %}<small>توضیح بهروزرسانی شد</small>{% endif %}{% if event.metadata.note %}<p>{{ event.metadata.note }}</p>{% endif %}</article>{% endfor %}
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,7 @@
|
||||
<form class="staff-share-form" method="post" action="{% url 'staff_tickets:share_message' ticket.public_id item.public_id %}">
|
||||
{% csrf_token %}
|
||||
<label for="share-expert-{{ item.pk }}">کارشناس گیرنده</label>
|
||||
<select id="share-expert-{{ item.pk }}" name="expert" required><option value="">انتخاب کارشناس</option>{% for expert in share_experts %}<option value="{{ expert.pk }}">{{ expert }}{% if expert.primary_unit %} — {{ expert.primary_unit }}{% endif %}</option>{% endfor %}</select>
|
||||
<details class="staff-share-note-field"><summary>افزودن توضیح<span class="staff-share-chevron" aria-hidden="true"></span></summary><label for="share-note-{{ item.pk }}">توضیح برای گیرنده</label><textarea id="share-note-{{ item.pk }}" name="note" maxlength="500" rows="2" placeholder="مثلاً: لطفاً این رسید را بررسی کنید"></textarea></details>
|
||||
<div class="staff-share-footer"><button class="staff-share-submit" type="submit" aria-label="اشتراک پیام و پیوستهایش">اشتراک</button></div>
|
||||
</form>
|
||||
@@ -0,0 +1,26 @@
|
||||
{% load jalali %}{% for item in conversation %}
|
||||
<article class="message {% if item.is_internal %}note{% elif item.author == viewer_user %}mine{% endif %}" data-message data-message-id="{{ item.public_id }}" data-date="{{ item.created_at|date:'Y-m-d' }}" data-day-label="{{ item.created_at|jalali_date }}"{% if item.author == viewer_user and not item.is_internal and not item.deleted_at %} data-chat-coach-own data-chat-coach-at="{{ item.created_at|date:'U' }}"{% endif %}>
|
||||
<div class="message-avatar" aria-hidden="true">{{ item.author_name|slice:':1' }}</div>
|
||||
<div class="message-bubble">
|
||||
<header><b>{{ item.author_name }}</b>{% if item.is_internal %}<span class="internal-label">🔒 یادداشت داخلی</span>{% endif %}<time title="{{ item.created_at|jalali_datetime }}">{{ item.created_at|persian_time }}</time></header>
|
||||
{% if item.deleted_at and not perms.messaging.view_deleted_message_content %}
|
||||
<p class="deleted-message">این پیام توسط فرستنده حذف شده است.</p>
|
||||
{% else %}
|
||||
{% if item.body %}<p>{{ item.body|linebreaksbr }}</p>{% endif %}
|
||||
{% if item.deleted_at %}<small class="danger">حذفشده در {{ item.deleted_at|jalali_datetime }}</small>{% endif %}
|
||||
{% if item.edited_at %}<span class="edited-label">ویرایش شده</span>{% if perms.messaging.view_message_revisions and not limited_share %}<a class="revision-link" href="{% url 'messages:revisions' item.public_id %}">تاریخچه</a>{% endif %}{% endif %}
|
||||
{% if item.attachments.all %}<div class="attachment-list">{% for file in item.attachments.all %}
|
||||
{% if file.attachment_type == 'IMAGE' %}<div class="attachment attachment-image"><button type="button" data-image-open data-image-src="{% if limited_share %}{% url 'staff_tickets:shared_message_attachment' item.received_share.public_id file.public_id %}{% else %}{% url 'attachments:view' file.public_id %}{% endif %}" data-image-name="{{ file.original_name }}"><img src="{% if limited_share %}{% url 'staff_tickets:shared_message_attachment' item.received_share.public_id file.public_id %}{% else %}{% url 'attachments:view' file.public_id %}{% endif %}" alt="{{ file.original_name }}"><span>{{ file.original_name }} · {{ file.file_size|filesizeformat }}</span></button><a class="attachment-download" href="{% if limited_share %}{% url 'staff_tickets:shared_message_attachment_download' item.received_share.public_id file.public_id %}{% else %}{% url 'attachments:download' file.public_id %}{% endif %}" download>⇩ دانلود تصویر</a></div>
|
||||
{% elif file.attachment_type == 'VOICE' %}<div class="attachment attachment-audio"><span>🎙 پیام صوتی</span><audio controls preload="metadata" src="{% if limited_share %}{% url 'staff_tickets:shared_message_attachment' item.received_share.public_id file.public_id %}{% else %}{% url 'attachments:view' file.public_id %}{% endif %}"></audio><a href="{% if limited_share %}{% url 'staff_tickets:shared_message_attachment_download' item.received_share.public_id file.public_id %}{% else %}{% url 'attachments:download' file.public_id %}{% endif %}">دریافت فایل</a></div>
|
||||
{% else %}<a class="attachment attachment-file" href="{% if limited_share %}{% url 'staff_tickets:shared_message_attachment_download' item.received_share.public_id file.public_id %}{% else %}{% url 'attachments:download' file.public_id %}{% endif %}"><span class="file-icon">⇩</span><span><b>{{ file.original_name }}</b><small>{{ file.get_attachment_type_display }} · {{ file.file_size|filesizeformat }}</small></span></a>{% endif %}
|
||||
{% endfor %}</div>{% endif %}
|
||||
{% endif %}
|
||||
{% if item.received_share %}<small class="received-share-attribution">{% include "tickets/_staff_share_icon.html" %} اشتراک توسط {{ item.received_share.granted_by.staff_profile.display_name|default:item.received_share.granted_by.username }} · <time>{{ item.received_share.created_at|jalali_datetime }}</time>{% if item.received_share.note %}<details><summary aria-label="توضیح اشتراک">ⓘ</summary>{{ item.received_share.note }}</details>{% endif %}</small>{% endif %}
|
||||
<div class="staff-message-footer">
|
||||
{% if not limited_share %}{% with receipts=item.read_receipts.all %}{% if receipts %}<details class="message-receipts"><summary>✓ دیدهشده توسط {{ receipts|length }} نفر</summary><ul>{% for receipt in receipts %}<li><span>{{ receipt.reader_name }}</span><time>{{ receipt.read_at|jalali_datetime }}</time></li>{% endfor %}</ul></details>{% endif %}{% endwith %}{% endif %}
|
||||
{% if item.author == user and not item.deleted_at and not limited_share %}<details class="message-menu" data-message-menu><summary aria-label="عملیات پیام" title="عملیات پیام"><span aria-hidden="true">•••</span></summary><div class="message-menu-panel" role="dialog" aria-label="عملیات پیام"><div class="message-menu-choices" data-message-menu-choices><button type="button" class="message-choice message-choice-edit" data-message-edit-open><span class="message-choice-icon" aria-hidden="true">✎</span><span><b>ویرایش</b><small>اصلاح متن پیام</small></span><span class="message-choice-arrow" aria-hidden="true">‹</span></button><form method="post" action="{% url 'messages:delete' item.public_id %}" onsubmit="return confirm('از حذف این پیام مطمئن هستید؟')">{% csrf_token %}<input type="hidden" name="next" value="{{ request.path }}"><button type="submit" class="message-choice message-choice-delete"><span class="message-choice-icon" aria-hidden="true">⌫</span><span><b>حذف</b><small>حذف پیام از گفتگو</small></span></button></form></div><div class="message-editor" data-message-editor hidden><header class="message-menu-head"><button type="button" class="message-editor-back" data-message-edit-back aria-label="بازگشت به عملیات">→</button><div><b>ویرایش پیام</b><small>متن اصلاحشده در تاریخچه ثبت میشود</small></div><button type="button" class="message-menu-close" data-message-menu-close aria-label="بستن">×</button></header><form class="message-edit-form" method="post" action="{% url 'messages:edit' item.public_id %}">{% csrf_token %}<label for="edit-{{ item.public_id }}">متن پیام</label><textarea id="edit-{{ item.public_id }}" name="body" maxlength="4000" data-message-edit-input data-original-value="{{ item.body }}">{{ item.body }}</textarea><div class="message-edit-meta"><span>متن را دقیق و کوتاه نگه دارید</span><span data-message-edit-count>{{ item.body|length }} / ۴۰۰۰</span></div><input type="hidden" name="next" value="{{ request.path }}"><div class="message-menu-actions"><button type="submit" class="message-edit-save">ذخیره تغییرات</button><button type="button" class="message-edit-cancel" data-message-menu-close>انصراف</button></div></form></div></div></details>{% endif %}
|
||||
{% include 'tickets/_staff_message_share.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% empty %}<div class="conversation-empty"><span>✦</span><h3>هنوز مکاتبهای ثبت نشده است</h3><p>شرح کامل درخواست یا پاسخ خود را در فرم پایین ثبت کنید.</p></div>{% endfor %}
|
||||
@@ -0,0 +1,2 @@
|
||||
{% if has_older %}<button class="load-older" type="button" data-load-older hx-get="{% url 'staff_tickets:messages' ticket.public_id %}?before={{ older_cursor }}" hx-target="this" hx-swap="outerHTML">↑ نمایش مکاتبات قدیمیتر</button>{% endif %}
|
||||
{% include 'tickets/_staff_messages.html' %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% load jalali %}
|
||||
<details class="share-audit-row">
|
||||
<summary>
|
||||
<span class="staff-share-person"><b>{{ grant.user.staff_profile|default:grant.user.username }}</b>{% if not inline %}<small>{% if is_message %}فقط یک پیام{% else %}{{ grant.get_permission_level_display }}{% endif %}</small>{% endif %}</span>
|
||||
<span class="staff-share-status {% if grant.revoked_at %}is-revoked{% elif is_message and grant.message.deleted_at %}is-revoked{% elif grant.first_viewed_at %}is-seen{% else %}is-pending{% endif %}">{% if grant.revoked_at %}لغوشده{% elif is_message and grant.message.deleted_at %}پیام حذفشده{% elif grant.first_viewed_at %}دیدهشده{% else %}هنوز دیده نشده{% endif %}</span>
|
||||
<span class="staff-share-chevron" aria-hidden="true"></span>
|
||||
</summary>
|
||||
<div class="staff-share-row-content">
|
||||
{% if is_message and not inline and not grant.message.deleted_at %}<p class="staff-share-preview">{{ grant.message.body|truncatechars:100|default:'پیام دارای پیوست' }}</p>{% endif %}
|
||||
<small>اشتراک توسط {{ grant.granted_by.staff_profile|default:grant.granted_by.username }} · {{ grant.created_at|jalali_datetime }}</small>
|
||||
{% if grant.note %}<p class="staff-share-note">{{ grant.note }}</p>{% endif %}
|
||||
{% if grant.first_viewed_at %}<small>اولین مشاهده: {{ grant.first_viewed_at|jalali_datetime }}</small><small>آخرین مشاهده: {{ grant.last_viewed_at|jalali_datetime }} · {{ grant.view_count }} بار</small>{% endif %}
|
||||
{% if is_message and not inline %}<small>زمان پیام: {{ grant.message.created_at|jalali_datetime }}</small><small class="staff-share-reference" dir="ltr">{{ grant.message.public_id }}</small>{% endif %}
|
||||
{% if grant.revoked_at %}<small>لغو: {{ grant.revoked_at|jalali_datetime }}{% if grant.revoked_by %} توسط {{ grant.revoked_by.staff_profile|default:grant.revoked_by.username }}{% endif %}</small>
|
||||
{% else %}<form method="post" action="{% if is_message %}{% url 'staff_tickets:revoke_message_share' ticket.public_id grant.public_id %}{% else %}{% url 'staff_tickets:revoke' ticket.public_id grant.user.public_id %}{% endif %}">{% csrf_token %}<button class="staff-share-revoke" type="submit">{% if is_message %}لغو اشتراک پیام{% else %}لغو اشتراک تیکت{% endif %}</button></form>{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
@@ -0,0 +1 @@
|
||||
<svg class="staff-share-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4"/></svg>
|
||||
|
After Width: | Height: | Size: 309 B |
@@ -0,0 +1,36 @@
|
||||
{% load jalali %}
|
||||
{% if can_share %}
|
||||
<details class="info-card staff-sharing-audit">
|
||||
<summary class="staff-sharing-audit-toggle">
|
||||
<svg class="staff-share-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
|
||||
<span><strong>پیگیری اشتراکها</strong><small>وضعیت مشاهده و سوابق</small></span>
|
||||
<span class="staff-share-chevron" aria-hidden="true"></span>
|
||||
</summary>
|
||||
<div class="staff-sharing-audit-content">
|
||||
<p class="staff-share-hint">«دیدهشده» یعنی بازکردن صفحه، نه تأیید بررسی. لغو اشتراک، دسترسی مستقل سازمانی را حذف نمیکند.</p>
|
||||
<details class="staff-share-group" data-share-scope="ticket">
|
||||
<summary><span>اشتراک کل تیکت</span><span class="staff-share-count">{{ grants|length }}</span><span class="staff-share-chevron" aria-hidden="true"></span></summary>
|
||||
{% for grant in grants %}{% include 'tickets/_staff_share_audit_row.html' with is_message=False %}{% empty %}<p class="staff-share-empty">اشتراکی ثبت نشده است.</p>{% endfor %}
|
||||
</details>
|
||||
<details class="staff-share-group" data-share-scope="message">
|
||||
<summary><span>اشتراک پیامها</span><span class="staff-share-count">{{ message_shares|length }}</span><span class="staff-share-chevron" aria-hidden="true"></span></summary>
|
||||
{% for grant in message_shares %}{% include 'tickets/_staff_share_audit_row.html' with is_message=True %}{% empty %}<p class="staff-share-empty">پیامی به اشتراک گذاشته نشده است.</p>{% endfor %}
|
||||
</details>
|
||||
<details class="staff-share-group staff-share-events">
|
||||
<summary><span>سوابق اشتراک و انتقال</span><span class="staff-share-count">{{ sharing_events|length }}</span><span class="staff-share-chevron" aria-hidden="true"></span></summary>
|
||||
<p class="staff-share-hint">۱۰۰ رویداد اخیر</p>
|
||||
{% for event in sharing_events %}
|
||||
<article class="share-event-row">
|
||||
<b>{{ event.get_event_type_display }}</b><small>{{ event.actor_name }} · {{ event.created_at|jalali_datetime }}</small>
|
||||
{% if event.recipient_name %}<span>گیرنده: {{ event.recipient_name }}</span>{% endif %}
|
||||
{% if event.previous_owner_name %}<small>کارشناس قبلی: {{ event.previous_owner_name }}</small>{% endif %}
|
||||
{% if event.metadata.action == 'updated' %}<span>توضیح یا سطح دسترسی بهروزرسانی شد</span>{% endif %}
|
||||
{% if event.metadata.level %}<small>سطح دسترسی ثبتشده: {{ event.metadata.level }}</small>{% endif %}
|
||||
{% if event.metadata.message_public_id %}<small class="staff-share-reference" dir="ltr">{{ event.metadata.message_public_id }}</small>{% endif %}
|
||||
{% if event.metadata.note %}<p>{{ event.metadata.note }}</p>{% endif %}
|
||||
</article>
|
||||
{% empty %}<p class="staff-share-empty">رویدادی ثبت نشده است.</p>{% endfor %}
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="staff-ticket-main">
|
||||
<div class="staff-ticket-head"><span class="ticket-number" dir="ltr">{{ ticket.ticket_number }}</span>{% if ticket.status == 'WAITING_STAFF' or ticket.status == 'REOPENED' %}<span class="action-required">نیازمند پاسخ</span>{% endif %}<time>{{ ticket.last_activity_at|jalali_datetime }}</time></div>
|
||||
<h2>{{ ticket.subject }}</h2>
|
||||
{% if ticket.owner and ticket.owner != user and not perms.tickets.view_all_tickets %}
|
||||
{% if not ticket.limited_share and ticket.owner and ticket.owner != user and not perms.tickets.view_all_tickets %}
|
||||
<p class="ticket-locked">قفلشده توسط {{ ticket.owner.staff_profile.display_name }} · جزئیات پس از انتقال قابل مشاهده است</p>
|
||||
{% else %}
|
||||
<div class="ticket-row-meta"><span>{{ ticket.category }}</span><span>{{ ticket.customer.customer_profile|default:ticket.customer.mobile }}</span><span>{% if ticket.city %}{{ ticket.city }}، {{ ticket.province }}{% elif ticket.province %}{{ ticket.province }}{% else %}موقعیت ثبت نشده{% endif %}</span></div>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{% if can_share %}
|
||||
<details class="staff-ticket-share">
|
||||
<summary>{% include 'tickets/_staff_share_icon.html' %}<span>اشتراک با کارشناس</span><span class="staff-share-chevron" aria-hidden="true"></span></summary>
|
||||
<div class="staff-share-content">
|
||||
<p class="staff-share-hint">دسترسی به کل تیکت. برای ارسال یک رسید یا تصویر، «اشتراک فقط این پیام» را انتخاب کنید.</p>
|
||||
<form class="staff-share-form" method="post" action="{% url 'staff_tickets:share' ticket.public_id %}">
|
||||
{% csrf_token %}
|
||||
<label for="{{ share_form.expert.id_for_label }}">کارشناس گیرنده</label>{{ share_form.expert }}
|
||||
<label for="{{ share_form.permission_level.id_for_label }}">سطح دسترسی</label>{{ share_form.permission_level }}
|
||||
<label for="{{ share_form.note.id_for_label }}">توضیح برای گیرنده</label>{{ share_form.note }}
|
||||
<div class="staff-share-footer"><small>توضیح اختیاری است</small><button class="staff-share-submit" type="submit">اشتراک کل تیکت</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends 'base.html' %}{% load static jalali %}
|
||||
{% block title %}{{ page_title }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="ticket-workspace staff-ticket-workspace" data-conversation-workspace data-poll-url="{% url 'staff_tickets:poll' ticket.public_id %}" data-poll-version="{{ poll_version }}" data-poll-cursor="{{ poll_cursor }}" data-draft-key="ticket-{{ ticket.public_id }}-staff" data-reply-result="{{ request.GET.reply_result|default:'' }}">
|
||||
<div class="ticket-workspace staff-ticket-workspace" data-conversation-workspace data-share-read-url="{% url 'staff_tickets:read_shares' ticket.public_id %}" data-poll-url="{% url 'staff_tickets:poll' ticket.public_id %}" data-poll-version="{{ poll_version }}" data-poll-cursor="{{ poll_cursor }}" data-draft-key="ticket-{{ ticket.public_id }}-staff" data-reply-result="{{ request.GET.reply_result|default:'' }}">
|
||||
<header class="ticket-workspace-head">
|
||||
<div class="ticket-title"><a class="workspace-back" href="{% url 'staff_tickets:list' %}" aria-label="بازگشت">→</a><div><span class="ticket-number" dir="ltr">{{ ticket.ticket_number }}</span><h1>{{ ticket.subject }}</h1></div></div>
|
||||
<div class="ticket-head-actions"><span class="badge" data-ticket-status>{{ ticket.get_status_display }}</span>{% include 'tickets/_voip_call_button.html' with customer=ticket.customer %}{% if can_claim %}<form method="post" action="{% url 'staff_tickets:claim' ticket.public_id %}">{% csrf_token %}<button type="submit">اختصاص به من</button></form>{% endif %}<button type="button" class="icon-button" data-toggle-panel="ticket-actions" aria-label="عملیات تیکت">⚙</button><button type="button" class="icon-button" data-toggle-panel="customer-info" aria-label="اطلاعات مشتری">◉</button></div>
|
||||
@@ -20,14 +20,15 @@
|
||||
{% 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/_immediate_announcement.html' %}
|
||||
{% 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>
|
||||
<section class="info-card ticket-operations"><h3>عملیات تیکت</h3>{% if can_share %}<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 can_share %}<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 %}{% include "tickets/_staff_ticket_share.html" %}</section>
|
||||
{% include 'tickets/_staff_sharing_audit.html' %}
|
||||
{% 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>
|
||||
<main class="conversation-pane correspondence-pane">
|
||||
<section class="conversation-toolbar"><div><b>سوابق رسیدگی و مکاتبات</b></div><small><span data-conversation-count>{{ conversation|length }}</span> مکاتبه</small></section>
|
||||
<section class="conversation" data-conversation role="log" aria-live="polite">{% if has_older %}<button class="load-older" type="button" data-load-older hx-get="{% url 'staff_tickets:messages' ticket.public_id %}?before={{ older_cursor }}" hx-target="this" hx-swap="outerHTML">↑ نمایش مکاتبات قدیمیتر</button>{% endif %}{% include 'tickets/_messages.html' %}<div class="conversation-end" data-conversation-end aria-hidden="true"></div></section>
|
||||
<section class="conversation" data-conversation role="log" aria-live="polite">{% if has_older %}<button class="load-older" type="button" data-load-older hx-get="{% url 'staff_tickets:messages' ticket.public_id %}?before={{ older_cursor }}" hx-target="this" hx-swap="outerHTML">↑ نمایش مکاتبات قدیمیتر</button>{% endif %}{% include 'tickets/_staff_messages.html' %}<div class="conversation-end" data-conversation-end aria-hidden="true"></div></section>
|
||||
<button class="new-message-jump" type="button" data-jump-latest hidden>↓ رفتن به آخرین مکاتبه</button>
|
||||
<section class="composer staff-composer {% if request.GET.reply_result != 'error' %}is-collapsed{% endif %}" x-data="{mode:'reply'}" :class="mode==='note'&&'note-mode'" data-composer-shell>
|
||||
{% if not limited_share %}<section class="composer staff-composer {% if request.GET.reply_result != 'error' %}is-collapsed{% endif %}" x-data="{mode:'reply'}" :class="mode==='note'&&'note-mode'" data-composer-shell>
|
||||
<button type="button" class="composer-launch" data-composer-toggle aria-expanded="false"><span class="composer-launch-icon">+</span><span><b>ثبت اقدام جدید در تیکت</b><small>پاسخ رسمی یا یادداشت کارشناسی ثبت کنید</small></span><span class="composer-launch-arrow">↑</span></button>
|
||||
<div class="mode" role="tablist"><button type="button" role="tab" data-composer-mode="reply" @click="mode='reply'" :class="mode==='reply'&&'active'">پاسخ رسمی به مشتری</button>{% if perms.messaging.add_internal_note %}<button type="button" role="tab" data-composer-mode="note" @click="mode='note'" :class="mode==='note'&&'active note-button'">🔒 یادداشت کارشناسی</button>{% endif %}</div>
|
||||
<form method="post" enctype="multipart/form-data" data-ticket-composer data-chat-coach data-chat-coach-key="ticket-{{ ticket.public_id }}-staff-{{ user.public_id }}" :action="mode==='note'?'{% url 'staff_tickets:note' ticket.public_id %}':'{% url 'staff_tickets:reply' ticket.public_id %}'">{% csrf_token %}
|
||||
@@ -39,10 +40,12 @@
|
||||
<div class="composer-bar"><div class="composer-tools"><button class="tool-button" type="button" data-toggle-recorder>●<span>توضیح صوتی</span></button><button class="tool-button" type="button" data-toggle-extra>@<span>اطلاع به کارشناس</span></button><button class="tool-button composer-close" type="button" data-composer-close>بستن</button></div><small class="composer-hint">پیش از ثبت، متن و پیوستها را بازبینی کنید.</small><button class="send-button" type="submit" data-primary-submit><span x-text="mode==='note'?'ثبت یادداشت در پرونده':'ثبت پاسخ در تیکت'"></span> ←</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
</main>
|
||||
<aside class="workspace-side customer-side" id="customer-info" data-workspace-panel><button type="button" class="panel-close" data-close-panel>×</button><section class="info-card"><p class="panel-index">مشتری</p><h3>{{ ticket.customer.customer_profile|default:'بدون نام' }}</h3><p class="mt-1 text-sm font-bold" dir="ltr">{{ ticket.customer.mobile }}</p><div class="info-list"><div class="info-row"><span>نوع مشتری</span><b>{{ ticket.customer.customer_profile.get_customer_type_display|default:'ثبت نشده' }}</b></div><div class="info-row"><span>موقعیت تیکت</span><b>{% if ticket.city %}{{ ticket.city }}، {{ ticket.province }}{% elif ticket.province %}{{ ticket.province }}{% else %}ثبت نشده{% endif %}</b></div><div class="info-row"><span>تعداد تیکت</span><b>{{ ticket.customer.customer_tickets.count }}</b></div></div></section><section class="info-card"><h3>سابقه اخیر</h3>{% for old in customer_history %}<div class="history-item"><b>{{ old.subject }}</b><span class="ticket-number">{{ old.ticket_number }}</span>{% if perms.tickets.view_all_tickets or old.owner == user or old.selected_expert == user %}<a class="back-link" href="{% url 'staff_tickets:detail' old.public_id %}">مشاهده ←</a>{% else %}<small class="muted">محتوای خصوصی</small>{% endif %}</div>{% empty %}<p class="muted mt-3">تیکت دیگری ثبت نشده است.</p>{% endfor %}</section></aside>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'tickets/_image_viewer.html' %}
|
||||
<link rel="stylesheet" href="{% static 'css/staff-sharing.css' %}?v={{ static_asset_version }}">
|
||||
<script src="{% static 'vendor/compressor.min.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/attachment-uploader.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/image-viewer.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/conversation.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/voip-call.js' %}?v={{ static_asset_version }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,57 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from tests.e2e.pages.base import BasePage
|
||||
|
||||
|
||||
class StaffSharingPage(BasePage):
|
||||
def share_message(self, ticket, message, recipient, note):
|
||||
self.goto(reverse("staff_tickets:detail", args=[ticket.public_id]))
|
||||
controls = self.page.locator(f'[data-share-message="{message.public_id}"]')
|
||||
expect(controls.locator("form")).to_be_hidden()
|
||||
controls.locator("summary").first.focus()
|
||||
controls.locator("summary").first.press("Enter")
|
||||
expect(controls.locator("form")).to_be_visible()
|
||||
controls.get_by_label("کارشناس گیرنده").select_option(str(recipient.staff_profile.pk))
|
||||
controls.locator(".staff-share-note-field > summary").click()
|
||||
controls.get_by_label("توضیح برای گیرنده").fill(note)
|
||||
controls.get_by_role("button", name="اشتراک پیام و پیوستهایش").click()
|
||||
expect(self.page.get_by_text("فقط همین پیام و پیوستهای آن در صندوق اشتراکهای کارشناس قرار گرفت.")).to_be_visible()
|
||||
|
||||
def open_received_message(self, ticket):
|
||||
self.goto(reverse("staff_tickets:shares"))
|
||||
expect(self.page.locator('[data-share-unread-count]').first).to_contain_text("1")
|
||||
self.page.locator("a.staff-ticket").first.click()
|
||||
expect(self.page.get_by_role("heading", name=ticket.subject, exact=True)).to_be_visible()
|
||||
expect(self.page.locator('.received-share-attribution')).to_contain_text("کارشناس sender")
|
||||
expect(self.page.locator('[data-share-unread-count]').first).to_be_hidden()
|
||||
expect(self.page.locator('[data-ticket-composer]')).to_have_count(0)
|
||||
expect(self.page.get_by_role("button", name="عملیات تیکت", exact=True)).to_be_visible()
|
||||
if self.page.viewport_size["width"] < 768:
|
||||
self.page.get_by_role("button", name="اطلاعات مشتری", exact=True).click()
|
||||
expect(self.page.locator('#customer-info')).to_contain_text(ticket.customer.mobile)
|
||||
expect(self.page.locator('#customer-info')).to_be_visible()
|
||||
if self.page.viewport_size["width"] < 768:
|
||||
self.page.locator('#customer-info [data-close-panel]').click()
|
||||
|
||||
def expect_isolated_content(self, selected_text, forbidden_texts):
|
||||
expect(self.page.get_by_text(selected_text, exact=True)).to_be_visible()
|
||||
for text in forbidden_texts:
|
||||
expect(self.page.get_by_text(text, exact=True)).to_have_count(0)
|
||||
|
||||
def expect_opened_and_revoke(self, ticket, recipient_name):
|
||||
self.goto(reverse("staff_tickets:detail", args=[ticket.public_id]))
|
||||
controls = self.page.locator("[data-share-message]").filter(has=self.page.locator(".share-audit-row").filter(has_text=recipient_name))
|
||||
controls.locator("summary").first.click()
|
||||
row = controls.locator(".share-audit-row").filter(has_text=recipient_name)
|
||||
expect(row).to_contain_text("دیدهشده")
|
||||
expect(row.get_by_role("button", name="لغو اشتراک پیام")).to_be_hidden()
|
||||
row.locator("summary").click()
|
||||
expect(row).to_contain_text("اولین مشاهده")
|
||||
controls.locator(".staff-inline-share-history > summary").click()
|
||||
expect(controls.locator(".share-event-row").filter(has_text="مشاهده پیام اشتراکی")).to_be_visible()
|
||||
row.get_by_role("button", name="لغو اشتراک پیام").click()
|
||||
|
||||
def expect_revoked_link_denied(self, url):
|
||||
self.goto(url)
|
||||
expect(self.page.get_by_text("403 Forbidden", exact=False)).to_be_visible()
|
||||
@@ -0,0 +1,33 @@
|
||||
import pytest
|
||||
|
||||
from tests.e2e.pages.auth import AuthenticationPage
|
||||
from tests.e2e.pages.sharing import StaffSharingPage
|
||||
from tests.test_staff_sharing import make_sharing_case
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [{"width": 1280, "height": 900}, {"width": 390, "height": 844}])
|
||||
def test_staff_can_share_one_message_track_opening_and_revoke(live_server, browser_page, viewport):
|
||||
case = make_sharing_case()
|
||||
password = "SharingTest123!"
|
||||
with browser_page() as page:
|
||||
page.set_viewport_size(viewport)
|
||||
AuthenticationPage(page, live_server.url).login_staff(case["sender"], password)
|
||||
StaffSharingPage(page, live_server.url).share_message(case["ticket"], case["selected"], case["recipient"], "بررسی رسید توسط مالی")
|
||||
with browser_page() as page:
|
||||
page.set_viewport_size(viewport)
|
||||
AuthenticationPage(page, live_server.url).login_staff(case["recipient"], password)
|
||||
sharing = StaffSharingPage(page, live_server.url)
|
||||
sharing.open_received_message(case["ticket"])
|
||||
sharing.expect_isolated_content(case["selected"].body, ["مکاتبه محرمانه قبل", "مکاتبه محرمانه بعد"])
|
||||
shared_url = page.url
|
||||
with browser_page() as page:
|
||||
page.set_viewport_size(viewport)
|
||||
AuthenticationPage(page, live_server.url).login_staff(case["sender"], password)
|
||||
StaffSharingPage(page, live_server.url).expect_opened_and_revoke(case["ticket"], "کارشناس recipient")
|
||||
with browser_page() as page:
|
||||
page.set_viewport_size(viewport)
|
||||
AuthenticationPage(page, live_server.url).login_staff(case["recipient"], password)
|
||||
StaffSharingPage(page, live_server.url).expect_revoked_link_denied(shared_url)
|
||||
@@ -0,0 +1,35 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.tickets.sharing import revoke_staff_share, share_staff_message, staff_message_context
|
||||
from tests.test_staff_sharing import make_sharing_case
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_inline_sharing_keeps_each_messages_recipients_and_history_separate(client):
|
||||
case = make_sharing_case()
|
||||
other_message = case["ticket"].ticket_messages.get(body="مکاتبه محرمانه قبل")
|
||||
selected_grant = share_staff_message(case["selected"], case["sender"], case["recipient"], "رسید منتخب")
|
||||
other_grant = share_staff_message(other_message, case["sender"], case["outsider"], "مکاتبه دیگر")
|
||||
client.force_login(case["recipient"])
|
||||
assert client.get(reverse("staff_tickets:shared_message", args=[selected_grant.public_id]), follow=True).status_code == 200
|
||||
assert client.post(reverse("staff_tickets:read_shares", args=[case["ticket"].public_id]), {"message_ids": [str(case["selected"].public_id)]}).status_code == 200
|
||||
client.force_login(case["sender"])
|
||||
response = client.get(reverse("staff_tickets:detail", args=[case["ticket"].public_id]))
|
||||
selected = next(item for item in response.context["conversation"] if item.pk == case["selected"].pk)
|
||||
before = next(item for item in response.context["conversation"] if item.pk == other_message.pk)
|
||||
assert [grant.pk for grant in selected.staff_message_shares] == [selected_grant.pk]
|
||||
assert selected.staff_message_shares[0].first_viewed_at is not None
|
||||
assert [grant.pk for grant in before.staff_message_shares] == [other_grant.pk]
|
||||
assert {event.metadata["action"] for event in selected.staff_share_events} == {"created", "opened"}
|
||||
assert all(event.metadata["message_public_id"] == str(selected.public_id) for event in selected.staff_share_events)
|
||||
assert all(event.metadata["user"] == case["recipient"].pk for event in selected.staff_share_events)
|
||||
assert revoke_staff_share(selected_grant, case["sender"])
|
||||
staff_message_context(case["ticket"], case["sender"], [case["selected"]])
|
||||
assert case["selected"].staff_message_shares[0].revoked_at is not None
|
||||
assert {event.metadata["action"] for event in case["selected"].staff_share_events} == {"created", "opened", "revoked"}
|
||||
context = staff_message_context(case["ticket"], case["customer"], [other_message])
|
||||
assert not context["can_share_message"]
|
||||
assert not hasattr(other_message, "staff_message_shares")
|
||||
@@ -0,0 +1,160 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from apps.messaging.models import Message
|
||||
from apps.staff.models import StaffProfile
|
||||
from apps.tickets.models import MessageShare, TicketAccessGrant
|
||||
from apps.tickets.policies import TicketPolicy
|
||||
from apps.tickets.services import change_priority, change_status
|
||||
from apps.tickets.sharing import share_staff_message, share_staff_ticket, revoke_staff_share
|
||||
from tests.test_staff_sharing import make_sharing_case
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_shared_queue_groups_messages_and_searches_normal_ticket_metadata(client):
|
||||
c = make_sharing_case()
|
||||
second = Message.objects.create(ticket=c['ticket'], author=c['customer'], body='رسید دوم')
|
||||
grants = [share_staff_message(m, c['sender'], c['recipient']) for m in (c['selected'], second)]
|
||||
client.force_login(c['recipient'])
|
||||
listing = reverse('staff_tickets:list')
|
||||
response = client.get(listing, {'tab': 'shared'})
|
||||
assert len(response.context['tickets']) == 1
|
||||
assert c['ticket'].subject in response.content.decode()
|
||||
assert len(client.get(listing, {'tab': 'shared', 'q': c['ticket'].subject}).context['tickets']) == 1
|
||||
assert len(client.get(listing, {'tab': 'shared', 'q': second.body}).context['tickets']) == 1
|
||||
assert len(client.get(listing, {'tab': 'shared', 'status': c['ticket'].status, 'category': c['ticket'].category_id}).context['tickets']) == 1
|
||||
assert len(client.get(listing, {'tab': 'shared', 'q': c['customer'].mobile}).context['tickets']) == 1
|
||||
assert client.get(listing, {'tab': 'shared', 'q': 'مکاتبه محرمانه قبل'}).context['tickets'] == []
|
||||
assert client.get(reverse('staff_tickets:shares_count')).json()['unread_shares'] == 2
|
||||
read = reverse('staff_tickets:read_shares', args=[c['ticket'].public_id])
|
||||
assert client.post(read, {'message_ids': ['bad-uuid', str(c['ticket'].ticket_messages.first().public_id)]}).json()['unread_shares'] == 2
|
||||
assert client.post(read, {'message_ids': [str(second.public_id)]}).json()['unread_shares'] == 1
|
||||
grants[0].refresh_from_db()
|
||||
assert grants[0].first_viewed_at is None
|
||||
revoke_staff_share(grants[0], c['sender'])
|
||||
assert client.get(reverse('staff_tickets:shares_count')).json()['unread_shares'] == 0
|
||||
assert client.get(listing, {'tab': 'shared', 'unread': 1}).context['tickets'] == []
|
||||
|
||||
|
||||
def test_limited_poll_tracks_revocation_without_leaking_hidden_activity(client):
|
||||
c = make_sharing_case()
|
||||
grant = share_staff_message(c['selected'], c['sender'], c['recipient'])
|
||||
client.force_login(c['recipient'])
|
||||
url = reverse('staff_tickets:poll', args=[c['ticket'].public_id])
|
||||
baseline = client.get(url).json()
|
||||
assert baseline['count'] == 1 and c['selected'].body in baseline['html']
|
||||
assert 'مکاتبه محرمانه' not in baseline['html']
|
||||
Message.objects.create(ticket=c['ticket'], author=c['sender'], body='hidden future')
|
||||
same = client.get(url, {'version': baseline['version'], 'cursor': baseline['cursor']}).json()
|
||||
assert same['changed'] is False
|
||||
second = Message.objects.create(ticket=c['ticket'], author=c['customer'], body='second shared')
|
||||
share_staff_message(second, c['sender'], c['recipient'])
|
||||
revoke_staff_share(grant, c['sender'])
|
||||
changed = client.get(url, {'version': baseline['version'], 'cursor': baseline['cursor']}).json()
|
||||
assert changed['replace'] and changed['count'] == 1
|
||||
assert second.body in changed['html'] and c['selected'].body not in changed['html']
|
||||
older = client.get(reverse('staff_tickets:messages', args=[c['ticket'].public_id]), {'before': c['selected'].public_id})
|
||||
assert 'hidden future' not in older.content.decode()
|
||||
Message.objects.filter(pk=second.pk).update(deleted_at=timezone.now())
|
||||
assert client.get(url).status_code == 403
|
||||
assert client.get(reverse('staff_tickets:detail', args=[c['ticket'].public_id])).status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize('role', StaffProfile.Role.values)
|
||||
def test_every_active_staff_role_can_operate_and_share_accessible_tickets(client, role):
|
||||
c = make_sharing_case()
|
||||
sender = c['sender']
|
||||
sender.user_permissions.clear()
|
||||
StaffProfile.objects.filter(user=sender).update(role=role)
|
||||
sender.refresh_from_db()
|
||||
assert TicketPolicy.can_share(sender, c['ticket'])
|
||||
assert TicketPolicy.can_transfer(sender, c['ticket'])
|
||||
change_status(c['ticket'], sender, 'OPEN')
|
||||
change_priority(c['ticket'], sender, 'URGENT')
|
||||
share_staff_message(c['selected'], sender, c['recipient'])
|
||||
share_staff_ticket(c['ticket'], sender, c['outsider'], 'VIEW_ONLY')
|
||||
client.force_login(sender)
|
||||
body = client.get(reverse('staff_tickets:detail', args=[c['ticket'].public_id])).content.decode()
|
||||
assert 'ثبت وضعیت' in body and 'ثبت اولویت' in body and 'انتقال تیکت' in body
|
||||
assert 'اشتراک با کارشناس' in body
|
||||
assert not TicketPolicy.can_operate(c['recipient'], c['ticket'])
|
||||
assert TicketPolicy.can_operate(c['outsider'], c['ticket'])
|
||||
assert not TicketPolicy.can_operate(c['customer'], c['ticket'])
|
||||
StaffProfile.objects.filter(user=sender).update(is_active=False)
|
||||
sender.refresh_from_db()
|
||||
assert not TicketPolicy.can_share(sender, c['ticket'])
|
||||
|
||||
|
||||
def test_old_shared_messages_stay_unread_until_displayed_and_poll_keeps_pagination(client):
|
||||
c = make_sharing_case()
|
||||
share_staff_message(c['selected'], c['sender'], c['recipient'])
|
||||
for index in range(51):
|
||||
message = Message.objects.create(ticket=c['ticket'], author=c['customer'], body=f'رسید مجاز {index}')
|
||||
share_staff_message(message, c['sender'], c['recipient'])
|
||||
client.force_login(c['recipient'])
|
||||
response = client.get(reverse('staff_tickets:detail', args=[c['ticket'].public_id]))
|
||||
assert len(response.context['conversation']) == 50 and response.context['has_older']
|
||||
assert c['selected'].body not in response.content.decode()
|
||||
read = reverse('staff_tickets:read_shares', args=[c['ticket'].public_id])
|
||||
last = response.context['conversation'][-1]
|
||||
assert client.post(read, {'message_ids': [str(last.public_id)]}).json()['unread_shares'] == 51
|
||||
old = client.get(reverse('staff_tickets:messages', args=[c['ticket'].public_id]), {'before': response.context['older_cursor']})
|
||||
assert c['selected'].body in old.content.decode() and 'مکاتبه محرمانه' not in old.content.decode()
|
||||
poll = client.get(reverse('staff_tickets:poll', args=[c['ticket'].public_id])).json()
|
||||
assert poll['staff_limited'] and 'data-load-older' in poll['html']
|
||||
|
||||
|
||||
def test_full_and_message_shares_have_one_row_and_full_access_preserves_conversation(client):
|
||||
c = make_sharing_case()
|
||||
share_staff_message(c['selected'], c['sender'], c['recipient'])
|
||||
share_staff_ticket(c['ticket'], c['sender'], c['recipient'], 'VIEW_ONLY')
|
||||
client.force_login(c['recipient'])
|
||||
queue = client.get(reverse('staff_tickets:list'), {'tab': 'shared'})
|
||||
assert len(queue.context['tickets']) == 1
|
||||
assert c['ticket'].subject in queue.content.decode()
|
||||
body = client.get(reverse('staff_tickets:detail', args=[c['ticket'].public_id])).content.decode()
|
||||
assert 'مکاتبه محرمانه قبل' in body and 'مکاتبه محرمانه بعد' in body
|
||||
assert client.get(reverse('staff_tickets:shares_count')).json()['unread_shares'] == 1
|
||||
response = client.post(reverse('staff_tickets:transfer', args=[c['ticket'].public_id]), {'expert': c['outsider'].staff_profile.pk})
|
||||
assert response.status_code == 302
|
||||
c['ticket'].refresh_from_db()
|
||||
assert c['ticket'].owner == c['outsider']
|
||||
|
||||
|
||||
def test_global_staff_access_does_not_duplicate_or_restrict_a_message_shared_ticket(client):
|
||||
from django.contrib.auth.models import Permission
|
||||
c = make_sharing_case()
|
||||
share_staff_message(c['selected'], c['sender'], c['recipient'])
|
||||
c['recipient'].user_permissions.add(Permission.objects.get(content_type__app_label='tickets', codename='view_all_tickets'))
|
||||
client.force_login(c['recipient'])
|
||||
queue = client.get(reverse('staff_tickets:list'), {'tab': 'shared'})
|
||||
assert len(queue.context['tickets']) == 1
|
||||
assert c['ticket'].subject in queue.content.decode()
|
||||
assert not getattr(queue.context['tickets'][0], 'limited_share', False)
|
||||
|
||||
|
||||
def test_message_recipient_has_normal_ticket_details_with_only_shared_message_content(client):
|
||||
from apps.customers.models import CustomerProfile
|
||||
from apps.tickets.models import Ticket
|
||||
c = make_sharing_case()
|
||||
CustomerProfile.objects.create(user=c['customer'], first_name='مشتری', last_name='اشتراک')
|
||||
Ticket.objects.filter(pk=c['ticket'].pk).update(province='اصفهان', city='گلپایگان', priority='URGENT')
|
||||
c['ticket'].refresh_from_db()
|
||||
share_staff_message(c['selected'], c['sender'], c['recipient'])
|
||||
client.force_login(c['recipient'])
|
||||
listing = client.get(reverse('staff_tickets:list'), {'tab': 'shared'})
|
||||
html = listing.content.decode()
|
||||
assert c['ticket'].subject in html and 'مشتری اشتراک' in html
|
||||
assert 'گلپایگان، اصفهان' in html and 'فوری' in html
|
||||
detail = client.get(reverse('staff_tickets:detail', args=[c['ticket'].public_id]))
|
||||
html = detail.content.decode()
|
||||
for value in (c['ticket'].subject, c['customer'].mobile, 'مشتری اشتراک', 'گلپایگان، اصفهان', 'فوری', 'سابقه اخیر', 'id="customer-info"', 'id="ticket-actions"', 'data-ticket-status'):
|
||||
assert value in html
|
||||
assert 'limited-shared-conversation' not in html
|
||||
assert c['selected'].body in html
|
||||
assert 'مکاتبه محرمانه قبل' not in html and 'مکاتبه محرمانه بعد' not in html
|
||||
assert not TicketAccessGrant.objects.filter(ticket=c['ticket'], user=c['recipient']).exists()
|
||||
assert not TicketPolicy.can_view(c['recipient'], c['ticket'])
|
||||
poll = client.get(reverse('staff_tickets:poll', args=[c['ticket'].public_id])).json()
|
||||
assert poll['status'] == c['ticket'].get_status_display() and poll['count'] == 1
|
||||
@@ -0,0 +1,239 @@
|
||||
import pytest
|
||||
import re
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.attachments.models import Attachment
|
||||
from apps.messaging.models import Message
|
||||
from apps.notifications.models import NotificationJob
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from apps.tickets.models import MessageShare, TicketAccessGrant, TicketCategory, TicketEvent
|
||||
from apps.tickets.policies import TicketPolicy
|
||||
from apps.tickets.selectors import staff_ticket_rows
|
||||
from apps.tickets.services import create_ticket
|
||||
from apps.tickets.sharing import revoke_staff_share, share_staff_message, share_staff_ticket
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def make_sharing_case():
|
||||
sales = OrganizationalUnit.objects.create(name="فروش اشتراک", slug="sharing-sales")
|
||||
finance = OrganizationalUnit.objects.create(name="مالی اشتراک", slug="sharing-finance")
|
||||
users = {}
|
||||
for index, name in enumerate(("sender", "recipient", "outsider"), 1):
|
||||
user = User.objects.create_user(username=f"sharing-{name}", mobile=f"0912777000{index}", password="SharingTest123!", account_type="STAFF")
|
||||
StaffProfile.objects.create(user=user, display_name=f"کارشناس {name}", primary_unit=sales if name == "sender" else finance)
|
||||
users[name] = user
|
||||
users["sender"].user_permissions.add(Permission.objects.get(content_type__app_label="tickets", codename="share_ticket"))
|
||||
customer = User.objects.create_user(username="sharing-customer", mobile="09127770004", account_type="CUSTOMER")
|
||||
category = TicketCategory.objects.create(unit=sales, title="اشتراک", slug="sharing-category")
|
||||
ticket = create_ticket(customer=customer, category=category, selected_expert=users["sender"], subject="موضوع محرمانه کل تیکت", body="مکاتبه محرمانه قبل")
|
||||
selected = Message.objects.create(ticket=ticket, author=customer, body="رسید منتخب برای بررسی مالی")
|
||||
Message.objects.create(ticket=ticket, author=users["sender"], body="مکاتبه محرمانه بعد", is_internal=True)
|
||||
return {**users, "customer": customer, "ticket": ticket, "selected": selected}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sharing_case():
|
||||
return make_sharing_case()
|
||||
|
||||
|
||||
def attachment_for(message, content=b"private receipt", filename="receipt.pdf"):
|
||||
return Attachment.objects.create(message=message, file=SimpleUploadedFile(filename, content), original_name=filename,
|
||||
stored_name=filename, relative_path=filename, attachment_type="PDF", mime_type="application/pdf", extension=".pdf", file_size=len(content), uploaded_by=message.author)
|
||||
|
||||
|
||||
def test_ticket_sharing_records_first_open_updates_and_revocation(client, sharing_case):
|
||||
c = sharing_case
|
||||
url = reverse("staff_tickets:share", args=[c["ticket"].public_id])
|
||||
client.force_login(c["sender"])
|
||||
response = client.post(url, {"expert": c["recipient"].staff_profile.pk, "permission_level": "VIEW_ONLY", "note": "بررسی کل پرونده"})
|
||||
assert response.status_code == 302
|
||||
grant = TicketAccessGrant.objects.get(ticket=c["ticket"], user=c["recipient"])
|
||||
assert grant.first_viewed_at is None and grant.view_count == 0
|
||||
client.force_login(c["recipient"])
|
||||
assert client.get(reverse("staff_tickets:shares")).status_code == 302
|
||||
grant.refresh_from_db()
|
||||
assert grant.first_viewed_at is None # Listing is not evidence of opening.
|
||||
detail = reverse("staff_tickets:detail", args=[c["ticket"].public_id])
|
||||
assert client.get(detail).status_code == 200
|
||||
assert client.get(detail).status_code == 200
|
||||
grant.refresh_from_db()
|
||||
first_view = grant.first_viewed_at
|
||||
assert first_view and grant.last_viewed_at >= first_view and grant.view_count == 2
|
||||
assert c["ticket"].events.filter(event_type=TicketEvent.Type.SHARE_OPENED).count() == 1
|
||||
client.force_login(c["sender"])
|
||||
audit = client.get(detail).content.decode()
|
||||
assert "دیدهشده" in audit and "کارشناس recipient" in audit and "بررسی کل پرونده" in audit
|
||||
client.post(url, {"expert": c["recipient"].staff_profile.pk, "permission_level": "REPLY", "note": "توضیح جدید"})
|
||||
grant.refresh_from_db()
|
||||
assert grant.permission_level == "REPLY" and grant.first_viewed_at == first_view
|
||||
assert c["ticket"].events.filter(event_type=TicketEvent.Type.SHARED).count() == 2
|
||||
client.post(reverse("staff_tickets:revoke", args=[c["ticket"].public_id, c["recipient"].public_id]))
|
||||
grant.refresh_from_db()
|
||||
assert grant.revoked_at and grant.revoked_by == c["sender"]
|
||||
assert not TicketPolicy.can_view(c["recipient"], c["ticket"])
|
||||
new = share_staff_ticket(c["ticket"], c["sender"], c["recipient"], "VIEW_ONLY")
|
||||
assert new.pk != grant.pk and new.first_viewed_at is None
|
||||
|
||||
|
||||
def test_one_message_share_never_grants_ticket_or_neighbor_access(client, sharing_case):
|
||||
c = sharing_case
|
||||
client.force_login(c["customer"])
|
||||
customer_url = reverse("tickets:detail", args=[c["ticket"].public_id])
|
||||
def stable_html(response):
|
||||
# Django masks CSRF tokens with fresh randomness on every render.
|
||||
return re.sub(r'(?<=["\x27])[A-Za-z0-9]{64}(?=["\x27])', 'CSRF_TOKEN', response.content.decode())
|
||||
before = stable_html(client.get(customer_url))
|
||||
jobs_before = NotificationJob.objects.count()
|
||||
grant = share_staff_message(c["selected"], c["sender"], c["recipient"], "بررسی رسید")
|
||||
assert not TicketAccessGrant.objects.filter(ticket=c["ticket"], user=c["recipient"]).exists()
|
||||
assert not TicketPolicy.can_view(c["recipient"], c["ticket"])
|
||||
assert not staff_ticket_rows(c["recipient"]).filter(pk=c["ticket"].pk).exists()
|
||||
assert NotificationJob.objects.count() == jobs_before
|
||||
assert stable_html(client.get(customer_url)) == before
|
||||
client.force_login(c["recipient"])
|
||||
assert client.get(reverse("staff_tickets:shares_count")).json()["unread_shares"] == 1
|
||||
inbox = client.get(reverse("staff_tickets:list"), {"tab": "shared"}).content.decode()
|
||||
assert c["ticket"].subject in inbox
|
||||
assert grant.first_viewed_at is None
|
||||
assert client.get(reverse("staff_tickets:shared_message", args=[grant.public_id])).status_code == 302
|
||||
detail = reverse("staff_tickets:detail", args=[c["ticket"].public_id])
|
||||
page = client.get(detail)
|
||||
body = page.content.decode()
|
||||
assert c["selected"].body in body and "بررسی رسید" in body
|
||||
assert "مکاتبه محرمانه قبل" not in body and "مکاتبه محرمانه بعد" not in body
|
||||
assert c["ticket"].subject in body and c["customer"].mobile in body
|
||||
assert 'id="ticket-actions"' in body and 'id="customer-info"' in body
|
||||
assert reverse("messages:revisions", args=[c["selected"].public_id]) not in body
|
||||
read_url = reverse("staff_tickets:read_shares", args=[c["ticket"].public_id])
|
||||
grant.refresh_from_db()
|
||||
assert grant.first_viewed_at is None
|
||||
assert client.post(read_url, {"message_ids": [str(c["selected"].public_id)]}).json()["unread_shares"] == 0
|
||||
Message.objects.create(ticket=c["ticket"], author=c["sender"], body="مکاتبه آینده محرمانه")
|
||||
assert "مکاتبه آینده محرمانه" not in client.get(detail).content.decode()
|
||||
grant.refresh_from_db()
|
||||
assert grant.first_viewed_at and grant.view_count == 1
|
||||
assert client.get(reverse("staff_tickets:shares_count")).json()["unread_shares"] == 0
|
||||
assert c["ticket"].events.filter(event_type=TicketEvent.Type.MESSAGE_SHARE_OPENED).count() == 1
|
||||
for route in ("detail", "messages", "poll"):
|
||||
assert client.get(reverse(f"staff_tickets:{route}", args=[c["ticket"].public_id])).status_code == 200
|
||||
assert client.post(reverse("staff_tickets:reply", args=[c["ticket"].public_id]), {"body": "unauthorized reply"}).status_code in (302, 403)
|
||||
assert not c["ticket"].ticket_messages.filter(body="unauthorized reply").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("download", [False, True])
|
||||
def test_shared_attachment_is_scoped_to_message_and_revoked_immediately(client, sharing_case, settings, tmp_path, download):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
settings.USE_NGINX_MEDIA = False
|
||||
c = sharing_case
|
||||
chosen = attachment_for(c["selected"])
|
||||
other = attachment_for(c["ticket"].ticket_messages.first(), b"other secret", "other.pdf")
|
||||
grant = share_staff_message(c["selected"], c["sender"], c["recipient"])
|
||||
route = "shared_message_attachment_download" if download else "shared_message_attachment"
|
||||
url = reverse(f"staff_tickets:{route}", args=[grant.public_id, chosen.public_id])
|
||||
client.force_login(c["recipient"])
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200 and b"".join(response.streaming_content) == b"private receipt"
|
||||
assert "no-store" in response["Cache-Control"]
|
||||
assert client.get(reverse(f"staff_tickets:{route}", args=[grant.public_id, other.public_id])).status_code == 404
|
||||
assert client.get(reverse("attachments:view", args=[chosen.public_id])).status_code == 403
|
||||
client.force_login(c["outsider"])
|
||||
assert client.get(url).status_code == 403
|
||||
revoke_staff_share(grant, c["sender"])
|
||||
client.force_login(c["recipient"])
|
||||
assert client.get(url).status_code == 403
|
||||
assert client.get(reverse("staff_tickets:shared_message", args=[grant.public_id])).status_code == 403
|
||||
|
||||
|
||||
def test_deleted_message_and_inactive_recipient_cannot_open_share(client, sharing_case):
|
||||
c = sharing_case
|
||||
grant = share_staff_message(c["selected"], c["sender"], c["recipient"])
|
||||
url = reverse("staff_tickets:shared_message", args=[grant.public_id])
|
||||
client.force_login(c["recipient"])
|
||||
StaffProfile.objects.filter(user=c["recipient"]).update(is_active=False)
|
||||
assert client.get(url).status_code == 403
|
||||
StaffProfile.objects.filter(user=c["recipient"]).update(is_active=True)
|
||||
Message.objects.filter(pk=c["selected"].pk).update(deleted_at=timezone.now())
|
||||
assert client.get(url).status_code == 403
|
||||
assert "بررسی رسید" not in client.get(reverse("staff_tickets:shares")).content.decode()
|
||||
with pytest.raises(ValidationError):
|
||||
share_staff_message(c["selected"], c["sender"], c["recipient"])
|
||||
|
||||
|
||||
def test_share_operations_validate_recipients_authority_method_and_csrf(client, sharing_case):
|
||||
c = sharing_case
|
||||
url = reverse("staff_tickets:share_message", args=[c["ticket"].public_id, c["selected"].public_id])
|
||||
client.force_login(c["sender"])
|
||||
assert client.get(url).status_code == 405
|
||||
for target in (c["sender"], c["customer"]):
|
||||
value = getattr(target, "staff_profile", None)
|
||||
client.post(url, {"expert": value.pk if value else 999999, "note": "bad"})
|
||||
assert MessageShare.objects.count() == 0
|
||||
StaffProfile.objects.filter(user=c["recipient"]).update(is_active=False)
|
||||
client.post(url, {"expert": c["recipient"].staff_profile.pk})
|
||||
assert MessageShare.objects.count() == 0
|
||||
StaffProfile.objects.filter(user=c["recipient"]).update(is_active=True)
|
||||
csrf_client = Client(enforce_csrf_checks=True)
|
||||
csrf_client.force_login(c["sender"])
|
||||
assert csrf_client.post(url, {"expert": c["recipient"].staff_profile.pk}).status_code == 403
|
||||
client.force_login(c["outsider"])
|
||||
assert client.post(url, {"expert": c["recipient"].staff_profile.pk}).status_code == 403
|
||||
client.force_login(c["customer"])
|
||||
assert client.post(url, {"expert": c["recipient"].staff_profile.pk}).status_code == 403
|
||||
assert MessageShare.objects.count() == 0
|
||||
|
||||
|
||||
def test_duplicate_share_is_idempotent_and_regrant_preserves_history(client, sharing_case):
|
||||
c = sharing_case
|
||||
first = share_staff_message(c["selected"], c["sender"], c["recipient"], "رسید")
|
||||
duplicate = share_staff_message(c["selected"], c["sender"], c["recipient"], "رسید")
|
||||
assert duplicate.pk == first.pk
|
||||
assert c["ticket"].events.filter(event_type=TicketEvent.Type.MESSAGE_SHARED).count() == 1
|
||||
assert revoke_staff_share(first, c["sender"])
|
||||
assert not revoke_staff_share(first, c["sender"])
|
||||
new = share_staff_message(c["selected"], c["sender"], c["recipient"], "ارسال مجدد")
|
||||
assert new.pk != first.pk and new.first_viewed_at is None
|
||||
first.refresh_from_db()
|
||||
assert first.revoked_at and first.note == "رسید"
|
||||
client.force_login(c["sender"])
|
||||
body = client.get(reverse("staff_tickets:detail", args=[c["ticket"].public_id])).content.decode()
|
||||
assert "لغوشده" in body and "ارسال مجدد" in body and "هنوز دیده نشده" in body
|
||||
|
||||
|
||||
def test_staff_message_sharing_controls_survive_polling_and_pagination(client, sharing_case):
|
||||
c = sharing_case
|
||||
for index in range(53):
|
||||
Message.objects.create(ticket=c["ticket"], author=c["customer"], body=f"پیام شماره {index}")
|
||||
client.force_login(c["sender"])
|
||||
detail = client.get(reverse("staff_tickets:detail", args=[c["ticket"].public_id]))
|
||||
assert "اشتراک فقط این پیام" in detail.content.decode()
|
||||
before = detail.context["older_cursor"]
|
||||
older = client.get(reverse("staff_tickets:messages", args=[c["ticket"].public_id]), {"before": before})
|
||||
assert "اشتراک فقط این پیام" in older.content.decode()
|
||||
poll = client.get(reverse("staff_tickets:poll", args=[c["ticket"].public_id])).json()
|
||||
assert "اشتراک فقط این پیام" in poll["html"]
|
||||
client.force_login(c["customer"])
|
||||
customer_poll = client.get(reverse("tickets:poll", args=[c["ticket"].public_id])).json()
|
||||
assert "اشتراک فقط این پیام" not in customer_poll["html"]
|
||||
assert "staff-message-share" not in customer_poll["html"]
|
||||
|
||||
|
||||
def test_message_share_cannot_be_used_under_another_ticket_or_by_another_user(client, sharing_case):
|
||||
c = sharing_case
|
||||
other = create_ticket(customer=c["customer"], category=c["ticket"].category, selected_expert=c["sender"], subject="تیکت دیگر", body="دیگر")
|
||||
client.force_login(c["sender"])
|
||||
assert client.post(reverse("staff_tickets:share_message", args=[other.public_id, c["selected"].public_id]), {"expert": c["recipient"].staff_profile.pk}).status_code == 404
|
||||
grant = share_staff_message(c["selected"], c["sender"], c["recipient"])
|
||||
assert client.post(reverse("staff_tickets:revoke_message_share", args=[other.public_id, grant.public_id])).status_code == 404
|
||||
for user in (c["sender"], c["outsider"], c["customer"]):
|
||||
client.force_login(user)
|
||||
assert client.get(reverse("staff_tickets:shared_message", args=[grant.public_id])).status_code == 403
|
||||
client.logout()
|
||||
assert client.get(reverse("staff_tickets:shared_message", args=[grant.public_id])).status_code == 302
|
||||
Reference in new issue
Block a user