diff --git a/PROJECT_TREE.txt b/PROJECT_TREE.txt index e0cf9e1..7794076 100644 --- a/PROJECT_TREE.txt +++ b/PROJECT_TREE.txt @@ -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 diff --git a/apps/attachments/admin.py b/apps/attachments/admin.py index c4c32c9..fb6566f 100644 --- a/apps/attachments/admin.py +++ b/apps/attachments/admin.py @@ -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) diff --git a/apps/attachments/models.py b/apps/attachments/models.py index 80267a5..143d8af 100644 --- a/apps/attachments/models.py +++ b/apps/attachments/models.py @@ -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) diff --git a/apps/core/context_processors.py b/apps/core/context_processors.py index 9de9861..ed0c99b 100644 --- a/apps/core/context_processors.py +++ b/apps/core/context_processors.py @@ -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"), diff --git a/apps/tickets/forms.py b/apps/tickets/forms.py index 7faf3ba..14f0f73 100644 --- a/apps/tickets/forms.py +++ b/apps/tickets/forms.py @@ -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":""})) diff --git a/apps/tickets/migrations/0017_ticketaccessgrant_first_viewed_at_and_more.py b/apps/tickets/migrations/0017_ticketaccessgrant_first_viewed_at_and_more.py new file mode 100644 index 0000000..a7c4ddb --- /dev/null +++ b/apps/tickets/migrations/0017_ticketaccessgrant_first_viewed_at_and_more.py @@ -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')], + }, + ), + ] diff --git a/apps/tickets/models.py b/apps/tickets/models.py index 6e736d3..a578753 100644 --- a/apps/tickets/models.py +++ b/apps/tickets/models.py @@ -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")] diff --git a/apps/tickets/policies.py b/apps/tickets/policies.py index 7cc4341..5ccaead 100644 --- a/apps/tickets/policies.py +++ b/apps/tickets/policies.py @@ -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): diff --git a/apps/tickets/selectors.py b/apps/tickets/selectors.py index 0625d87..b4ce7af 100644 --- a/apps/tickets/selectors.py +++ b/apps/tickets/selectors.py @@ -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( diff --git a/apps/tickets/services.py b/apps/tickets/services.py index 8dfd4c7..accf9b5 100644 --- a/apps/tickets/services.py +++ b/apps/tickets/services.py @@ -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}) diff --git a/apps/tickets/sharing.py b/apps/tickets/sharing.py new file mode 100644 index 0000000..c50f247 --- /dev/null +++ b/apps/tickets/sharing.py @@ -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 diff --git a/apps/tickets/sharing_views.py b/apps/tickets/sharing_views.py new file mode 100644 index 0000000..1a4b8a3 --- /dev/null +++ b/apps/tickets/sharing_views.py @@ -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 diff --git a/apps/tickets/staff_conversation.py b/apps/tickets/staff_conversation.py new file mode 100644 index 0000000..3d8a939 --- /dev/null +++ b/apps/tickets/staff_conversation.py @@ -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 diff --git a/apps/tickets/staff_urls.py b/apps/tickets/staff_urls.py index dcfebf0..708804c 100644 --- a/apps/tickets/staff_urls.py +++ b/apps/tickets/staff_urls.py @@ -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//edit/",views.staff_contact_edit,name="contact_edit"),path("contacts//call/",views.staff_contact_call,name="contact_call"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups//",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links//revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates//",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches//",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches//retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("dispatches//resolve/",views.staff_dispatch_resolve,name="dispatch_resolve"),path("experts//avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets//messages/older/",views.staff_messages,name="messages"),path("tickets//messages/poll/",views.staff_poll,name="poll"),path("tickets//sms//retry/",views.staff_sms_retry,name="sms_retry"),path("tickets//announcement/now/",views.staff_announcement_now,name="announcement_now"),path("tickets//",views.staff_detail,name="detail"),path("tickets//claim/",views.staff_claim,name="claim"),path("tickets//reply/",views.staff_reply,name="reply"),path("tickets//note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets//transfer/",views.staff_transfer,name="transfer"),path("tickets//share/",views.staff_share,name="share"),path("tickets//revoke//",views.staff_revoke,name="revoke"),path("tickets//status/",views.staff_status,name="status"),path("tickets//priority/",views.staff_priority,name="priority"),path("tickets//delete/",views.staff_delete,name="delete")] + +urlpatterns += [ + path("shares/count/", sharing_views.unread_count, name="shares_count"), + path("tickets//shares/read/", sharing_views.read_shared_messages, name="read_shares"), + path("shares/", sharing_views.inbox, name="shares"), + path("tickets//messages//share/", views.staff_share, name="share_message"), + path("tickets//message-shares//revoke/", sharing_views.revoke_message, name="revoke_message_share"), + path("shared-messages//", sharing_views.shared_message, name="shared_message"), + path("shared-messages//attachments//", sharing_views.shared_attachment, name="shared_message_attachment"), + path("shared-messages//attachments//download/", sharing_views.shared_attachment, {"download": True}, name="shared_message_attachment_download"), +] diff --git a/apps/tickets/views.py b/apps/tickets/views.py index b23f835..5c06535 100644 --- a/apps/tickets/views.py +++ b/apps/tickets/views.py @@ -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 diff --git a/docs/STAFF_SHARING.md b/docs/STAFF_SHARING.md new file mode 100644 index 0000000..5516125 --- /dev/null +++ b/docs/STAFF_SHARING.md @@ -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 تیکت به‌تنهایی جدول اعلان‌ها را ایجاد نمی‌کند. diff --git a/static/css/staff-sharing.css b/static/css/staff-sharing.css new file mode 100644 index 0000000..da6cbb9 --- /dev/null +++ b/static/css/staff-sharing.css @@ -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; } diff --git a/static/js/conversation.js b/static/js/conversation.js index 41df8dc..768cd1c 100644 --- a/static/js/conversation.js +++ b/static/js/conversation.js @@ -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); diff --git a/static/js/staff-sharing.js b/static/js/staff-sharing.js new file mode 100644 index 0000000..8f11c86 --- /dev/null +++ b/static/js/staff-sharing.js @@ -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(); +})(); diff --git a/templates/base.html b/templates/base.html index 26dccaa..b10f6c8 100644 --- a/templates/base.html +++ b/templates/base.html @@ -29,7 +29,7 @@ ◌صف جدید ↺در انتظار پاسخ من{% if counts.waiting %}{{ counts.waiting }}{% endif %} ◎تیکت‌های من - ◇اشتراک‌ها + ◇اشتراک‌ها{{ staff_unread_shares }} {% if staff_operations_access.contacts %}♧مخاطبان{% endif %} {% if staff_operations_access.dispatches %}⇶ارسال گروهی{% endif %} {% if staff_operations_access.audit %}⌁ممیزی لینک‌ها{% endif %} @@ -53,7 +53,7 @@ ◌صف جدید ↺پاسخ من ◎تیکت‌های من - ◇اشتراک‌ها + ◇اشتراک‌ها{{ staff_unread_shares }} {% if staff_operations_access.contacts %}♧مخاطبان{% endif %} {% if staff_operations_access.dispatches %}⇶گروهی{% endif %} @@ -64,6 +64,7 @@ {% block pwa_install_prompt %}{% endblock %} {% if user.is_authenticated %}{% endif %} +{% if user.is_authenticated and user.account_type == 'STAFF' %}{% endif %} {% block extra_scripts %}{% endblock %} {% block otp_script %}{% endblock %} diff --git a/templates/tickets/_staff_message_share.html b/templates/tickets/_staff_message_share.html new file mode 100644 index 0000000..11f9fc0 --- /dev/null +++ b/templates/tickets/_staff_message_share.html @@ -0,0 +1,18 @@ + {% if can_share_message and not item.deleted_at %} +
+ {% include 'tickets/_staff_share_icon.html' %}{% if item.staff_message_shares %}{% endif %} +
+
اشتراک پیامفقط همین پیام و پیوست‌ها
+ {% if item.staff_message_shares %} +
{% for grant in item.staff_message_shares %}{% include 'tickets/_staff_share_audit_row.html' with is_message=True inline=True %}{% endfor %}
+
+ اشتراک جدید{% include 'tickets/_staff_message_share_form.html' %}
+ {% else %}{% include 'tickets/_staff_message_share_form.html' %}{% endif %} + {% if item.staff_share_events %} + {% load jalali %} +
سوابق{{ item.staff_share_events|length }} + {% for event in item.staff_share_events %}{% endfor %} +
+ {% endif %} +
+
+ {% endif %} diff --git a/templates/tickets/_staff_message_share_form.html b/templates/tickets/_staff_message_share_form.html new file mode 100644 index 0000000..1c533f1 --- /dev/null +++ b/templates/tickets/_staff_message_share_form.html @@ -0,0 +1,7 @@ +
+ {% csrf_token %} + + +
افزودن توضیح
+ +
diff --git a/templates/tickets/_staff_messages.html b/templates/tickets/_staff_messages.html new file mode 100644 index 0000000..f22d66d --- /dev/null +++ b/templates/tickets/_staff_messages.html @@ -0,0 +1,26 @@ +{% load jalali %}{% for item in conversation %} +
+ +
+
{{ item.author_name }}{% if item.is_internal %}🔒 یادداشت داخلی{% endif %}
+ {% if item.deleted_at and not perms.messaging.view_deleted_message_content %} +

