diff --git a/src/Pipfile b/src/Pipfile index 9208fada5..9366423f1 100644 --- a/src/Pipfile +++ b/src/Pipfile @@ -31,6 +31,7 @@ gevent = "*" fred-epplib = {git = "https://github.com/cisagov/epplib.git", ref = "master"} pyzipper="*" tblib = "*" +django-admin-multiple-choice-list-filter = "*" [dev-packages] django-debug-toolbar = "*" diff --git a/src/Pipfile.lock b/src/Pipfile.lock index 4eb2c0fb3..a1c27e1db 100644 --- a/src/Pipfile.lock +++ b/src/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "082a951f15bb26a28f2dca7e0840fdf61518b3d90c42d77a310f982344cbd1dc" + "sha256": "52143c73ccc59cd3dd6a1294a9352dbae009ebfc6e3ca5d018b8484275e2b6f8" }, "pipfile-spec": 6, "requires": {}, @@ -24,28 +24,28 @@ }, "asgiref": { "hashes": [ - "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e", - "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed" + "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", + "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590" ], - "markers": "python_version >= '3.7'", - "version": "==3.7.2" + "markers": "python_version >= '3.8'", + "version": "==3.8.1" }, "boto3": { "hashes": [ - "sha256:300888f0c1b6f32f27f85a9aa876f50f46514ec619647af7e4d20db74d339714", - "sha256:b26928f9a21cf3649cea20a59061340f3294c6e7785ceb6e1a953eb8010dc3ba" + "sha256:7ce8c9a50af2f8a159a0dd86b40011d8dfdaba35005a118e51cd3ac72dc630f1", + "sha256:d786e7fbe3c4152866199786468a625dc77b9f27294cd7ad4f63cd2e0c927287" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.34.56" + "version": "==1.34.71" }, "botocore": { "hashes": [ - "sha256:bffeb71ab21d47d4ecf947d9bdb2fbd1b0bbd0c27742cea7cf0b77b701c41d9f", - "sha256:fff66e22a5589c2d58fba57d1d95c334ce771895e831f80365f6cff6453285ec" + "sha256:3bc9e23aee73fe6f097823d61f79a8877790436038101a83fa96c7593e8109f8", + "sha256:c58f9ed71af2ea53d24146187130541222d7de8c27eb87d23f15457e7b83d88b" ], "markers": "python_version >= '3.8'", - "version": "==1.34.56" + "version": "==1.34.71" }, "cachetools": { "hashes": [ @@ -295,6 +295,14 @@ "markers": "python_version >= '3.8'", "version": "==4.2.10" }, + "django-admin-multiple-choice-list-filter": { + "hashes": [ + "sha256:a5b82682ff752cf74bbc4fa3d562c99b9f9f80389961017e1ac63e08f22d8b9f", + "sha256:eab2ad5fad6df8ed8436cb6c1d0e67863453a9d30282b7fdcb7e45b2eb37ab6f" + ], + "index": "pypi", + "version": "==0.1.1" + }, "django-allow-cidr": { "hashes": [ "sha256:11126c5bb9df3a61ff9d97304856ba7e5b26d46c6d456709a6d9e28483bff47f", @@ -384,12 +392,12 @@ }, "faker": { "hashes": [ - "sha256:2456d674f40bd51eb3acbf85221277027822e529a90cc826453d9a25dff932b1", - "sha256:ea6f784c40730de0f77067e49e78cdd590efb00bec3d33f577492262206c17fc" + "sha256:998c29ee7d64429bd59204abffa9ba11f784fb26c7b9df4def78d1a70feb36a7", + "sha256:a5ddccbe97ab691fad6bd8036c31f5697cfaa550e62e000078d1935fa8a7ec2e" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==24.0.0" + "version": "==24.4.0" }, "fred-epplib": { "git": "https://github.com/cisagov/epplib.git", @@ -732,18 +740,18 @@ }, "packaging": { "hashes": [ - "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", - "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" + "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", + "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" ], "markers": "python_version >= '3.7'", - "version": "==23.2" + "version": "==24.0" }, "phonenumberslite": { "hashes": [ - "sha256:137d53d5d78dca30bc2becf81a3e2ac74deb8f0997e9bbe44de515ece4bd92bd", - "sha256:e1f4359bff90c86d1b52db0e726d3334df00cc7d9c9c2ef66561d5f7a774d4ba" + "sha256:4d92f4f9079bb83588dde45fd8a414bc13e4962886aa4d23576984196f4d83c2", + "sha256:7426bc46af3de5a800a4c8f33ab13e33225d2c8ed4fc52aa3c0380dadd8d7381" ], - "version": "==8.13.31" + "version": "==8.13.33" }, "psycopg2-binary": { "hashes": [ @@ -872,11 +880,11 @@ }, "pydantic": { "hashes": [ - "sha256:72c6034df47f46ccdf81869fddb81aade68056003900a8724a4f160700016a2a", - "sha256:e07805c4c7f5c6826e33a1d4c9d47950d7eaf34868e2690f8594d2e30241f11f" + "sha256:b1704e0847db01817624a6b86766967f552dd9dbf3afba4004409f908dcc84e6", + "sha256:cc46fce86607580867bdc3361ad462bab9c222ef042d3da86f2fb333e1d916c5" ], "markers": "python_version >= '3.8'", - "version": "==2.6.3" + "version": "==2.6.4" }, "pydantic-core": { "hashes": [ @@ -1014,19 +1022,19 @@ }, "s3transfer": { "hashes": [ - "sha256:3cdb40f5cfa6966e812209d0994f2a4709b561c88e90cf00c2696d2df4e56b2e", - "sha256:d0c8bbf672d5eebbe4e57945e23b972d963f07d82f661cabf678a5c88831595b" + "sha256:5683916b4c724f799e600f41dd9e10a9ff19871bf87623cc8f491cb4f5fa0a19", + "sha256:ceb252b11bcf87080fb7850a224fb6e05c8a776bab8f2b64b7f25b969464839d" ], "markers": "python_version >= '3.8'", - "version": "==0.10.0" + "version": "==0.10.1" }, "setuptools": { "hashes": [ - "sha256:02fa291a0471b3a18b2b2481ed902af520c69e8ae0919c13da936542754b4c56", - "sha256:5c0806c7d9af348e6dd3777b4f4dbb42c7ad85b190104837488eab9a7c945cf8" + "sha256:0ff4183f8f42cd8fa3acea16c45205521a4ef28f73c6391d8a25e92893134f2e", + "sha256:c21c49fb1042386df081cb5d86759792ab89efca84cf114889191cd09aacc80c" ], "markers": "python_version >= '3.8'", - "version": "==69.1.1" + "version": "==69.2.0" }, "six": { "hashes": [ @@ -1064,11 +1072,11 @@ }, "urllib3": { "hashes": [ - "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84", - "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e" + "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", + "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" ], - "markers": "python_version >= '3.7'", - "version": "==2.0.7" + "markers": "python_version >= '3.8'", + "version": "==2.2.1" }, "whitenoise": { "hashes": [ @@ -1133,20 +1141,20 @@ "develop": { "asgiref": { "hashes": [ - "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e", - "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed" + "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", + "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590" ], - "markers": "python_version >= '3.7'", - "version": "==3.7.2" + "markers": "python_version >= '3.8'", + "version": "==3.8.1" }, "bandit": { "hashes": [ - "sha256:17e60786a7ea3c9ec84569fd5aee09936d116cb0cb43151023258340dbffb7ed", - "sha256:527906bec6088cb499aae31bc962864b4e77569e9d529ee51df3a93b4b8ab28a" + "sha256:36de50f720856ab24a24dbaa5fee2c66050ed97c1477e0a1159deab1775eab6b", + "sha256:509f7af645bc0cd8fd4587abc1a038fc795636671ee8204d502b933aee44f381" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.7.7" + "version": "==1.7.8" }, "beautifulsoup4": { "hashes": [ @@ -1158,32 +1166,32 @@ }, "black": { "hashes": [ - "sha256:057c3dc602eaa6fdc451069bd027a1b2635028b575a6c3acfd63193ced20d9c8", - "sha256:08654d0797e65f2423f850fc8e16a0ce50925f9337fb4a4a176a7aa4026e63f8", - "sha256:163baf4ef40e6897a2a9b83890e59141cc8c2a98f2dda5080dc15c00ee1e62cd", - "sha256:1e08fb9a15c914b81dd734ddd7fb10513016e5ce7e6704bdd5e1251ceee51ac9", - "sha256:4dd76e9468d5536abd40ffbc7a247f83b2324f0c050556d9c371c2b9a9a95e31", - "sha256:4f9de21bafcba9683853f6c96c2d515e364aee631b178eaa5145fc1c61a3cc92", - "sha256:61a0391772490ddfb8a693c067df1ef5227257e72b0e4108482b8d41b5aee13f", - "sha256:6981eae48b3b33399c8757036c7f5d48a535b962a7c2310d19361edeef64ce29", - "sha256:7e53a8c630f71db01b28cd9602a1ada68c937cbf2c333e6ed041390d6968faf4", - "sha256:810d445ae6069ce64030c78ff6127cd9cd178a9ac3361435708b907d8a04c693", - "sha256:93601c2deb321b4bad8f95df408e3fb3943d85012dddb6121336b8e24a0d1218", - "sha256:992e451b04667116680cb88f63449267c13e1ad134f30087dec8527242e9862a", - "sha256:9db528bccb9e8e20c08e716b3b09c6bdd64da0dd129b11e160bf082d4642ac23", - "sha256:a0057f800de6acc4407fe75bb147b0c2b5cbb7c3ed110d3e5999cd01184d53b0", - "sha256:ba15742a13de85e9b8f3239c8f807723991fbfae24bad92d34a2b12e81904982", - "sha256:bce4f25c27c3435e4dace4815bcb2008b87e167e3bf4ee47ccdc5ce906eb4894", - "sha256:ca610d29415ee1a30a3f30fab7a8f4144e9d34c89a235d81292a1edb2b55f540", - "sha256:d533d5e3259720fdbc1b37444491b024003e012c5173f7d06825a77508085430", - "sha256:d84f29eb3ee44859052073b7636533ec995bd0f64e2fb43aeceefc70090e752b", - "sha256:e37c99f89929af50ffaf912454b3e3b47fd64109659026b678c091a4cd450fb2", - "sha256:e8a6ae970537e67830776488bca52000eaa37fa63b9988e8c487458d9cd5ace6", - "sha256:faf2ee02e6612577ba0181f4347bcbcf591eb122f7841ae5ba233d12c39dcb4d" + "sha256:2818cf72dfd5d289e48f37ccfa08b460bf469e67fb7c4abb07edc2e9f16fb63f", + "sha256:41622020d7120e01d377f74249e677039d20e6344ff5851de8a10f11f513bf93", + "sha256:4acf672def7eb1725f41f38bf6bf425c8237248bb0804faa3965c036f7672d11", + "sha256:4be5bb28e090456adfc1255e03967fb67ca846a03be7aadf6249096100ee32d0", + "sha256:4f1373a7808a8f135b774039f61d59e4be7eb56b2513d3d2f02a8b9365b8a8a9", + "sha256:56f52cfbd3dabe2798d76dbdd299faa046a901041faf2cf33288bc4e6dae57b5", + "sha256:65b76c275e4c1c5ce6e9870911384bff5ca31ab63d19c76811cb1fb162678213", + "sha256:65c02e4ea2ae09d16314d30912a58ada9a5c4fdfedf9512d23326128ac08ac3d", + "sha256:6905238a754ceb7788a73f02b45637d820b2f5478b20fec82ea865e4f5d4d9f7", + "sha256:79dcf34b33e38ed1b17434693763301d7ccbd1c5860674a8f871bd15139e7837", + "sha256:7bb041dca0d784697af4646d3b62ba4a6b028276ae878e53f6b4f74ddd6db99f", + "sha256:7d5e026f8da0322b5662fa7a8e752b3fa2dac1c1cbc213c3d7ff9bdd0ab12395", + "sha256:9f50ea1132e2189d8dff0115ab75b65590a3e97de1e143795adb4ce317934995", + "sha256:a0c9c4a0771afc6919578cec71ce82a3e31e054904e7197deacbc9382671c41f", + "sha256:aadf7a02d947936ee418777e0247ea114f78aff0d0959461057cae8a04f20597", + "sha256:b5991d523eee14756f3c8d5df5231550ae8993e2286b8014e2fdea7156ed0959", + "sha256:bf21b7b230718a5f08bd32d5e4f1db7fc8788345c8aea1d155fc17852b3410f5", + "sha256:c45f8dff244b3c431b36e3224b6be4a127c6aca780853574c00faf99258041eb", + "sha256:c7ed6668cbbfcd231fa0dc1b137d3e40c04c7f786e626b405c62bcd5db5857e4", + "sha256:d7de8d330763c66663661a1ffd432274a2f92f07feeddd89ffd085b5744f85e7", + "sha256:e19cb1c6365fd6dc38a6eae2dcb691d7d83935c10215aef8e6c38edee3f77abd", + "sha256:e2af80566f43c85f5797365077fb64a393861a3730bd110971ab7a0c94e873e7" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==24.2.0" + "version": "==24.3.0" }, "blinker": { "hashes": [ @@ -1195,12 +1203,12 @@ }, "boto3": { "hashes": [ - "sha256:300888f0c1b6f32f27f85a9aa876f50f46514ec619647af7e4d20db74d339714", - "sha256:b26928f9a21cf3649cea20a59061340f3294c6e7785ceb6e1a953eb8010dc3ba" + "sha256:7ce8c9a50af2f8a159a0dd86b40011d8dfdaba35005a118e51cd3ac72dc630f1", + "sha256:d786e7fbe3c4152866199786468a625dc77b9f27294cd7ad4f63cd2e0c927287" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.34.56" + "version": "==1.34.71" }, "boto3-mocking": { "hashes": [ @@ -1213,28 +1221,28 @@ }, "boto3-stubs": { "hashes": [ - "sha256:627f8eca69d832581ee1676d39df099a2a2e3a86d6b3ebd21c81c5f11ed6a6fa", - "sha256:a87e7ecbab6235ec371b4363027e57483bca349a9cd5c891f40db81dadfa273e" + "sha256:1be579780a39a75394db0c413594aec380afde86dbc7eb5eb086a97a25d3c995", + "sha256:c9959c48ee1b53e9d19e424898caacf365aaee34cee4c70f40692e2b47bc8099" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.34.56" + "version": "==1.34.71" }, "botocore": { "hashes": [ - "sha256:bffeb71ab21d47d4ecf947d9bdb2fbd1b0bbd0c27742cea7cf0b77b701c41d9f", - "sha256:fff66e22a5589c2d58fba57d1d95c334ce771895e831f80365f6cff6453285ec" + "sha256:3bc9e23aee73fe6f097823d61f79a8877790436038101a83fa96c7593e8109f8", + "sha256:c58f9ed71af2ea53d24146187130541222d7de8c27eb87d23f15457e7b83d88b" ], "markers": "python_version >= '3.8'", - "version": "==1.34.56" + "version": "==1.34.71" }, "botocore-stubs": { "hashes": [ - "sha256:018e001e3add5eb1828ef444b45fb8c9faf695e08334031bf2d96853cd9af703", - "sha256:25468ba6983987b704b1856bb155f297f576e6d4a690b021ab0c7122889ba907" + "sha256:0c3835c775db1387246c1ba8063b197604462fba8603d9b36b5dc60297197b2f", + "sha256:463248fd1d6e7b68a0c57bdd758d04c6bd0c5c2c3bfa81afdf9d64f0930b59bc" ], "markers": "python_version >= '3.8' and python_version < '4.0'", - "version": "==1.34.56" + "version": "==1.34.69" }, "click": { "hashes": [ @@ -1337,37 +1345,37 @@ }, "mypy": { "hashes": [ - "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6", - "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d", - "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02", - "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d", - "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3", - "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3", - "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3", - "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66", - "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259", - "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835", - "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd", - "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d", - "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8", - "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07", - "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b", - "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e", - "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6", - "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae", - "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9", - "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d", - "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a", - "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592", - "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218", - "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817", - "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4", - "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410", - "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55" + "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6", + "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913", + "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129", + "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc", + "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974", + "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374", + "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150", + "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03", + "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9", + "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02", + "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89", + "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2", + "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d", + "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3", + "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612", + "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e", + "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3", + "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e", + "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd", + "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04", + "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed", + "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185", + "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf", + "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b", + "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4", + "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f", + "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.8.0" + "version": "==1.9.0" }, "mypy-extensions": { "hashes": [ @@ -1387,11 +1395,11 @@ }, "packaging": { "hashes": [ - "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", - "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" + "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", + "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" ], "markers": "python_version >= '3.7'", - "version": "==23.2" + "version": "==24.0" }, "pathspec": { "hashes": [ @@ -1516,11 +1524,11 @@ }, "s3transfer": { "hashes": [ - "sha256:3cdb40f5cfa6966e812209d0994f2a4709b561c88e90cf00c2696d2df4e56b2e", - "sha256:d0c8bbf672d5eebbe4e57945e23b972d963f07d82f661cabf678a5c88831595b" + "sha256:5683916b4c724f799e600f41dd9e10a9ff19871bf87623cc8f491cb4f5fa0a19", + "sha256:ceb252b11bcf87080fb7850a224fb6e05c8a776bab8f2b64b7f25b969464839d" ], "markers": "python_version >= '3.8'", - "version": "==0.10.0" + "version": "==0.10.1" }, "six": { "hashes": [ @@ -1589,19 +1597,20 @@ }, "types-pyyaml": { "hashes": [ - "sha256:334373d392fde0fdf95af5c3f1661885fa10c52167b14593eb856289e1855062", - "sha256:c05bc6c158facb0676674b7f11fe3960db4f389718e19e62bd2b84d6205cfd24" + "sha256:a9e0f0f88dc835739b0c1ca51ee90d04ca2a897a71af79de9aec5f38cb0a5342", + "sha256:b845b06a1c7e54b8e5b4c683043de0d9caf205e7434b3edc678ff2411979b8f6" ], - "version": "==6.0.12.12" + "markers": "python_version >= '3.8'", + "version": "==6.0.12.20240311" }, "types-requests": { "hashes": [ - "sha256:a82807ec6ddce8f00fe0e949da6d6bc1fbf1715420218a9640d695f70a9e5a9b", - "sha256:f1721dba8385958f504a5386240b92de4734e047a08a40751c1654d1ac3349c5" + "sha256:47872893d65a38e282ee9f277a4ee50d1b28bd592040df7d1fdaffdf3779937d", + "sha256:b1c1b66abfb7fa79aae09097a811c4aa97130eb8831c60e47aee4ca344731ca5" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==2.31.0.20240218" + "version": "==2.31.0.20240311" }, "types-s3transfer": { "hashes": [ @@ -1622,11 +1631,11 @@ }, "urllib3": { "hashes": [ - "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84", - "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e" + "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", + "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" ], - "markers": "python_version >= '3.7'", - "version": "==2.0.7" + "markers": "python_version >= '3.8'", + "version": "==2.2.1" }, "waitress": { "hashes": [ diff --git a/src/registrar/admin.py b/src/registrar/admin.py index e4c71f8d5..8a2bc2141 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -27,6 +27,7 @@ from django_fsm import TransitionNotAllowed # type: ignore from django.utils.safestring import mark_safe from django.utils.html import escape from django.contrib.auth.forms import UserChangeForm, UsernameField +from django_admin_multiple_choice_list_filter.list_filters import MultipleChoiceListFilter from django.utils.translation import gettext_lazy as _ @@ -978,6 +979,18 @@ class DomainRequestAdmin(ListHeaderAdmin): """Custom domain requests admin class.""" form = DomainRequestAdminForm + change_form_template = "django/admin/domain_request_change_form.html" + + class StatusListFilter(MultipleChoiceListFilter): + """Custom status filter which is a multiple choice filter""" + + title = "Status" + parameter_name = "status__in" + + template = "django/admin/multiple_choice_list_filter.html" + + def lookups(self, request, model_admin): + return DomainRequest.DomainRequestStatus.choices class InvestigatorFilter(admin.SimpleListFilter): """Custom investigator filter that only displays users with the manager role""" @@ -1039,8 +1052,6 @@ class DomainRequestAdmin(ListHeaderAdmin): if self.value() == "0": return queryset.filter(Q(is_election_board=False) | Q(is_election_board=None)) - change_form_template = "django/admin/domain_request_change_form.html" - # Columns list_display = [ "requested_domain", @@ -1071,7 +1082,7 @@ class DomainRequestAdmin(ListHeaderAdmin): # Filters list_filter = ( - "status", + StatusListFilter, "generic_org_type", "federal_type", ElectionOfficeFilter, @@ -1353,6 +1364,23 @@ class DomainRequestAdmin(ListHeaderAdmin): "Cannot edit a domain request with a restricted creator.", ) + def changelist_view(self, request, extra_context=None): + """ + Override changelist_view to set the selected value of status filter. + """ + # use http_referer in order to distinguish between request as a link from another page + # and request as a removal of all filters + http_referer = request.META.get("HTTP_REFERER", "") + # if there are no query parameters in the request + # and the request is the initial request for this view + if not bool(request.GET) and request.path not in http_referer: + # modify the GET of the request to set the selected filter + modified_get = copy.deepcopy(request.GET) + modified_get["status__in"] = "submitted,in review,action needed" + request.GET = modified_get + response = super().changelist_view(request, extra_context=extra_context) + return response + def change_view(self, request, object_id, form_url="", extra_context=None): obj = self.get_object(request, object_id) self.display_restricted_warning(request, obj) @@ -1406,6 +1434,13 @@ class DomainInformationInline(admin.StackedInline): "submitter", ] + def has_change_permission(self, request, obj=None): + """Custom has_change_permission override so that we can specify that + analysts can edit this through this inline, but not through the model normally""" + if request.user.has_perm("registrar.analyst_access_permission"): + return True + return super().has_change_permission(request, obj) + def formfield_for_manytomany(self, db_field, request, **kwargs): """customize the behavior of formfields with manytomany relationships. the customized behavior includes sorting of objects in lists as well as customizing helper text""" diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 8c60c534f..9a92542b1 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -410,3 +410,60 @@ function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk, }); observer.observe({ type: "navigation" }); })(); + +/** An IIFE for toggling the submit bar on domain request forms +*/ +(function (){ + // Get a reference to the button element + const toggleButton = document.getElementById('submitRowToggle'); + const submitRowWrapper = document.querySelector('.submit-row-wrapper'); + + if (toggleButton) { + // Add event listener to toggle the class and update content on click + toggleButton.addEventListener('click', function() { + // Toggle the 'collapsed' class on the bar + submitRowWrapper.classList.toggle('submit-row-wrapper--collapsed'); + + // Get a reference to the span element inside the button + const spanElement = this.querySelector('span'); + + // Get a reference to the use element inside the button + const useElement = this.querySelector('use'); + + // Check if the span element text is 'Hide' + if (spanElement.textContent.trim() === 'Hide') { + // Update the span element text to 'Show' + spanElement.textContent = 'Show'; + + // Update the xlink:href attribute to expand_more + useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less'); + } else { + // Update the span element text to 'Hide' + spanElement.textContent = 'Hide'; + + // Update the xlink:href attribute to expand_less + useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more'); + } + }); + + // We have a scroll indicator at the end of the page. + // Observe it. Once it gets on screen, test to see if the row is collapsed. + // If it is, expand it. + const targetElement = document.querySelector(".scroll-indicator"); + const options = { + threshold: 1 + }; + // Create a new Intersection Observer + const observer = new IntersectionObserver((entries, observer) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + // Refresh reference to submit row wrapper and check if it's collapsed + if (document.querySelector('.submit-row-wrapper').classList.contains('submit-row-wrapper--collapsed')) { + toggleButton.click(); + } + } + }); + }, options); + observer.observe(targetElement); + } +})(); diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index 658ae5ca8..d86c0e07b 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -42,6 +42,7 @@ html[data-theme="light"] { --error-fg: #{$theme-color-error}; --message-success-bg: #{$theme-color-success-lighter}; + --checkbox-green: #{$theme-color-success-light}; // $theme-color-warning-lighter - yellow-5 --message-warning-bg: #faf3d1; --message-error-bg: #{$theme-color-error-lighter}; @@ -93,6 +94,7 @@ html[data-theme="light"] { --error-fg: #e35f5f; --message-success-bg: #006b1b; + --checkbox-green: #006b1b; --message-warning-bg: #583305; --message-error-bg: #570808; @@ -422,6 +424,102 @@ address.dja-address-contact-list { border: 1px solid var(--error-fg) !important; } +.choice-filter { + position: relative; + padding-left: 20px; + svg { + top: 4px; + } +} + +.choice-filter--checked { + svg:nth-child(1) { + background: var(--checkbox-green); + fill: var(--checkbox-green); + } + svg:nth-child(2) { + color: var(--body-loud-color); + } +} + +// Let's define this block of code once and use it for analysts over a certain screen size, +// superusers over another screen size. +@mixin submit-row-wrapper--collapsed-one-line(){ + &.submit-row-wrapper--collapsed { + transform: translate3d(0, 42px, 0); + } + .submit-row { + clear: none; + } + } + +// Sticky submit bar for domain requests on desktop +@media screen and (min-width:768px) { + .submit-row-wrapper { + position: fixed; + bottom: 0; + right: 0; + left: 338px; + background: var(--darkened-bg); + border-top-left-radius: 6px; + transition: transform .2s ease-out; + .submit-row { + margin-bottom: 0; + } + } + .submit-row-wrapper--collapsed { + // translate3d is more performant than translateY + // https://stackoverflow.com/questions/22111256/translate3d-vs-translate-performance + transform: translate3d(0, 88px, 0); + } + .submit-row-wrapper--collapsed-one-line { + @include submit-row-wrapper--collapsed-one-line(); + } + .submit-row { + clear: both; + } + .submit-row-toggle{ + display: inline-block; + position: absolute; + top: -30px; + right: 0; + background: var(--darkened-bg); + } + #submitRowToggle { + color: var(--body-fg); + } + .requested-domain-sticky { + max-width: 325px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } +} + +.visible-768 { + display: none; +} + +@media screen and (min-width:768px) { + .visible-768 { + display: block; + } +} + +@media screen and (min-width:935px) { + // Analyst only class + .submit-row-wrapper--analyst-view { + @include submit-row-wrapper--collapsed-one-line(); + } +} + +@media screen and (min-width:1256px) { + .submit-row-wrapper { + @include submit-row-wrapper--collapsed-one-line(); + } +} + +// Collapse button styles for fieldsets .module.collapse { margin-top: -35px; padding-top: 0; diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index 127db5589..212df992f 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -117,6 +117,10 @@ abbr[title] { } } +.visible-desktop { + display: none; +} + @include at-media(desktop) { .float-right-desktop { float: right; @@ -124,33 +128,15 @@ abbr[title] { .float-left-desktop { float: left; } + .visible-desktop { + display: block; + } } .flex-end { align-items: flex-end; } -// Only apply this custom wrapping to desktop -@include at-media(desktop) { - .usa-tooltip__body { - width: 350px; - white-space: normal; - text-align: center; - } -} - -@include at-media(tablet) { - .usa-tooltip__body { - width: 250px !important; - white-space: normal !important; - text-align: center !important; - } -} - -@include at-media(mobile) { - .usa-tooltip__body { - width: 250px !important; - white-space: normal !important; - text-align: center !important; - } +.cursor-pointer { + cursor: pointer; } diff --git a/src/registrar/assets/sass/_theme/_buttons.scss b/src/registrar/assets/sass/_theme/_buttons.scss index ef8635b95..1f5047503 100644 --- a/src/registrar/assets/sass/_theme/_buttons.scss +++ b/src/registrar/assets/sass/_theme/_buttons.scss @@ -138,6 +138,13 @@ a.usa-button--unstyled:visited { } } +.usa-button--unstyled--white, +.usa-button--unstyled--white:hover, +.usa-button--unstyled--white:focus, +.usa-button--unstyled--white:active { + color: color('white'); +} + // Cancel button used on the // DNSSEC main page // We want to center this button on mobile diff --git a/src/registrar/assets/sass/_theme/_tooltips.scss b/src/registrar/assets/sass/_theme/_tooltips.scss new file mode 100644 index 000000000..01348e1b1 --- /dev/null +++ b/src/registrar/assets/sass/_theme/_tooltips.scss @@ -0,0 +1,26 @@ +@use "uswds-core" as *; + +// Only apply this custom wrapping to desktop +@include at-media(desktop) { + .usa-tooltip__body { + width: 350px; + white-space: normal; + text-align: center; + } +} + +@include at-media(tablet) { + .usa-tooltip__body { + width: 250px !important; + white-space: normal !important; + text-align: center !important; + } +} + +@include at-media(mobile) { + .usa-tooltip__body { + width: 250px !important; + white-space: normal !important; + text-align: center !important; + } +} diff --git a/src/registrar/assets/sass/_theme/styles.scss b/src/registrar/assets/sass/_theme/styles.scss index 0239199e7..942501110 100644 --- a/src/registrar/assets/sass/_theme/styles.scss +++ b/src/registrar/assets/sass/_theme/styles.scss @@ -13,6 +13,7 @@ @forward "lists"; @forward "buttons"; @forward "forms"; +@forward "tooltips"; @forward "fieldsets"; @forward "alerts"; @forward "tables"; diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 646b7298f..54b65e83e 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -146,6 +146,8 @@ INSTALLED_APPS = [ # "puml_generator", # supports necessary headers for Django cross origin "corsheaders", + # library for multiple choice filters in django admin + "django_admin_multiple_choice_list_filter", ] # Middleware are routines for processing web requests. diff --git a/src/registrar/migrations/0081_create_groups_v10.py b/src/registrar/migrations/0081_create_groups_v10.py new file mode 100644 index 000000000..d65b6dbd2 --- /dev/null +++ b/src/registrar/migrations/0081_create_groups_v10.py @@ -0,0 +1,37 @@ +# This migration creates the create_full_access_group and create_cisa_analyst_group groups +# It is dependent on 0079 (which populates federal agencies) +# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS +# in the user_group model then: +# [NOT RECOMMENDED] +# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions +# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups +# step 3: fake run the latest migration in the migrations list +# [RECOMMENDED] +# Alternatively: +# step 1: duplicate the migration that loads data +# step 2: docker-compose exec app ./manage.py migrate + +from django.db import migrations +from registrar.models import UserGroup +from typing import Any + + +# For linting: RunPython expects a function reference, +# so let's give it one +def create_groups(apps, schema_editor) -> Any: + UserGroup.create_cisa_analyst_group(apps, schema_editor) + UserGroup.create_full_access_group(apps, schema_editor) + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0080_create_groups_v09"), + ] + + operations = [ + migrations.RunPython( + create_groups, + reverse_code=migrations.RunPython.noop, + atomic=True, + ), + ] diff --git a/src/registrar/models/user_group.py b/src/registrar/models/user_group.py index 2aa2f642e..e8636a462 100644 --- a/src/registrar/models/user_group.py +++ b/src/registrar/models/user_group.py @@ -26,11 +26,6 @@ class UserGroup(Group): "model": "contact", "permissions": ["change_contact"], }, - { - "app_label": "registrar", - "model": "domaininformation", - "permissions": ["change_domaininformation"], - }, { "app_label": "registrar", "model": "domainrequest", diff --git a/src/registrar/templates/admin/change_list.html b/src/registrar/templates/admin/change_list.html index 479b7b1ff..05c2d4e64 100644 --- a/src/registrar/templates/admin/change_list.html +++ b/src/registrar/templates/admin/change_list.html @@ -21,6 +21,8 @@ {% else %} election office = Yes {% endif %} + {% elif filter_param.parameter_name == 'status__in' %} + status in [{{ filter_param.parameter_value }}] {% else %} {{ filter_param.parameter_name }} = {{ filter_param.parameter_value }} {% endif %} diff --git a/src/registrar/templates/django/admin/domain_request_change_form.html b/src/registrar/templates/django/admin/domain_request_change_form.html index b0ee6e044..3b4fa7283 100644 --- a/src/registrar/templates/django/admin/domain_request_change_form.html +++ b/src/registrar/templates/django/admin/domain_request_change_form.html @@ -1,4 +1,5 @@ {% extends 'admin/change_form.html' %} +{% load custom_filters %} {% load i18n static %} {% block field_sets %} @@ -102,5 +103,25 @@ -{{ block.super }} + +{# submit-row-wrapper--analyst-view is a class that manages layout on certain screens for analysts only #} +
+ + + + + +

+ Requested domain: {{ original.requested_domain.name }} +

+ {{ block.super }} +
+ + + {% endblock %} diff --git a/src/registrar/templates/django/admin/multiple_choice_list_filter.html b/src/registrar/templates/django/admin/multiple_choice_list_filter.html new file mode 100644 index 000000000..66643f4ec --- /dev/null +++ b/src/registrar/templates/django/admin/multiple_choice_list_filter.html @@ -0,0 +1,37 @@ +{% load i18n %} +{% load static field_helpers url_helpers %} + + +

{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}

+ diff --git a/src/registrar/templates/domain_request_status.html b/src/registrar/templates/domain_request_status.html index 5b6ee8368..d3c1eab6d 100644 --- a/src/registrar/templates/domain_request_status.html +++ b/src/registrar/templates/domain_request_status.html @@ -41,7 +41,7 @@
-

Last updated: {{DomainRequest.updated_at|date:"F j, Y"}}

+

Last updated: {{DomainRequest.updated_at|date:"F j, Y"}}

{% include "includes/domain_request.html" %}

Withdraw request diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html index 7de159871..65da4ef6b 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -135,7 +135,7 @@ {% if invitation.status == invitation.DomainInvitationStatus.INVITED %}

- {% csrf_token %} + {% csrf_token %}
{% endif %} diff --git a/src/registrar/templates/includes/non-production-alert.html b/src/registrar/templates/includes/non-production-alert.html index 8e40892bc..911eea9d6 100644 --- a/src/registrar/templates/includes/non-production-alert.html +++ b/src/registrar/templates/includes/non-production-alert.html @@ -1,5 +1,8 @@ -
-
- Attention: You are on a test site. +
+
+
+ Attention: You are on a test site. +
-
+
\ No newline at end of file diff --git a/src/registrar/templatetags/custom_filters.py b/src/registrar/templatetags/custom_filters.py index dc42a5c1d..9fa5c9aa9 100644 --- a/src/registrar/templatetags/custom_filters.py +++ b/src/registrar/templatetags/custom_filters.py @@ -62,3 +62,8 @@ def get_organization_long_name(generic_org_type): return "Error" return long_form_type + + +@register.filter(name="has_permission") +def has_permission(user, permission): + return user.has_perm(permission) diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 9681b8cb7..b41c2c35c 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -693,6 +693,24 @@ class MockDb(TestCase): user=meoward_user, domain=self.domain_12, role=UserDomainRole.Roles.MANAGER ) + _, created = DomainInvitation.objects.get_or_create( + email=meoward_user.email, domain=self.domain_1, status=DomainInvitation.DomainInvitationStatus.RETRIEVED + ) + + _, created = DomainInvitation.objects.get_or_create( + email="woofwardthethird@rocks.com", + domain=self.domain_1, + status=DomainInvitation.DomainInvitationStatus.INVITED, + ) + + _, created = DomainInvitation.objects.get_or_create( + email="squeaker@rocks.com", domain=self.domain_2, status=DomainInvitation.DomainInvitationStatus.INVITED + ) + + _, created = DomainInvitation.objects.get_or_create( + email="squeaker@rocks.com", domain=self.domain_10, status=DomainInvitation.DomainInvitationStatus.INVITED + ) + with less_console_noise(): self.domain_request_1 = completed_domain_request( status=DomainRequest.DomainRequestStatus.STARTED, name="city1.gov" @@ -722,6 +740,7 @@ class MockDb(TestCase): DomainRequest.objects.all().delete() User.objects.all().delete() UserDomainRole.objects.all().delete() + DomainInvitation.objects.all().delete() def mock_user(): diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 46b5e104a..7b810a2c5 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -129,6 +129,83 @@ class TestDomainAdmin(MockEppLib, WebTest): ) mock_add_message.assert_has_calls([expected_call], 1) + @less_console_noise_decorator + def test_analyst_can_see_inline_domain_information_in_domain_change_form(self): + """Tests if an analyst can still see the inline domain information form""" + + # Create fake creator + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + ) + + # Create a fake domain request + _domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) + + # Creates a Domain and DomainInformation object + _domain_request.approve() + + domain_information = DomainInformation.objects.filter(domain_request=_domain_request).get() + domain_information.organization_name = "MonkeySeeMonkeyDo" + domain_information.save() + + # We use filter here rather than just domain_information.domain just to get the latest data. + domain = Domain.objects.filter(domain_info=domain_information).get() + + p = "userpass" + self.client.login(username="staffuser", password=p) + response = self.client.get( + "/admin/registrar/domain/{}/change/".format(domain.pk), + follow=True, + ) + + # Make sure the page loaded, and that we're on the right page + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + + # Test for data. We only need to test one since its all interconnected. + expected_organization_name = "MonkeySeeMonkeyDo" + self.assertContains(response, expected_organization_name) + + @less_console_noise_decorator + def test_admin_can_see_inline_domain_information_in_domain_change_form(self): + """Tests if an admin can still see the inline domain information form""" + # Create fake creator + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + ) + + # Create a fake domain request + _domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) + + # Creates a Domain and DomainInformation object + _domain_request.approve() + + domain_information = DomainInformation.objects.filter(domain_request=_domain_request).get() + domain_information.organization_name = "MonkeySeeMonkeyDo" + domain_information.save() + + # We use filter here rather than just domain_information.domain just to get the latest data. + domain = Domain.objects.filter(domain_info=domain_information).get() + + p = "adminpass" + self.client.login(username="superuser", password=p) + response = self.client.get( + "/admin/registrar/domain/{}/change/".format(domain.pk), + follow=True, + ) + + # Make sure the page loaded, and that we're on the right page + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + + # Test for data. We only need to test one since its all interconnected. + expected_organization_name = "MonkeySeeMonkeyDo" + self.assertContains(response, expected_organization_name) + @patch("registrar.admin.DomainAdmin._get_current_date", return_value=date(2024, 1, 1)) def test_extend_expiration_date_button_epp(self, mock_date_today): """ @@ -706,15 +783,26 @@ class TestDomainRequestAdmin(MockEppLib): with less_console_noise(): self.client.force_login(self.superuser) completed_domain_request() - response = self.client.get("/admin/registrar/domainrequest/") - # There are 4 template references to Federal (4) plus two references in the table - # for our actual domain request + response = self.client.get("/admin/registrar/domainrequest/?generic_org_type__exact=federal") + # There are 2 template references to Federal (4) and two in the results data + # of the request self.assertContains(response, "Federal", count=34) # This may be a bit more robust self.assertContains(response, 'Federal', count=1) # Now let's make sure the long description does not exist self.assertNotContains(response, "Federal: an agency of the U.S. government") + def test_default_status_in_domain_requests_list(self): + """ + Make sure the default status in admin is selected on the domain requests list page + """ + with less_console_noise(): + self.client.force_login(self.superuser) + completed_domain_request() + response = self.client.get("/admin/registrar/domainrequest/") + # The results are filtered by "status in [submitted,in review,action needed]" + self.assertContains(response, "status in [submitted,in review,action needed]", count=1) + def transition_state_and_send_email(self, domain_request, status, rejection_reason=None): """Helper method for the email test cases.""" @@ -1213,6 +1301,61 @@ class TestDomainRequestAdmin(MockEppLib): self.assertEqual(domain_request.requested_domain.name, domain_request.approved_domain.name) @less_console_noise_decorator + def test_sticky_submit_row(self): + """Test that the change_form template contains strings indicative of the customization + of the sticky submit bar. + + Also test that it does NOT contain a CSS class meant for analysts only when logged in as superuser.""" + + # make sure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + self.client.force_login(self.superuser) + + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + # Create a mock request + request = self.client.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) + + # Since we're using client to mock the request, we can only test against + # non-interpolated values + expected_content = "Requested domain:" + expected_content2 = '' + expected_content3 = '
' + not_expected_content = "submit-row-wrapper--analyst-view>" + self.assertContains(request, expected_content) + self.assertContains(request, expected_content2) + self.assertContains(request, expected_content3) + self.assertNotContains(request, not_expected_content) + + @less_console_noise_decorator + def test_sticky_submit_row_has_extra_class_for_analysts(self): + """Test that the change_form template contains strings indicative of the customization + of the sticky submit bar. + + Also test that it DOES contain a CSS class meant for analysts only when logged in as analyst.""" + + # make sure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + self.client.force_login(self.staffuser) + + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + # Create a mock request + request = self.client.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) + + # Since we're using client to mock the request, we can only test against + # non-interpolated values + expected_content = "Requested domain:" + expected_content2 = '' + expected_content3 = '
' + self.assertContains(request, expected_content) + self.assertContains(request, expected_content2) + self.assertContains(request, expected_content3) + def test_other_contacts_has_readonly_link(self): """Tests if the readonly other_contacts field has links""" @@ -1665,7 +1808,7 @@ class TestDomainRequestAdmin(MockEppLib): # Grab the current list of table filters readonly_fields = self.admin.get_list_filter(request) expected_fields = ( - "status", + DomainRequestAdmin.StatusListFilter, "generic_org_type", "federal_type", DomainRequestAdmin.ElectionOfficeFilter, @@ -1961,8 +2104,8 @@ class TestDomainInformationAdmin(TestCase): # Get the other contact other_contact = domain_info.other_contacts.all().first() - p = "userpass" - self.client.login(username="staffuser", password=p) + p = "adminpass" + self.client.login(username="superuser", password=p) response = self.client.get( "/admin/registrar/domaininformation/{}/change/".format(domain_info.pk), @@ -1983,6 +2126,44 @@ class TestDomainInformationAdmin(TestCase): expected_url = "Testy Tester" self.assertContains(response, expected_url) + @less_console_noise_decorator + def test_analyst_cant_access_domain_information(self): + """Ensures that analysts can't directly access the DomainInformation page through /admin""" + # Create fake creator + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + ) + + # Create a fake domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) + domain_request.approve() + domain_info = DomainInformation.objects.filter(domain=domain_request.approved_domain).get() + + p = "userpass" + self.client.login(username="staffuser", password=p) + response = self.client.get( + "/admin/registrar/domaininformation/{}/change/".format(domain_info.pk), + follow=True, + ) + + # Make sure that we're denied access + self.assertEqual(response.status_code, 403) + + # To make sure that its not a fluke, swap to an admin user + # and try to access the same page. This should succeed. + p = "adminpass" + self.client.login(username="superuser", password=p) + response = self.client.get( + "/admin/registrar/domaininformation/{}/change/".format(domain_info.pk), + follow=True, + ) + + # Make sure the page loaded, and that we're on the right page + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain_info.domain.name) + @less_console_noise_decorator def test_contact_fields_have_detail_table(self): """Tests if the contact fields have the detail table which displays title, email, and phone""" @@ -2006,8 +2187,8 @@ class TestDomainInformationAdmin(TestCase): domain_request.approve() domain_info = DomainInformation.objects.filter(domain=domain_request.approved_domain).get() - p = "userpass" - self.client.login(username="staffuser", password=p) + p = "adminpass" + self.client.login(username="superuser", password=p) response = self.client.get( "/admin/registrar/domaininformation/{}/change/".format(domain_info.pk), follow=True, diff --git a/src/registrar/tests/test_migrations.py b/src/registrar/tests/test_migrations.py index bf3b09d0d..add65105a 100644 --- a/src/registrar/tests/test_migrations.py +++ b/src/registrar/tests/test_migrations.py @@ -34,7 +34,6 @@ class TestGroups(TestCase): "view_logentry", "change_contact", "view_domain", - "change_domaininformation", "add_domaininvitation", "view_domaininvitation", "change_domainrequest", diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index 459ccde0f..6299349c5 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -9,10 +9,10 @@ from registrar.utility.csv_export import ( export_data_unmanaged_domains_to_csv, get_sliced_domains, get_sliced_requests, - write_domains_csv, + write_csv_for_domains, get_default_start_date, get_default_end_date, - write_requests_csv, + write_csv_for_requests, ) from django.core.management import call_command @@ -242,8 +242,13 @@ class ExportDataTest(MockDb, MockEppLib): } self.maxDiff = None # Call the export functions - write_domains_csv( - writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True + write_csv_for_domains( + writer, + columns, + sort_fields, + filter_condition, + should_get_domain_managers=False, + should_write_header=True, ) # Reset the CSV file's position to the beginning @@ -268,7 +273,7 @@ class ExportDataTest(MockDb, MockEppLib): expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() self.assertEqual(csv_content, expected_content) - def test_write_domains_csv(self): + def test_write_csv_for_domains(self): """Test that write_body returns the existing domain, test that sort by domain name works, test that filter works""" @@ -304,8 +309,13 @@ class ExportDataTest(MockDb, MockEppLib): ], } # Call the export functions - write_domains_csv( - writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True + write_csv_for_domains( + writer, + columns, + sort_fields, + filter_condition, + should_get_domain_managers=False, + should_write_header=True, ) # Reset the CSV file's position to the beginning csv_file.seek(0) @@ -357,8 +367,13 @@ class ExportDataTest(MockDb, MockEppLib): ], } # Call the export functions - write_domains_csv( - writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True + write_csv_for_domains( + writer, + columns, + sort_fields, + filter_condition, + should_get_domain_managers=False, + should_write_header=True, ) # Reset the CSV file's position to the beginning csv_file.seek(0) @@ -433,20 +448,20 @@ class ExportDataTest(MockDb, MockEppLib): } # Call the export functions - write_domains_csv( + write_csv_for_domains( writer, columns, sort_fields, filter_condition, - get_domain_managers=False, + should_get_domain_managers=False, should_write_header=True, ) - write_domains_csv( + write_csv_for_domains( writer, columns, sort_fields_for_deleted_domains, filter_conditions_for_deleted_domains, - get_domain_managers=False, + should_get_domain_managers=False, should_write_header=False, ) # Reset the CSV file's position to the beginning @@ -478,7 +493,12 @@ class ExportDataTest(MockDb, MockEppLib): def test_export_domains_to_writer_domain_managers(self): """Test that export_domains_to_writer returns the - expected domain managers.""" + expected domain managers. + + An invited user, woofwardthethird, should also be pulled into this report. + + squeaker@rocks.com is invited to domain2 (DNS_NEEDED) and domain10 (No managers). + She should show twice in this report but not in test_export_data_managed_domains_to_csv.""" with less_console_noise(): # Create a CSV file in memory @@ -508,8 +528,13 @@ class ExportDataTest(MockDb, MockEppLib): } self.maxDiff = None # Call the export functions - write_domains_csv( - writer, columns, sort_fields, filter_condition, get_domain_managers=True, should_write_header=True + write_csv_for_domains( + writer, + columns, + sort_fields, + filter_condition, + should_get_domain_managers=True, + should_write_header=True, ) # Reset the CSV file's position to the beginning @@ -521,14 +546,16 @@ class ExportDataTest(MockDb, MockEppLib): expected_content = ( "Domain name,Status,Expiration date,Domain type,Agency," "Organization name,City,State,AO,AO email," - "Security contact email,Domain manager email 1,Domain manager email 2,Domain manager email 3\n" - "adomain10.gov,Ready,,Federal,Armed Forces Retirement Home,,,, , ,\n" - "adomain2.gov,Dns needed,,Interstate,,,,, , , ,meoward@rocks.com\n" - "cdomain11.govReadyFederal-ExecutiveWorldWarICentennialCommissionmeoward@rocks.com\n" + "Security contact email,Domain manager 1,DM1 status,Domain manager 2,DM2 status," + "Domain manager 3,DM3 status,Domain manager 4,DM4 status\n" + "adomain10.gov,Ready,,Federal,Armed Forces Retirement Home,,,, , ,squeaker@rocks.com, I\n" + "adomain2.gov,Dns needed,,Interstate,,,,, , , ,meoward@rocks.com, R,squeaker@rocks.com, I\n" + "cdomain11.govReadyFederal-ExecutiveWorldWarICentennialCommissionmeoward@rocks.comR\n" "cdomain1.gov,Ready,,Federal - Executive,World War I Centennial Commission,,," - ", , , ,meoward@rocks.com,info@example.com,big_lebowski@dude.co\n" + ", , , ,meoward@rocks.com,R,info@example.com,R,big_lebowski@dude.co,R," + "woofwardthethird@rocks.com,I\n" "ddomain3.gov,On hold,,Federal,Armed Forces Retirement Home,,,, , , ,,\n" - "zdomain12.govReadyInterstatemeoward@rocks.com\n" + "zdomain12.govReadyInterstatemeoward@rocks.comR\n" ) # Normalize line endings and remove commas, # spaces and leading/trailing whitespace @@ -538,7 +565,9 @@ class ExportDataTest(MockDb, MockEppLib): def test_export_data_managed_domains_to_csv(self): """Test get counts for domains that have domain managers for two different dates, - get list of managed domains at end_date.""" + get list of managed domains at end_date. + + An invited user, woofwardthethird, should also be pulled into this report.""" with less_console_noise(): # Create a CSV file in memory @@ -564,10 +593,12 @@ class ExportDataTest(MockDb, MockEppLib): "Special district,School district,Election office\n" "3,2,1,0,0,0,0,0,0,0\n" "\n" - "Domain name,Domain type,Domain manager email 1,Domain manager email 2,Domain manager email 3\n" - "cdomain11.govFederal-Executivemeoward@rocks.com\n" - "cdomain1.gov,Federal - Executive,meoward@rocks.com,info@example.com,big_lebowski@dude.co\n" - "zdomain12.govInterstatemeoward@rocks.com\n" + "Domain name,Domain type,Domain manager 1,DM1 status,Domain manager 2,DM2 status," + "Domain manager 3,DM3 status,Domain manager 4,DM4 status\n" + "cdomain11.govFederal-Executivemeoward@rocks.com, R\n" + "cdomain1.gov,Federal - Executive,meoward@rocks.com,R,info@example.com,R," + "big_lebowski@dude.co,R,woofwardthethird@rocks.com,I\n" + "zdomain12.govInterstatemeoward@rocks.com,R\n" ) # Normalize line endings and remove commas, @@ -642,7 +673,7 @@ class ExportDataTest(MockDb, MockEppLib): "submission_date__lte": self.end_date, "submission_date__gte": self.start_date, } - write_requests_csv(writer, columns, sort_fields, filter_condition, should_write_header=True) + write_csv_for_requests(writer, columns, sort_fields, filter_condition, should_write_header=True) # Reset the CSV file's position to the beginning csv_file.seek(0) # Read the content into a variable @@ -686,7 +717,7 @@ class HelperFunctions(MockDb): "domain__first_ready__lte": self.end_date, } # Test with distinct - managed_domains_sliced_at_end_date = get_sliced_domains(filter_condition, True) + managed_domains_sliced_at_end_date = get_sliced_domains(filter_condition) expected_content = [3, 2, 1, 0, 0, 0, 0, 0, 0, 0] self.assertEqual(managed_domains_sliced_at_end_date, expected_content) diff --git a/src/registrar/tests/test_views_application.py b/src/registrar/tests/test_views_request.py similarity index 100% rename from src/registrar/tests/test_views_application.py rename to src/registrar/tests/test_views_request.py diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 7fc710827..949b0adcd 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -1,8 +1,8 @@ -from collections import Counter import csv import logging from datetime import datetime from registrar.models.domain import Domain +from registrar.models.domain_invitation import DomainInvitation from registrar.models.domain_request import DomainRequest from registrar.models.domain_information import DomainInformation from django.utils import timezone @@ -11,6 +11,7 @@ from django.db.models import F, Value, CharField from django.db.models.functions import Concat, Coalesce from registrar.models.public_contact import PublicContact +from registrar.models.user_domain_role import UserDomainRole from registrar.utility.enums import DefaultEmail logger = logging.getLogger(__name__) @@ -33,7 +34,6 @@ def get_domain_infos(filter_condition, sort_fields): """ domain_infos = ( DomainInformation.objects.select_related("domain", "authorizing_official") - .prefetch_related("domain__permissions") .filter(**filter_condition) .order_by(*sort_fields) .distinct() @@ -53,7 +53,14 @@ def get_domain_infos(filter_condition, sort_fields): return domain_infos_cleaned -def parse_domain_row(columns, domain_info: DomainInformation, security_emails_dict=None, get_domain_managers=False): +def parse_row_for_domain( + columns, + domain_info: DomainInformation, + dict_security_emails=None, + should_get_domain_managers=False, + dict_domain_invitations_with_invited_status=None, + dict_user_domain_roles=None, +): """Given a set of columns, generate a new row from cleaned column data""" # Domain should never be none when parsing this information @@ -65,8 +72,8 @@ def parse_domain_row(columns, domain_info: DomainInformation, security_emails_di # Grab the security email from a preset dictionary. # If nothing exists in the dictionary, grab from .contacts. - if security_emails_dict is not None and domain.name in security_emails_dict: - _email = security_emails_dict.get(domain.name) + if dict_security_emails is not None and domain.name in dict_security_emails: + _email = dict_security_emails.get(domain.name) security_email = _email if _email is not None else " " else: # If the dictionary doesn't contain that data, lets filter for it manually. @@ -103,13 +110,22 @@ def parse_domain_row(columns, domain_info: DomainInformation, security_emails_di "Deleted": domain.deleted, } - if get_domain_managers: - # Get each domain managers email and add to list - dm_emails = [dm.user.email for dm in domain.permissions.all()] + if should_get_domain_managers: + # Get lists of emails for active and invited domain managers - # Set up the "matching header" + row field data - for i, dm_email in enumerate(dm_emails, start=1): - FIELDS[f"Domain manager email {i}"] = dm_email + dms_active_emails = dict_user_domain_roles.get(domain_info.domain.name, []) + dms_invited_emails = dict_domain_invitations_with_invited_status.get(domain_info.domain.name, []) + + # Set up the "matching headers" + row field data for email and status + i = 0 # Declare i outside of the loop to avoid a reference before assignment in the second loop + for i, dm_email in enumerate(dms_active_emails, start=1): + FIELDS[f"Domain manager {i}"] = dm_email + FIELDS[f"DM{i} status"] = "R" + + # Continue enumeration from where we left off and add data for invited domain managers + for j, dm_email in enumerate(dms_invited_emails, start=i + 1): + FIELDS[f"Domain manager {j}"] = dm_email + FIELDS[f"DM{j} status"] = "I" row = [FIELDS.get(column, "") for column in columns] return row @@ -119,7 +135,7 @@ def _get_security_emails(sec_contact_ids): """ Retrieve security contact emails for the given security contact IDs. """ - security_emails_dict = {} + dict_security_emails = {} public_contacts = ( PublicContact.objects.only("email", "domain__name") .select_related("domain") @@ -129,65 +145,151 @@ def _get_security_emails(sec_contact_ids): # Populate a dictionary of domain names and their security contacts for contact in public_contacts: domain: Domain = contact.domain - if domain is not None and domain.name not in security_emails_dict: - security_emails_dict[domain.name] = contact.email + if domain is not None and domain.name not in dict_security_emails: + dict_security_emails[domain.name] = contact.email else: logger.warning("csv_export -> Domain was none for PublicContact") - return security_emails_dict + return dict_security_emails -def write_domains_csv( +def count_domain_managers(domain_name, dict_domain_invitations_with_invited_status, dict_user_domain_roles): + """Count active and invited domain managers""" + dms_active = len(dict_user_domain_roles.get(domain_name, [])) + dms_invited = len(dict_domain_invitations_with_invited_status.get(domain_name, [])) + return dms_active, dms_invited + + +def update_columns(columns, dms_total, should_update_columns): + """Update columns if necessary""" + if should_update_columns: + for i in range(1, dms_total + 1): + email_column_header = f"Domain manager {i}" + status_column_header = f"DM{i} status" + if email_column_header not in columns: + columns.append(email_column_header) + columns.append(status_column_header) + should_update_columns = False + return columns, should_update_columns, dms_total + + +def update_columns_with_domain_managers( + columns, + domain_info, + should_update_columns, + dms_total, + dict_domain_invitations_with_invited_status, + dict_user_domain_roles, +): + """Helper function to update columns with domain manager information""" + + domain_name = domain_info.domain.name + + try: + dms_active, dms_invited = count_domain_managers( + domain_name, dict_domain_invitations_with_invited_status, dict_user_domain_roles + ) + + if dms_active + dms_invited > dms_total: + dms_total = dms_active + dms_invited + should_update_columns = True + + except Exception as err: + logger.error(f"Exception while parsing domain managers for reports: {err}") + + return update_columns(columns, dms_total, should_update_columns) + + +def build_dictionaries_for_domain_managers(dict_user_domain_roles, dict_domain_invitations_with_invited_status): + """Helper function that builds dicts for invited users and active domain + managers. We do so to avoid filtering within loops.""" + + user_domain_roles = UserDomainRole.objects.all() + + # Iterate through each user domain role and populate the dictionary + for user_domain_role in user_domain_roles: + domain_name = user_domain_role.domain.name + email = user_domain_role.user.email + if domain_name not in dict_user_domain_roles: + dict_user_domain_roles[domain_name] = [] + dict_user_domain_roles[domain_name].append(email) + + domain_invitations_with_invited_status = None + domain_invitations_with_invited_status = DomainInvitation.objects.filter( + status=DomainInvitation.DomainInvitationStatus.INVITED + ).select_related("domain") + + # Iterate through each domain invitation and populate the dictionary + for invite in domain_invitations_with_invited_status: + domain_name = invite.domain.name + email = invite.email + if domain_name not in dict_domain_invitations_with_invited_status: + dict_domain_invitations_with_invited_status[domain_name] = [] + dict_domain_invitations_with_invited_status[domain_name].append(email) + + return dict_user_domain_roles, dict_domain_invitations_with_invited_status + + +def write_csv_for_domains( writer, columns, sort_fields, filter_condition, - get_domain_managers=False, + should_get_domain_managers=False, should_write_header=True, ): """ Receives params from the parent methods and outputs a CSV with filtered and sorted domains. Works with write_header as long as the same writer object is passed. - get_domain_managers: Conditional bc we only use domain manager info for export_data_full_to_csv + should_get_domain_managers: Conditional bc we only use domain manager info for export_data_full_to_csv should_write_header: Conditional bc export_data_domain_growth_to_csv calls write_body twice """ + # Retrieve domain information and all sec emails all_domain_infos = get_domain_infos(filter_condition, sort_fields) - - # Store all security emails to avoid epp calls or excessive filters sec_contact_ids = all_domain_infos.values_list("domain__security_contact_registry_id", flat=True) - - security_emails_dict = _get_security_emails(sec_contact_ids) - - # Reduce the memory overhead when performing the write operation + dict_security_emails = _get_security_emails(sec_contact_ids) paginator = Paginator(all_domain_infos, 1000) - # The maximum amount of domain managers an account has - # We get the max so we can set the column header accurately - max_dm_count = 0 + # Initialize variables + dms_total = 0 + should_update_columns = False total_body_rows = [] + dict_user_domain_roles = {} + dict_domain_invitations_with_invited_status = {} + # Build dictionaries if necessary + if should_get_domain_managers: + dict_user_domain_roles, dict_domain_invitations_with_invited_status = build_dictionaries_for_domain_managers( + dict_user_domain_roles, dict_domain_invitations_with_invited_status + ) + + # Process domain information for page_num in paginator.page_range: rows = [] page = paginator.page(page_num) for domain_info in page.object_list: - - # Get count of all the domain managers for an account - if get_domain_managers: - dm_count = domain_info.domain.permissions.count() - if dm_count > max_dm_count: - max_dm_count = dm_count - for i in range(1, max_dm_count + 1): - column_name = f"Domain manager email {i}" - if column_name not in columns: - columns.append(column_name) + if should_get_domain_managers: + columns, dms_total, should_update_columns = update_columns_with_domain_managers( + columns, + domain_info, + should_update_columns, + dms_total, + dict_domain_invitations_with_invited_status, + dict_user_domain_roles, + ) try: - row = parse_domain_row(columns, domain_info, security_emails_dict, get_domain_managers) + row = parse_row_for_domain( + columns, + domain_info, + dict_security_emails, + should_get_domain_managers, + dict_domain_invitations_with_invited_status, + dict_user_domain_roles, + ) rows.append(row) except ValueError: - # This should not happen. If it does, just skip this row. - # It indicates that DomainInformation.domain is None. logger.error("csv_export -> Error when parsing row, domain was None") continue total_body_rows.extend(rows) @@ -208,7 +310,7 @@ def get_requests(filter_condition, sort_fields): return requests -def parse_request_row(columns, request: DomainRequest): +def parse_row_for_requests(columns, request: DomainRequest): """Given a set of columns, generate a new row from cleaned column data""" requested_domain_name = "No requested domain" @@ -240,7 +342,7 @@ def parse_request_row(columns, request: DomainRequest): return row -def write_requests_csv( +def write_csv_for_requests( writer, columns, sort_fields, @@ -261,7 +363,7 @@ def write_requests_csv( rows = [] for request in page.object_list: try: - row = parse_request_row(columns, request) + row = parse_row_for_requests(columns, request) rows.append(row) except ValueError: # This should not happen. If it does, just skip this row. @@ -309,8 +411,8 @@ def export_data_type_to_csv(csv_file): Domain.State.ON_HOLD, ], } - write_domains_csv( - writer, columns, sort_fields, filter_condition, get_domain_managers=True, should_write_header=True + write_csv_for_domains( + writer, columns, sort_fields, filter_condition, should_get_domain_managers=True, should_write_header=True ) @@ -342,8 +444,8 @@ def export_data_full_to_csv(csv_file): Domain.State.ON_HOLD, ], } - write_domains_csv( - writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True + write_csv_for_domains( + writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True ) @@ -376,8 +478,8 @@ def export_data_federal_to_csv(csv_file): Domain.State.ON_HOLD, ], } - write_domains_csv( - writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True + write_csv_for_domains( + writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True ) @@ -446,77 +548,42 @@ def export_data_domain_growth_to_csv(csv_file, start_date, end_date): "domain__deleted__gte": start_date_formatted, } - write_domains_csv( - writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True + write_csv_for_domains( + writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True ) - write_domains_csv( + write_csv_for_domains( writer, columns, sort_fields_for_deleted_domains, filter_condition_for_deleted_domains, - get_domain_managers=False, + should_get_domain_managers=False, should_write_header=False, ) -def get_sliced_domains(filter_condition, distinct=False): +def get_sliced_domains(filter_condition): """Get filtered domains counts sliced by org type and election office. Pass distinct=True when filtering by permissions so we do not to count multiples when a domain has more that one manager. """ - # Round trip 1: Get distinct domain names based on filter condition - domains_count = DomainInformation.objects.filter(**filter_condition).distinct().count() - - # Round trip 2: Get counts for other slices - # This will require either 8 filterd and distinct DB round trips, - # or 2 DB round trips plus iteration on domain_permissions for each domain - if distinct: - generic_org_types_query = DomainInformation.objects.filter(**filter_condition).values_list( - "domain_id", "generic_org_type" - ) - # Initialize Counter to store counts for each generic_org_type - generic_org_type_counts = Counter() - - # Keep track of domains already counted - domains_counted = set() - - # Iterate over distinct domains - for domain_id, generic_org_type in generic_org_types_query: - # Check if the domain has already been counted - if domain_id in domains_counted: - continue - - # Get all permissions for the current domain - domain_permissions = DomainInformation.objects.filter(domain_id=domain_id, **filter_condition).values_list( - "domain__permissions", flat=True - ) - - # Check if the domain has multiple permissions - if len(domain_permissions) > 0: - # Mark the domain as counted - domains_counted.add(domain_id) - - # Increment the count for the corresponding generic_org_type - generic_org_type_counts[generic_org_type] += 1 - else: - generic_org_types_query = DomainInformation.objects.filter(**filter_condition).values_list( - "generic_org_type", flat=True - ) - generic_org_type_counts = Counter(generic_org_types_query) - - # Extract counts for each generic_org_type - federal = generic_org_type_counts.get(DomainRequest.OrganizationChoices.FEDERAL, 0) - interstate = generic_org_type_counts.get(DomainRequest.OrganizationChoices.INTERSTATE, 0) - state_or_territory = generic_org_type_counts.get(DomainRequest.OrganizationChoices.STATE_OR_TERRITORY, 0) - tribal = generic_org_type_counts.get(DomainRequest.OrganizationChoices.TRIBAL, 0) - county = generic_org_type_counts.get(DomainRequest.OrganizationChoices.COUNTY, 0) - city = generic_org_type_counts.get(DomainRequest.OrganizationChoices.CITY, 0) - special_district = generic_org_type_counts.get(DomainRequest.OrganizationChoices.SPECIAL_DISTRICT, 0) - school_district = generic_org_type_counts.get(DomainRequest.OrganizationChoices.SCHOOL_DISTRICT, 0) - - # Round trip 3 - election_board = DomainInformation.objects.filter(is_election_board=True, **filter_condition).distinct().count() + domains = DomainInformation.objects.all().filter(**filter_condition).distinct() + domains_count = domains.count() + federal = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.FEDERAL).distinct().count() + interstate = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.INTERSTATE).count() + state_or_territory = ( + domains.filter(generic_org_type=DomainRequest.OrganizationChoices.STATE_OR_TERRITORY).distinct().count() + ) + tribal = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.TRIBAL).distinct().count() + county = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.COUNTY).distinct().count() + city = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.CITY).distinct().count() + special_district = ( + domains.filter(generic_org_type=DomainRequest.OrganizationChoices.SPECIAL_DISTRICT).distinct().count() + ) + school_district = ( + domains.filter(generic_org_type=DomainRequest.OrganizationChoices.SCHOOL_DISTRICT).distinct().count() + ) + election_board = domains.filter(is_election_board=True).distinct().count() return [ domains_count, @@ -535,26 +602,23 @@ def get_sliced_domains(filter_condition, distinct=False): def get_sliced_requests(filter_condition): """Get filtered requests counts sliced by org type and election office.""" - # Round trip 1: Get distinct requests based on filter condition - requests_count = DomainRequest.objects.filter(**filter_condition).distinct().count() - - # Round trip 2: Get counts for other slices - generic_org_types_query = DomainRequest.objects.filter(**filter_condition).values_list( - "generic_org_type", flat=True + requests = DomainRequest.objects.all().filter(**filter_condition).distinct() + requests_count = requests.count() + federal = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.FEDERAL).distinct().count() + interstate = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.INTERSTATE).distinct().count() + state_or_territory = ( + requests.filter(generic_org_type=DomainRequest.OrganizationChoices.STATE_OR_TERRITORY).distinct().count() ) - generic_org_type_counts = Counter(generic_org_types_query) - - federal = generic_org_type_counts.get(DomainRequest.OrganizationChoices.FEDERAL, 0) - interstate = generic_org_type_counts.get(DomainRequest.OrganizationChoices.INTERSTATE, 0) - state_or_territory = generic_org_type_counts.get(DomainRequest.OrganizationChoices.STATE_OR_TERRITORY, 0) - tribal = generic_org_type_counts.get(DomainRequest.OrganizationChoices.TRIBAL, 0) - county = generic_org_type_counts.get(DomainRequest.OrganizationChoices.COUNTY, 0) - city = generic_org_type_counts.get(DomainRequest.OrganizationChoices.CITY, 0) - special_district = generic_org_type_counts.get(DomainRequest.OrganizationChoices.SPECIAL_DISTRICT, 0) - school_district = generic_org_type_counts.get(DomainRequest.OrganizationChoices.SCHOOL_DISTRICT, 0) - - # Round trip 3 - election_board = DomainRequest.objects.filter(is_election_board=True, **filter_condition).distinct().count() + tribal = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.TRIBAL).distinct().count() + county = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.COUNTY).distinct().count() + city = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.CITY).distinct().count() + special_district = ( + requests.filter(generic_org_type=DomainRequest.OrganizationChoices.SPECIAL_DISTRICT).distinct().count() + ) + school_district = ( + requests.filter(generic_org_type=DomainRequest.OrganizationChoices.SCHOOL_DISTRICT).distinct().count() + ) + election_board = requests.filter(is_election_board=True).distinct().count() return [ requests_count, @@ -588,7 +652,7 @@ def export_data_managed_domains_to_csv(csv_file, start_date, end_date): "domain__permissions__isnull": False, "domain__first_ready__lte": start_date_formatted, } - managed_domains_sliced_at_start_date = get_sliced_domains(filter_managed_domains_start_date, True) + managed_domains_sliced_at_start_date = get_sliced_domains(filter_managed_domains_start_date) writer.writerow(["MANAGED DOMAINS COUNTS AT START DATE"]) writer.writerow( @@ -612,7 +676,7 @@ def export_data_managed_domains_to_csv(csv_file, start_date, end_date): "domain__permissions__isnull": False, "domain__first_ready__lte": end_date_formatted, } - managed_domains_sliced_at_end_date = get_sliced_domains(filter_managed_domains_end_date, True) + managed_domains_sliced_at_end_date = get_sliced_domains(filter_managed_domains_end_date) writer.writerow(["MANAGED DOMAINS COUNTS AT END DATE"]) writer.writerow( @@ -632,12 +696,12 @@ def export_data_managed_domains_to_csv(csv_file, start_date, end_date): writer.writerow(managed_domains_sliced_at_end_date) writer.writerow([]) - write_domains_csv( + write_csv_for_domains( writer, columns, sort_fields, filter_managed_domains_end_date, - get_domain_managers=True, + should_get_domain_managers=True, should_write_header=True, ) @@ -661,7 +725,7 @@ def export_data_unmanaged_domains_to_csv(csv_file, start_date, end_date): "domain__permissions__isnull": True, "domain__first_ready__lte": start_date_formatted, } - unmanaged_domains_sliced_at_start_date = get_sliced_domains(filter_unmanaged_domains_start_date, True) + unmanaged_domains_sliced_at_start_date = get_sliced_domains(filter_unmanaged_domains_start_date) writer.writerow(["UNMANAGED DOMAINS AT START DATE"]) writer.writerow( @@ -685,7 +749,7 @@ def export_data_unmanaged_domains_to_csv(csv_file, start_date, end_date): "domain__permissions__isnull": True, "domain__first_ready__lte": end_date_formatted, } - unmanaged_domains_sliced_at_end_date = get_sliced_domains(filter_unmanaged_domains_end_date, True) + unmanaged_domains_sliced_at_end_date = get_sliced_domains(filter_unmanaged_domains_end_date) writer.writerow(["UNMANAGED DOMAINS AT END DATE"]) writer.writerow( @@ -705,12 +769,12 @@ def export_data_unmanaged_domains_to_csv(csv_file, start_date, end_date): writer.writerow(unmanaged_domains_sliced_at_end_date) writer.writerow([]) - write_domains_csv( + write_csv_for_domains( writer, columns, sort_fields, filter_unmanaged_domains_end_date, - get_domain_managers=False, + should_get_domain_managers=False, should_write_header=True, ) @@ -741,4 +805,4 @@ def export_data_requests_growth_to_csv(csv_file, start_date, end_date): "submission_date__gte": start_date_formatted, } - write_requests_csv(writer, columns, sort_fields, filter_condition, should_write_header=True) + write_csv_for_requests(writer, columns, sort_fields, filter_condition, should_write_header=True) diff --git a/src/registrar/views/admin_views.py b/src/registrar/views/admin_views.py index eba8423ed..01a8157f9 100644 --- a/src/registrar/views/admin_views.py +++ b/src/registrar/views/admin_views.py @@ -49,8 +49,8 @@ class AnalyticsView(View): "domain__permissions__isnull": False, "domain__first_ready__lte": end_date_formatted, } - managed_domains_sliced_at_start_date = csv_export.get_sliced_domains(filter_managed_domains_start_date, True) - managed_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_managed_domains_end_date, True) + managed_domains_sliced_at_start_date = csv_export.get_sliced_domains(filter_managed_domains_start_date) + managed_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_managed_domains_end_date) filter_unmanaged_domains_start_date = { "domain__permissions__isnull": True, @@ -60,10 +60,8 @@ class AnalyticsView(View): "domain__permissions__isnull": True, "domain__first_ready__lte": end_date_formatted, } - unmanaged_domains_sliced_at_start_date = csv_export.get_sliced_domains( - filter_unmanaged_domains_start_date, True - ) - unmanaged_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_unmanaged_domains_end_date, True) + unmanaged_domains_sliced_at_start_date = csv_export.get_sliced_domains(filter_unmanaged_domains_start_date) + unmanaged_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_unmanaged_domains_end_date) filter_ready_domains_start_date = { "domain__state__in": [models.Domain.State.READY], diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index aa0c9cd6b..c7083ce48 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -307,7 +307,12 @@ class UserDeleteDomainRolePermission(PermissionsLoginMixin): domain=domain_pk, domain__permissions__user=self.request.user, ).exists() - if not has_delete_permission: + + user_is_analyst_or_superuser = self.request.user.has_perm( + "registrar.analyst_access_permission" + ) or self.request.user.has_perm("registrar.full_access_permission") + + if not (has_delete_permission or user_is_analyst_or_superuser): return False # Check if more than one manager exists on the domain. diff --git a/src/requirements.txt b/src/requirements.txt index 1db089f5a..91f2452ca 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,8 +1,8 @@ -i https://pypi.python.org/simple annotated-types==0.6.0; python_version >= '3.8' -asgiref==3.7.2; python_version >= '3.7' -boto3==1.34.56; python_version >= '3.8' -botocore==1.34.56; python_version >= '3.8' +asgiref==3.8.1; python_version >= '3.8' +boto3==1.34.71; python_version >= '3.8' +botocore==1.34.71; python_version >= '3.8' cachetools==5.3.3; python_version >= '3.7' certifi==2024.2.2; python_version >= '3.6' cfenv==0.5.3 @@ -13,6 +13,7 @@ defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, dj-database-url==2.1.0 dj-email-url==1.0.6 django==4.2.10; python_version >= '3.8' +django-admin-multiple-choice-list-filter==0.1.1 django-allow-cidr==0.7.1 django-auditlog==2.3.0; python_version >= '3.7' django-cache-url==3.4.5 @@ -23,7 +24,7 @@ django-login-required-middleware==0.9.0 django-phonenumber-field[phonenumberslite]==7.3.0; python_version >= '3.8' django-widget-tweaks==1.5.0; python_version >= '3.8' environs[django]==11.0.0; python_version >= '3.8' -faker==24.0.0; python_version >= '3.8' +faker==24.4.0; python_version >= '3.8' fred-epplib@ git+https://github.com/cisagov/epplib.git@d56d183f1664f34c40ca9716a3a9a345f0ef561c furl==2.1.3 future==1.0.0; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3' @@ -38,12 +39,12 @@ markupsafe==2.1.5; python_version >= '3.7' marshmallow==3.21.1; python_version >= '3.8' oic==1.6.1; python_version ~= '3.7' orderedmultidict==1.0.1 -packaging==23.2; python_version >= '3.7' -phonenumberslite==8.13.31 +packaging==24.0; python_version >= '3.7' +phonenumberslite==8.13.33 psycopg2-binary==2.9.9; python_version >= '3.7' pycparser==2.21 pycryptodomex==3.20.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' -pydantic==2.6.3; python_version >= '3.8' +pydantic==2.6.4; python_version >= '3.8' pydantic-core==2.16.3; python_version >= '3.8' pydantic-settings==2.2.1; python_version >= '3.8' pyjwkest==1.4.2 @@ -51,13 +52,13 @@ python-dateutil==2.9.0.post0; python_version >= '2.7' and python_version not in python-dotenv==1.0.1; python_version >= '3.8' pyzipper==0.3.6; python_version >= '3.4' requests==2.31.0; python_version >= '3.7' -s3transfer==0.10.0; python_version >= '3.8' -setuptools==69.1.1; python_version >= '3.8' +s3transfer==0.10.1; python_version >= '3.8' +setuptools==69.2.0; python_version >= '3.8' six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' sqlparse==0.4.4; python_version >= '3.5' tblib==3.0.0; python_version >= '3.8' typing-extensions==4.10.0; python_version >= '3.8' -urllib3==2.0.7; python_version >= '3.7' +urllib3==2.2.1; python_version >= '3.8' whitenoise==6.6.0; python_version >= '3.8' zope.event==5.0; python_version >= '3.7' zope.interface==6.2; python_version >= '3.7'