share ticket and ticket message

This commit is contained in:
javad committed 2026-10-04 16:42:19 +03:30
1 parent 39e64854b8
commit 79fe1cddbb
35 files changed
+1742 -95

No files matched your search

+112 -48
View File
@@ -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
+56 -2
View File
@@ -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)
+4
View File
@@ -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)
+2 -1
View File
@@ -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"),
+14
View File
@@ -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
View File
@@ -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")]
+7 -4
View File
@@ -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 -3
View File
@@ -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(
+10 -5
View File
@@ -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})
+176
View File
@@ -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
+101
View File
@@ -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
+83
View File
@@ -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
+12 -1
View File
@@ -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
View File
@@ -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
+29
View File
@@ -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 تیکت به‌تنهایی جدول اعلان‌ها را ایجاد نمی‌کند.
+254
View File
@@ -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; }
+2
View File
@@ -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);
+54
View File
@@ -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
View File
@@ -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>
+26
View File
@@ -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>
+1
View File
@@ -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 %}
+1 -1
View File
@@ -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 %}
+7 -4
View File
@@ -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 %}
+57
View File
@@ -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()
+33
View File
@@ -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)
+35
View File
@@ -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")
+160
View File
@@ -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
+239
View File
@@ -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