این پیام توسط فرستنده حذف شده است.

+ {% else %} + {% if item.body %}

{{ item.body|linebreaksbr }}

{% endif %} + {% if item.deleted_at %}حذف‌شده در {{ item.deleted_at|jalali_datetime }}{% endif %} + {% if item.edited_at %}ویرایش شده{% if perms.messaging.view_message_revisions and not limited_share %}تاریخچه{% endif %}{% endif %} + {% if item.attachments.all %}
{% for file in item.attachments.all %} + {% if file.attachment_type == 'IMAGE' %}
⇩ دانلود تصویر
+ {% elif file.attachment_type == 'VOICE' %}
🎙 پیام صوتیدریافت فایل
+ {% else %}⇩{{ file.original_name }}{{ file.get_attachment_type_display }} · {{ file.file_size|filesizeformat }}{% endif %} + {% endfor %}
{% endif %} + {% endif %} + {% if item.received_share %}{% include "tickets/_staff_share_icon.html" %} اشتراک توسط {{ item.received_share.granted_by.staff_profile.display_name|default:item.received_share.granted_by.username }} · {% if item.received_share.note %}
ⓘ{{ item.received_share.note }}
{% endif %}
{% endif %} + +
+
+{% empty %}
✦

هنوز مکاتبه‌ای ثبت نشده است

شرح کامل درخواست یا پاسخ خود را در فرم پایین ثبت کنید.

{% endfor %} diff --git a/templates/tickets/_staff_older_messages.html b/templates/tickets/_staff_older_messages.html new file mode 100644 index 0000000..480a7ca --- /dev/null +++ b/templates/tickets/_staff_older_messages.html @@ -0,0 +1,2 @@ +{% if has_older %}{% endif %} +{% include 'tickets/_staff_messages.html' %} diff --git a/templates/tickets/_staff_share_audit_row.html b/templates/tickets/_staff_share_audit_row.html new file mode 100644 index 0000000..84a6802 --- /dev/null +++ b/templates/tickets/_staff_share_audit_row.html @@ -0,0 +1,17 @@ +{% load jalali %} + diff --git a/templates/tickets/_staff_share_icon.html b/templates/tickets/_staff_share_icon.html new file mode 100644 index 0000000..83dfd72 --- /dev/null +++ b/templates/tickets/_staff_share_icon.html @@ -0,0 +1 @@ + diff --git a/templates/tickets/_staff_sharing_audit.html b/templates/tickets/_staff_sharing_audit.html new file mode 100644 index 0000000..4478807 --- /dev/null +++ b/templates/tickets/_staff_sharing_audit.html @@ -0,0 +1,36 @@ +{% load jalali %} +{% if can_share %} +
+ + + پیگیری اشتراک‌هاوضعیت مشاهده و سوابق + + +
+

«دیده‌شده» یعنی بازکردن صفحه، نه تأیید بررسی. لغو اشتراک، دسترسی مستقل سازمانی را حذف نمی‌کند.

+
+ اشتراک کل تیکت{{ grants|length }} + {% for grant in grants %}{% include 'tickets/_staff_share_audit_row.html' with is_message=False %}{% empty %}

اشتراکی ثبت نشده است.

{% endfor %} +
+
+ اشتراک پیام‌ها{{ message_shares|length }} + {% for grant in message_shares %}{% include 'tickets/_staff_share_audit_row.html' with is_message=True %}{% empty %}

پیامی به اشتراک گذاشته نشده است.

{% endfor %} +
+
+ سوابق اشتراک و انتقال{{ sharing_events|length }} +

۱۰۰ رویداد اخیر

+ {% for event in sharing_events %} + + {% empty %}

رویدادی ثبت نشده است.

{% endfor %} +
+
+
+{% endif %} diff --git a/templates/tickets/_staff_ticket_results.html b/templates/tickets/_staff_ticket_results.html index 4e8140a..014be68 100644 --- a/templates/tickets/_staff_ticket_results.html +++ b/templates/tickets/_staff_ticket_results.html @@ -4,7 +4,7 @@
{{ ticket.ticket_number }}{% if ticket.status == 'WAITING_STAFF' or ticket.status == 'REOPENED' %}نیازمند پاسخ{% endif %}

{{ ticket.subject }}

- {% 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 %}

قفل‌شده توسط {{ ticket.owner.staff_profile.display_name }} · جزئیات پس از انتقال قابل مشاهده است

{% else %}
{{ ticket.category }}{{ ticket.customer.customer_profile|default:ticket.customer.mobile }}{% if ticket.city %}{{ ticket.city }}، {{ ticket.province }}{% elif ticket.province %}{{ ticket.province }}{% else %}موقعیت ثبت نشده{% endif %}
diff --git a/templates/tickets/_staff_ticket_share.html b/templates/tickets/_staff_ticket_share.html new file mode 100644 index 0000000..d21aae7 --- /dev/null +++ b/templates/tickets/_staff_ticket_share.html @@ -0,0 +1,15 @@ +{% if can_share %} +
+ {% include 'tickets/_staff_share_icon.html' %}اشتراک با کارشناس +
+

دسترسی به کل تیکت. برای ارسال یک رسید یا تصویر، «اشتراک فقط این پیام» را انتخاب کنید.

+
+ {% csrf_token %} + {{ share_form.expert }} + {{ share_form.permission_level }} + {{ share_form.note }} + +
+
+
+{% endif %} diff --git a/templates/tickets/staff_detail.html b/templates/tickets/staff_detail.html index 62232ad..0227e6d 100644 --- a/templates/tickets/staff_detail.html +++ b/templates/tickets/staff_detail.html @@ -1,7 +1,7 @@ {% extends 'base.html' %}{% load static jalali %} {% block title %}{{ page_title }}{% endblock %} {% block content %} -
+
→
{{ ticket.ticket_number }}

{{ ticket.subject }}

{{ ticket.get_status_display }}{% include 'tickets/_voip_call_button.html' with customer=ticket.customer %}{% if can_claim %}
{% csrf_token %}
{% endif %}
@@ -20,14 +20,15 @@ {% if sms_jobs %}

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

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

{{ sms_job.delivery_description }}

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

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

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

عملیات تیکت

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

عملیات تیکت

{% if can_share %}
{% csrf_token %}
{% endif %}{% if can_share %}
{% csrf_token %}
{% endif %}{% if can_transfer %}
انتقال تیکت
{% csrf_token %}{{ transfer_form.as_p }}
{% endif %}{% include "tickets/_staff_ticket_share.html" %}
+ {% include 'tickets/_staff_sharing_audit.html' %} {% if can_delete %}

حذف کامل تیکت

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

{% csrf_token %}
{% endif %}
سوابق رسیدگی و مکاتبات
{{ conversation|length }} مکاتبه
-
{% if has_older %}{% endif %}{% include 'tickets/_messages.html' %}
+
{% if has_older %}{% endif %}{% include 'tickets/_staff_messages.html' %}
-
+ {% if not limited_share %}
{% if perms.messaging.add_internal_note %}{% endif %}
{% csrf_token %} @@ -39,10 +40,12 @@
پیش از ثبت، متن و پیوست‌ها را بازبینی کنید.
+ {% endif %}
{% include 'tickets/_image_viewer.html' %} + {% endblock %} diff --git a/tests/e2e/pages/sharing.py b/tests/e2e/pages/sharing.py new file mode 100644 index 0000000..52334e1 --- /dev/null +++ b/tests/e2e/pages/sharing.py @@ -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() diff --git a/tests/e2e/test_staff_sharing_e2e.py b/tests/e2e/test_staff_sharing_e2e.py new file mode 100644 index 0000000..0c81a70 --- /dev/null +++ b/tests/e2e/test_staff_sharing_e2e.py @@ -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) diff --git a/tests/test_staff_inline_sharing.py b/tests/test_staff_inline_sharing.py new file mode 100644 index 0000000..9b6e4b8 --- /dev/null +++ b/tests/test_staff_inline_sharing.py @@ -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") diff --git a/tests/test_staff_shared_queue.py b/tests/test_staff_shared_queue.py new file mode 100644 index 0000000..05a96e5 --- /dev/null +++ b/tests/test_staff_shared_queue.py @@ -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 diff --git a/tests/test_staff_sharing.py b/tests/test_staff_sharing.py new file mode 100644 index 0000000..cf4d945 --- /dev/null +++ b/tests/test_staff_sharing.py @@ -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