Merge branch 'main' into za/1909-change-org-field-to-new-format

This commit is contained in:
zandercymatics 2024-04-05 08:45:33 -06:00
commit 773b1e20f2
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
28 changed files with 966 additions and 346 deletions

View file

@ -31,6 +31,7 @@ gevent = "*"
fred-epplib = {git = "https://github.com/cisagov/epplib.git", ref = "master"} fred-epplib = {git = "https://github.com/cisagov/epplib.git", ref = "master"}
pyzipper="*" pyzipper="*"
tblib = "*" tblib = "*"
django-admin-multiple-choice-list-filter = "*"
[dev-packages] [dev-packages]
django-debug-toolbar = "*" django-debug-toolbar = "*"

247
src/Pipfile.lock generated
View file

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "082a951f15bb26a28f2dca7e0840fdf61518b3d90c42d77a310f982344cbd1dc" "sha256": "52143c73ccc59cd3dd6a1294a9352dbae009ebfc6e3ca5d018b8484275e2b6f8"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": {}, "requires": {},
@ -24,28 +24,28 @@
}, },
"asgiref": { "asgiref": {
"hashes": [ "hashes": [
"sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e", "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47",
"sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed" "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.8'",
"version": "==3.7.2" "version": "==3.8.1"
}, },
"boto3": { "boto3": {
"hashes": [ "hashes": [
"sha256:300888f0c1b6f32f27f85a9aa876f50f46514ec619647af7e4d20db74d339714", "sha256:7ce8c9a50af2f8a159a0dd86b40011d8dfdaba35005a118e51cd3ac72dc630f1",
"sha256:b26928f9a21cf3649cea20a59061340f3294c6e7785ceb6e1a953eb8010dc3ba" "sha256:d786e7fbe3c4152866199786468a625dc77b9f27294cd7ad4f63cd2e0c927287"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==1.34.56" "version": "==1.34.71"
}, },
"botocore": { "botocore": {
"hashes": [ "hashes": [
"sha256:bffeb71ab21d47d4ecf947d9bdb2fbd1b0bbd0c27742cea7cf0b77b701c41d9f", "sha256:3bc9e23aee73fe6f097823d61f79a8877790436038101a83fa96c7593e8109f8",
"sha256:fff66e22a5589c2d58fba57d1d95c334ce771895e831f80365f6cff6453285ec" "sha256:c58f9ed71af2ea53d24146187130541222d7de8c27eb87d23f15457e7b83d88b"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==1.34.56" "version": "==1.34.71"
}, },
"cachetools": { "cachetools": {
"hashes": [ "hashes": [
@ -295,6 +295,14 @@
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==4.2.10" "version": "==4.2.10"
}, },
"django-admin-multiple-choice-list-filter": {
"hashes": [
"sha256:a5b82682ff752cf74bbc4fa3d562c99b9f9f80389961017e1ac63e08f22d8b9f",
"sha256:eab2ad5fad6df8ed8436cb6c1d0e67863453a9d30282b7fdcb7e45b2eb37ab6f"
],
"index": "pypi",
"version": "==0.1.1"
},
"django-allow-cidr": { "django-allow-cidr": {
"hashes": [ "hashes": [
"sha256:11126c5bb9df3a61ff9d97304856ba7e5b26d46c6d456709a6d9e28483bff47f", "sha256:11126c5bb9df3a61ff9d97304856ba7e5b26d46c6d456709a6d9e28483bff47f",
@ -384,12 +392,12 @@
}, },
"faker": { "faker": {
"hashes": [ "hashes": [
"sha256:2456d674f40bd51eb3acbf85221277027822e529a90cc826453d9a25dff932b1", "sha256:998c29ee7d64429bd59204abffa9ba11f784fb26c7b9df4def78d1a70feb36a7",
"sha256:ea6f784c40730de0f77067e49e78cdd590efb00bec3d33f577492262206c17fc" "sha256:a5ddccbe97ab691fad6bd8036c31f5697cfaa550e62e000078d1935fa8a7ec2e"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==24.0.0" "version": "==24.4.0"
}, },
"fred-epplib": { "fred-epplib": {
"git": "https://github.com/cisagov/epplib.git", "git": "https://github.com/cisagov/epplib.git",
@ -732,18 +740,18 @@
}, },
"packaging": { "packaging": {
"hashes": [ "hashes": [
"sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5",
"sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==23.2" "version": "==24.0"
}, },
"phonenumberslite": { "phonenumberslite": {
"hashes": [ "hashes": [
"sha256:137d53d5d78dca30bc2becf81a3e2ac74deb8f0997e9bbe44de515ece4bd92bd", "sha256:4d92f4f9079bb83588dde45fd8a414bc13e4962886aa4d23576984196f4d83c2",
"sha256:e1f4359bff90c86d1b52db0e726d3334df00cc7d9c9c2ef66561d5f7a774d4ba" "sha256:7426bc46af3de5a800a4c8f33ab13e33225d2c8ed4fc52aa3c0380dadd8d7381"
], ],
"version": "==8.13.31" "version": "==8.13.33"
}, },
"psycopg2-binary": { "psycopg2-binary": {
"hashes": [ "hashes": [
@ -872,11 +880,11 @@
}, },
"pydantic": { "pydantic": {
"hashes": [ "hashes": [
"sha256:72c6034df47f46ccdf81869fddb81aade68056003900a8724a4f160700016a2a", "sha256:b1704e0847db01817624a6b86766967f552dd9dbf3afba4004409f908dcc84e6",
"sha256:e07805c4c7f5c6826e33a1d4c9d47950d7eaf34868e2690f8594d2e30241f11f" "sha256:cc46fce86607580867bdc3361ad462bab9c222ef042d3da86f2fb333e1d916c5"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==2.6.3" "version": "==2.6.4"
}, },
"pydantic-core": { "pydantic-core": {
"hashes": [ "hashes": [
@ -1014,19 +1022,19 @@
}, },
"s3transfer": { "s3transfer": {
"hashes": [ "hashes": [
"sha256:3cdb40f5cfa6966e812209d0994f2a4709b561c88e90cf00c2696d2df4e56b2e", "sha256:5683916b4c724f799e600f41dd9e10a9ff19871bf87623cc8f491cb4f5fa0a19",
"sha256:d0c8bbf672d5eebbe4e57945e23b972d963f07d82f661cabf678a5c88831595b" "sha256:ceb252b11bcf87080fb7850a224fb6e05c8a776bab8f2b64b7f25b969464839d"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==0.10.0" "version": "==0.10.1"
}, },
"setuptools": { "setuptools": {
"hashes": [ "hashes": [
"sha256:02fa291a0471b3a18b2b2481ed902af520c69e8ae0919c13da936542754b4c56", "sha256:0ff4183f8f42cd8fa3acea16c45205521a4ef28f73c6391d8a25e92893134f2e",
"sha256:5c0806c7d9af348e6dd3777b4f4dbb42c7ad85b190104837488eab9a7c945cf8" "sha256:c21c49fb1042386df081cb5d86759792ab89efca84cf114889191cd09aacc80c"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==69.1.1" "version": "==69.2.0"
}, },
"six": { "six": {
"hashes": [ "hashes": [
@ -1064,11 +1072,11 @@
}, },
"urllib3": { "urllib3": {
"hashes": [ "hashes": [
"sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84", "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d",
"sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e" "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.8'",
"version": "==2.0.7" "version": "==2.2.1"
}, },
"whitenoise": { "whitenoise": {
"hashes": [ "hashes": [
@ -1133,20 +1141,20 @@
"develop": { "develop": {
"asgiref": { "asgiref": {
"hashes": [ "hashes": [
"sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e", "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47",
"sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed" "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.8'",
"version": "==3.7.2" "version": "==3.8.1"
}, },
"bandit": { "bandit": {
"hashes": [ "hashes": [
"sha256:17e60786a7ea3c9ec84569fd5aee09936d116cb0cb43151023258340dbffb7ed", "sha256:36de50f720856ab24a24dbaa5fee2c66050ed97c1477e0a1159deab1775eab6b",
"sha256:527906bec6088cb499aae31bc962864b4e77569e9d529ee51df3a93b4b8ab28a" "sha256:509f7af645bc0cd8fd4587abc1a038fc795636671ee8204d502b933aee44f381"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==1.7.7" "version": "==1.7.8"
}, },
"beautifulsoup4": { "beautifulsoup4": {
"hashes": [ "hashes": [
@ -1158,32 +1166,32 @@
}, },
"black": { "black": {
"hashes": [ "hashes": [
"sha256:057c3dc602eaa6fdc451069bd027a1b2635028b575a6c3acfd63193ced20d9c8", "sha256:2818cf72dfd5d289e48f37ccfa08b460bf469e67fb7c4abb07edc2e9f16fb63f",
"sha256:08654d0797e65f2423f850fc8e16a0ce50925f9337fb4a4a176a7aa4026e63f8", "sha256:41622020d7120e01d377f74249e677039d20e6344ff5851de8a10f11f513bf93",
"sha256:163baf4ef40e6897a2a9b83890e59141cc8c2a98f2dda5080dc15c00ee1e62cd", "sha256:4acf672def7eb1725f41f38bf6bf425c8237248bb0804faa3965c036f7672d11",
"sha256:1e08fb9a15c914b81dd734ddd7fb10513016e5ce7e6704bdd5e1251ceee51ac9", "sha256:4be5bb28e090456adfc1255e03967fb67ca846a03be7aadf6249096100ee32d0",
"sha256:4dd76e9468d5536abd40ffbc7a247f83b2324f0c050556d9c371c2b9a9a95e31", "sha256:4f1373a7808a8f135b774039f61d59e4be7eb56b2513d3d2f02a8b9365b8a8a9",
"sha256:4f9de21bafcba9683853f6c96c2d515e364aee631b178eaa5145fc1c61a3cc92", "sha256:56f52cfbd3dabe2798d76dbdd299faa046a901041faf2cf33288bc4e6dae57b5",
"sha256:61a0391772490ddfb8a693c067df1ef5227257e72b0e4108482b8d41b5aee13f", "sha256:65b76c275e4c1c5ce6e9870911384bff5ca31ab63d19c76811cb1fb162678213",
"sha256:6981eae48b3b33399c8757036c7f5d48a535b962a7c2310d19361edeef64ce29", "sha256:65c02e4ea2ae09d16314d30912a58ada9a5c4fdfedf9512d23326128ac08ac3d",
"sha256:7e53a8c630f71db01b28cd9602a1ada68c937cbf2c333e6ed041390d6968faf4", "sha256:6905238a754ceb7788a73f02b45637d820b2f5478b20fec82ea865e4f5d4d9f7",
"sha256:810d445ae6069ce64030c78ff6127cd9cd178a9ac3361435708b907d8a04c693", "sha256:79dcf34b33e38ed1b17434693763301d7ccbd1c5860674a8f871bd15139e7837",
"sha256:93601c2deb321b4bad8f95df408e3fb3943d85012dddb6121336b8e24a0d1218", "sha256:7bb041dca0d784697af4646d3b62ba4a6b028276ae878e53f6b4f74ddd6db99f",
"sha256:992e451b04667116680cb88f63449267c13e1ad134f30087dec8527242e9862a", "sha256:7d5e026f8da0322b5662fa7a8e752b3fa2dac1c1cbc213c3d7ff9bdd0ab12395",
"sha256:9db528bccb9e8e20c08e716b3b09c6bdd64da0dd129b11e160bf082d4642ac23", "sha256:9f50ea1132e2189d8dff0115ab75b65590a3e97de1e143795adb4ce317934995",
"sha256:a0057f800de6acc4407fe75bb147b0c2b5cbb7c3ed110d3e5999cd01184d53b0", "sha256:a0c9c4a0771afc6919578cec71ce82a3e31e054904e7197deacbc9382671c41f",
"sha256:ba15742a13de85e9b8f3239c8f807723991fbfae24bad92d34a2b12e81904982", "sha256:aadf7a02d947936ee418777e0247ea114f78aff0d0959461057cae8a04f20597",
"sha256:bce4f25c27c3435e4dace4815bcb2008b87e167e3bf4ee47ccdc5ce906eb4894", "sha256:b5991d523eee14756f3c8d5df5231550ae8993e2286b8014e2fdea7156ed0959",
"sha256:ca610d29415ee1a30a3f30fab7a8f4144e9d34c89a235d81292a1edb2b55f540", "sha256:bf21b7b230718a5f08bd32d5e4f1db7fc8788345c8aea1d155fc17852b3410f5",
"sha256:d533d5e3259720fdbc1b37444491b024003e012c5173f7d06825a77508085430", "sha256:c45f8dff244b3c431b36e3224b6be4a127c6aca780853574c00faf99258041eb",
"sha256:d84f29eb3ee44859052073b7636533ec995bd0f64e2fb43aeceefc70090e752b", "sha256:c7ed6668cbbfcd231fa0dc1b137d3e40c04c7f786e626b405c62bcd5db5857e4",
"sha256:e37c99f89929af50ffaf912454b3e3b47fd64109659026b678c091a4cd450fb2", "sha256:d7de8d330763c66663661a1ffd432274a2f92f07feeddd89ffd085b5744f85e7",
"sha256:e8a6ae970537e67830776488bca52000eaa37fa63b9988e8c487458d9cd5ace6", "sha256:e19cb1c6365fd6dc38a6eae2dcb691d7d83935c10215aef8e6c38edee3f77abd",
"sha256:faf2ee02e6612577ba0181f4347bcbcf591eb122f7841ae5ba233d12c39dcb4d" "sha256:e2af80566f43c85f5797365077fb64a393861a3730bd110971ab7a0c94e873e7"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==24.2.0" "version": "==24.3.0"
}, },
"blinker": { "blinker": {
"hashes": [ "hashes": [
@ -1195,12 +1203,12 @@
}, },
"boto3": { "boto3": {
"hashes": [ "hashes": [
"sha256:300888f0c1b6f32f27f85a9aa876f50f46514ec619647af7e4d20db74d339714", "sha256:7ce8c9a50af2f8a159a0dd86b40011d8dfdaba35005a118e51cd3ac72dc630f1",
"sha256:b26928f9a21cf3649cea20a59061340f3294c6e7785ceb6e1a953eb8010dc3ba" "sha256:d786e7fbe3c4152866199786468a625dc77b9f27294cd7ad4f63cd2e0c927287"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==1.34.56" "version": "==1.34.71"
}, },
"boto3-mocking": { "boto3-mocking": {
"hashes": [ "hashes": [
@ -1213,28 +1221,28 @@
}, },
"boto3-stubs": { "boto3-stubs": {
"hashes": [ "hashes": [
"sha256:627f8eca69d832581ee1676d39df099a2a2e3a86d6b3ebd21c81c5f11ed6a6fa", "sha256:1be579780a39a75394db0c413594aec380afde86dbc7eb5eb086a97a25d3c995",
"sha256:a87e7ecbab6235ec371b4363027e57483bca349a9cd5c891f40db81dadfa273e" "sha256:c9959c48ee1b53e9d19e424898caacf365aaee34cee4c70f40692e2b47bc8099"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==1.34.56" "version": "==1.34.71"
}, },
"botocore": { "botocore": {
"hashes": [ "hashes": [
"sha256:bffeb71ab21d47d4ecf947d9bdb2fbd1b0bbd0c27742cea7cf0b77b701c41d9f", "sha256:3bc9e23aee73fe6f097823d61f79a8877790436038101a83fa96c7593e8109f8",
"sha256:fff66e22a5589c2d58fba57d1d95c334ce771895e831f80365f6cff6453285ec" "sha256:c58f9ed71af2ea53d24146187130541222d7de8c27eb87d23f15457e7b83d88b"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==1.34.56" "version": "==1.34.71"
}, },
"botocore-stubs": { "botocore-stubs": {
"hashes": [ "hashes": [
"sha256:018e001e3add5eb1828ef444b45fb8c9faf695e08334031bf2d96853cd9af703", "sha256:0c3835c775db1387246c1ba8063b197604462fba8603d9b36b5dc60297197b2f",
"sha256:25468ba6983987b704b1856bb155f297f576e6d4a690b021ab0c7122889ba907" "sha256:463248fd1d6e7b68a0c57bdd758d04c6bd0c5c2c3bfa81afdf9d64f0930b59bc"
], ],
"markers": "python_version >= '3.8' and python_version < '4.0'", "markers": "python_version >= '3.8' and python_version < '4.0'",
"version": "==1.34.56" "version": "==1.34.69"
}, },
"click": { "click": {
"hashes": [ "hashes": [
@ -1337,37 +1345,37 @@
}, },
"mypy": { "mypy": {
"hashes": [ "hashes": [
"sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6", "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6",
"sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d", "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913",
"sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02", "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129",
"sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d", "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc",
"sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3", "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974",
"sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3", "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374",
"sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3", "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150",
"sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66", "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03",
"sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259", "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9",
"sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835", "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02",
"sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd", "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89",
"sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d", "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2",
"sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8", "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d",
"sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07", "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3",
"sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b", "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612",
"sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e", "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e",
"sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6", "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3",
"sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae", "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e",
"sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9", "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd",
"sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d", "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04",
"sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a", "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed",
"sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592", "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185",
"sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218", "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf",
"sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817", "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b",
"sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4", "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4",
"sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410", "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f",
"sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55" "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==1.8.0" "version": "==1.9.0"
}, },
"mypy-extensions": { "mypy-extensions": {
"hashes": [ "hashes": [
@ -1387,11 +1395,11 @@
}, },
"packaging": { "packaging": {
"hashes": [ "hashes": [
"sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5",
"sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==23.2" "version": "==24.0"
}, },
"pathspec": { "pathspec": {
"hashes": [ "hashes": [
@ -1516,11 +1524,11 @@
}, },
"s3transfer": { "s3transfer": {
"hashes": [ "hashes": [
"sha256:3cdb40f5cfa6966e812209d0994f2a4709b561c88e90cf00c2696d2df4e56b2e", "sha256:5683916b4c724f799e600f41dd9e10a9ff19871bf87623cc8f491cb4f5fa0a19",
"sha256:d0c8bbf672d5eebbe4e57945e23b972d963f07d82f661cabf678a5c88831595b" "sha256:ceb252b11bcf87080fb7850a224fb6e05c8a776bab8f2b64b7f25b969464839d"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==0.10.0" "version": "==0.10.1"
}, },
"six": { "six": {
"hashes": [ "hashes": [
@ -1589,19 +1597,20 @@
}, },
"types-pyyaml": { "types-pyyaml": {
"hashes": [ "hashes": [
"sha256:334373d392fde0fdf95af5c3f1661885fa10c52167b14593eb856289e1855062", "sha256:a9e0f0f88dc835739b0c1ca51ee90d04ca2a897a71af79de9aec5f38cb0a5342",
"sha256:c05bc6c158facb0676674b7f11fe3960db4f389718e19e62bd2b84d6205cfd24" "sha256:b845b06a1c7e54b8e5b4c683043de0d9caf205e7434b3edc678ff2411979b8f6"
], ],
"version": "==6.0.12.12" "markers": "python_version >= '3.8'",
"version": "==6.0.12.20240311"
}, },
"types-requests": { "types-requests": {
"hashes": [ "hashes": [
"sha256:a82807ec6ddce8f00fe0e949da6d6bc1fbf1715420218a9640d695f70a9e5a9b", "sha256:47872893d65a38e282ee9f277a4ee50d1b28bd592040df7d1fdaffdf3779937d",
"sha256:f1721dba8385958f504a5386240b92de4734e047a08a40751c1654d1ac3349c5" "sha256:b1c1b66abfb7fa79aae09097a811c4aa97130eb8831c60e47aee4ca344731ca5"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==2.31.0.20240218" "version": "==2.31.0.20240311"
}, },
"types-s3transfer": { "types-s3transfer": {
"hashes": [ "hashes": [
@ -1622,11 +1631,11 @@
}, },
"urllib3": { "urllib3": {
"hashes": [ "hashes": [
"sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84", "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d",
"sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e" "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.8'",
"version": "==2.0.7" "version": "==2.2.1"
}, },
"waitress": { "waitress": {
"hashes": [ "hashes": [

View file

@ -27,6 +27,7 @@ from django_fsm import TransitionNotAllowed # type: ignore
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.html import escape from django.utils.html import escape
from django.contrib.auth.forms import UserChangeForm, UsernameField 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 _ from django.utils.translation import gettext_lazy as _
@ -978,6 +979,18 @@ class DomainRequestAdmin(ListHeaderAdmin):
"""Custom domain requests admin class.""" """Custom domain requests admin class."""
form = DomainRequestAdminForm 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): class InvestigatorFilter(admin.SimpleListFilter):
"""Custom investigator filter that only displays users with the manager role""" """Custom investigator filter that only displays users with the manager role"""
@ -1039,8 +1052,6 @@ class DomainRequestAdmin(ListHeaderAdmin):
if self.value() == "0": if self.value() == "0":
return queryset.filter(Q(is_election_board=False) | Q(is_election_board=None)) return queryset.filter(Q(is_election_board=False) | Q(is_election_board=None))
change_form_template = "django/admin/domain_request_change_form.html"
# Columns # Columns
list_display = [ list_display = [
"requested_domain", "requested_domain",
@ -1071,7 +1082,7 @@ class DomainRequestAdmin(ListHeaderAdmin):
# Filters # Filters
list_filter = ( list_filter = (
"status", StatusListFilter,
"generic_org_type", "generic_org_type",
"federal_type", "federal_type",
ElectionOfficeFilter, ElectionOfficeFilter,
@ -1353,6 +1364,23 @@ class DomainRequestAdmin(ListHeaderAdmin):
"Cannot edit a domain request with a restricted creator.", "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): def change_view(self, request, object_id, form_url="", extra_context=None):
obj = self.get_object(request, object_id) obj = self.get_object(request, object_id)
self.display_restricted_warning(request, obj) self.display_restricted_warning(request, obj)
@ -1406,6 +1434,13 @@ class DomainInformationInline(admin.StackedInline):
"submitter", "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): def formfield_for_manytomany(self, db_field, request, **kwargs):
"""customize the behavior of formfields with manytomany relationships. the customized """customize the behavior of formfields with manytomany relationships. the customized
behavior includes sorting of objects in lists as well as customizing helper text""" behavior includes sorting of objects in lists as well as customizing helper text"""

View file

@ -410,3 +410,60 @@ function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk,
}); });
observer.observe({ type: "navigation" }); 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);
}
})();

View file

@ -42,6 +42,7 @@ html[data-theme="light"] {
--error-fg: #{$theme-color-error}; --error-fg: #{$theme-color-error};
--message-success-bg: #{$theme-color-success-lighter}; --message-success-bg: #{$theme-color-success-lighter};
--checkbox-green: #{$theme-color-success-light};
// $theme-color-warning-lighter - yellow-5 // $theme-color-warning-lighter - yellow-5
--message-warning-bg: #faf3d1; --message-warning-bg: #faf3d1;
--message-error-bg: #{$theme-color-error-lighter}; --message-error-bg: #{$theme-color-error-lighter};
@ -93,6 +94,7 @@ html[data-theme="light"] {
--error-fg: #e35f5f; --error-fg: #e35f5f;
--message-success-bg: #006b1b; --message-success-bg: #006b1b;
--checkbox-green: #006b1b;
--message-warning-bg: #583305; --message-warning-bg: #583305;
--message-error-bg: #570808; --message-error-bg: #570808;
@ -422,6 +424,102 @@ address.dja-address-contact-list {
border: 1px solid var(--error-fg) !important; 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 { .module.collapse {
margin-top: -35px; margin-top: -35px;
padding-top: 0; padding-top: 0;

View file

@ -117,6 +117,10 @@ abbr[title] {
} }
} }
.visible-desktop {
display: none;
}
@include at-media(desktop) { @include at-media(desktop) {
.float-right-desktop { .float-right-desktop {
float: right; float: right;
@ -124,33 +128,15 @@ abbr[title] {
.float-left-desktop { .float-left-desktop {
float: left; float: left;
} }
.visible-desktop {
display: block;
}
} }
.flex-end { .flex-end {
align-items: flex-end; align-items: flex-end;
} }
// Only apply this custom wrapping to desktop .cursor-pointer {
@include at-media(desktop) { cursor: pointer;
.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;
}
} }

View file

@ -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 // Cancel button used on the
// DNSSEC main page // DNSSEC main page
// We want to center this button on mobile // We want to center this button on mobile

View file

@ -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;
}
}

View file

@ -13,6 +13,7 @@
@forward "lists"; @forward "lists";
@forward "buttons"; @forward "buttons";
@forward "forms"; @forward "forms";
@forward "tooltips";
@forward "fieldsets"; @forward "fieldsets";
@forward "alerts"; @forward "alerts";
@forward "tables"; @forward "tables";

View file

@ -146,6 +146,8 @@ INSTALLED_APPS = [
# "puml_generator", # "puml_generator",
# supports necessary headers for Django cross origin # supports necessary headers for Django cross origin
"corsheaders", "corsheaders",
# library for multiple choice filters in django admin
"django_admin_multiple_choice_list_filter",
] ]
# Middleware are routines for processing web requests. # Middleware are routines for processing web requests.

View file

@ -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,
),
]

View file

@ -26,11 +26,6 @@ class UserGroup(Group):
"model": "contact", "model": "contact",
"permissions": ["change_contact"], "permissions": ["change_contact"],
}, },
{
"app_label": "registrar",
"model": "domaininformation",
"permissions": ["change_domaininformation"],
},
{ {
"app_label": "registrar", "app_label": "registrar",
"model": "domainrequest", "model": "domainrequest",

View file

@ -21,6 +21,8 @@
{% else %} {% else %}
election office = Yes election office = Yes
{% endif %} {% endif %}
{% elif filter_param.parameter_name == 'status__in' %}
status in [{{ filter_param.parameter_value }}]
{% else %} {% else %}
{{ filter_param.parameter_name }} = {{ filter_param.parameter_value }} {{ filter_param.parameter_name }} = {{ filter_param.parameter_value }}
{% endif %} {% endif %}

View file

@ -1,4 +1,5 @@
{% extends 'admin/change_form.html' %} {% extends 'admin/change_form.html' %}
{% load custom_filters %}
{% load i18n static %} {% load i18n static %}
{% block field_sets %} {% block field_sets %}
@ -102,5 +103,25 @@
</button> </button>
</div> </div>
</div> </div>
{# submit-row-wrapper--analyst-view is a class that manages layout on certain screens for analysts only #}
<div class="submit-row-wrapper{% if not request.user|has_permission:'registrar.full_access_permission' %} submit-row-wrapper--analyst-view{% endif %}">
<span class="submit-row-toggle padding-1 padding-right-2 visible-desktop">
<button type="button" class="usa-button usa-button--unstyled" id="submitRowToggle">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#expand_more"></use>
</svg>
<span>Hide</span>
</button>
</span>
<p class="text-right margin-top-2 padding-right-2 margin-bottom-0 requested-domain-sticky float-right visible-768">
<strong>Requested domain:</strong> {{ original.requested_domain.name }}
</p>
{{ block.super }} {{ block.super }}
</div>
<span class="scroll-indicator"></span>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,37 @@
{% load i18n %}
{% load static field_helpers url_helpers %}
<h3>{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}</h3>
<ul class="mulitple-choice">
{% for choice in choices %}
{% if choice.reset %}
<li{% if choice.selected %} class="selected"{% endif %}>
<a href="{{ choice.query_string|iriencode }}" title="{{ choice.display }}">{{ choice.display }}</a>
</li>
{% endif %}
{% endfor %}
{% for choice in choices %}
{% if not choice.reset %}
<li{% if choice.selected %} class="selected"{% endif %}>
{% if choice.selected and choice.exclude_query_string %}
<a class="choice-filter choice-filter--checked" href="{{ choice.exclude_query_string|iriencode }}">{{ choice.display }}
<svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#check_box_outline_blank"></use>
</svg>
<svg class="usa-icon position-absolute z-100 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#check"></use>
</svg>
</a>
{% endif %}
{% if not choice.selected and choice.include_query_string %}
<a class="choice-filter" href="{{ choice.include_query_string|iriencode }}">{{ choice.display }}
<svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#check_box_outline_blank"></use>
</svg>
</a>
{% endif %}
</li>
{% endif %}
{% endfor %}
</ul>

View file

@ -41,7 +41,7 @@
</div> </div>
</div> </div>
<br> <br>
<p> <b class="review__step__name">Last updated:</b> {{DomainRequest.updated_at|date:"F j, Y"}}<br></p> <p><b class="review__step__name">Last updated:</b> {{DomainRequest.updated_at|date:"F j, Y"}}</p>
<p>{% include "includes/domain_request.html" %}</p> <p>{% include "includes/domain_request.html" %}</p>
<p><a href="{% url 'domain-request-withdraw-confirmation' pk=DomainRequest.id %}" class="usa-button usa-button--outline withdraw_outline"> <p><a href="{% url 'domain-request-withdraw-confirmation' pk=DomainRequest.id %}" class="usa-button usa-button--outline withdraw_outline">
Withdraw request</a> Withdraw request</a>

View file

@ -135,7 +135,7 @@
<td> <td>
{% if invitation.status == invitation.DomainInvitationStatus.INVITED %} {% if invitation.status == invitation.DomainInvitationStatus.INVITED %}
<form method="POST" action="{% url "invitation-delete" pk=invitation.id %}"> <form method="POST" action="{% url "invitation-delete" pk=invitation.id %}">
{% csrf_token %}<input type="submit" class="usa-button--unstyled text-no-underline" value="Cancel"> {% csrf_token %}<input type="submit" class="usa-button--unstyled text-no-underline cursor-pointer" value="Cancel">
</form> </form>
{% endif %} {% endif %}
</td> </td>

View file

@ -1,5 +1,8 @@
<div class="usa-alert usa-alert--emergency margin-y-0 {% if add_class %}{{ add_class }}{% endif %}"> <div class="usa-site-alert--emergency margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert"
>
<div class="usa-alert">
<div class="usa-alert__body {% if add_body_class %}{{ add_body_class }}{% endif %}"> <div class="usa-alert__body {% if add_body_class %}{{ add_body_class }}{% endif %}">
<b>Attention:</b> You are on a test site. <b>Attention:</b> You are on a test site.
</div> </div>
</div> </div>
</div>

View file

@ -62,3 +62,8 @@ def get_organization_long_name(generic_org_type):
return "Error" return "Error"
return long_form_type return long_form_type
@register.filter(name="has_permission")
def has_permission(user, permission):
return user.has_perm(permission)

View file

@ -693,6 +693,24 @@ class MockDb(TestCase):
user=meoward_user, domain=self.domain_12, role=UserDomainRole.Roles.MANAGER 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(): with less_console_noise():
self.domain_request_1 = completed_domain_request( self.domain_request_1 = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED, name="city1.gov" status=DomainRequest.DomainRequestStatus.STARTED, name="city1.gov"
@ -722,6 +740,7 @@ class MockDb(TestCase):
DomainRequest.objects.all().delete() DomainRequest.objects.all().delete()
User.objects.all().delete() User.objects.all().delete()
UserDomainRole.objects.all().delete() UserDomainRole.objects.all().delete()
DomainInvitation.objects.all().delete()
def mock_user(): def mock_user():

View file

@ -129,6 +129,83 @@ class TestDomainAdmin(MockEppLib, WebTest):
) )
mock_add_message.assert_has_calls([expected_call], 1) 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)) @patch("registrar.admin.DomainAdmin._get_current_date", return_value=date(2024, 1, 1))
def test_extend_expiration_date_button_epp(self, mock_date_today): def test_extend_expiration_date_button_epp(self, mock_date_today):
""" """
@ -706,15 +783,26 @@ class TestDomainRequestAdmin(MockEppLib):
with less_console_noise(): with less_console_noise():
self.client.force_login(self.superuser) self.client.force_login(self.superuser)
completed_domain_request() completed_domain_request()
response = self.client.get("/admin/registrar/domainrequest/") response = self.client.get("/admin/registrar/domainrequest/?generic_org_type__exact=federal")
# There are 4 template references to Federal (4) plus two references in the table # There are 2 template references to Federal (4) and two in the results data
# for our actual domain request # of the request
self.assertContains(response, "Federal", count=34) self.assertContains(response, "Federal", count=34)
# This may be a bit more robust # This may be a bit more robust
self.assertContains(response, '<td class="field-generic_org_type">Federal</td>', count=1) self.assertContains(response, '<td class="field-generic_org_type">Federal</td>', count=1)
# Now let's make sure the long description does not exist # Now let's make sure the long description does not exist
self.assertNotContains(response, "Federal: an agency of the U.S. government") 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): def transition_state_and_send_email(self, domain_request, status, rejection_reason=None):
"""Helper method for the email test cases.""" """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) self.assertEqual(domain_request.requested_domain.name, domain_request.approved_domain.name)
@less_console_noise_decorator @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 = "<strong>Requested domain:</strong>"
expected_content2 = '<span class="scroll-indicator"></span>'
expected_content3 = '<div class="submit-row-wrapper">'
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 = "<strong>Requested domain:</strong>"
expected_content2 = '<span class="scroll-indicator"></span>'
expected_content3 = '<div class="submit-row-wrapper submit-row-wrapper--analyst-view">'
self.assertContains(request, expected_content)
self.assertContains(request, expected_content2)
self.assertContains(request, expected_content3)
def test_other_contacts_has_readonly_link(self): def test_other_contacts_has_readonly_link(self):
"""Tests if the readonly other_contacts field has links""" """Tests if the readonly other_contacts field has links"""
@ -1665,7 +1808,7 @@ class TestDomainRequestAdmin(MockEppLib):
# Grab the current list of table filters # Grab the current list of table filters
readonly_fields = self.admin.get_list_filter(request) readonly_fields = self.admin.get_list_filter(request)
expected_fields = ( expected_fields = (
"status", DomainRequestAdmin.StatusListFilter,
"generic_org_type", "generic_org_type",
"federal_type", "federal_type",
DomainRequestAdmin.ElectionOfficeFilter, DomainRequestAdmin.ElectionOfficeFilter,
@ -1961,8 +2104,8 @@ class TestDomainInformationAdmin(TestCase):
# Get the other contact # Get the other contact
other_contact = domain_info.other_contacts.all().first() other_contact = domain_info.other_contacts.all().first()
p = "userpass" p = "adminpass"
self.client.login(username="staffuser", password=p) self.client.login(username="superuser", password=p)
response = self.client.get( response = self.client.get(
"/admin/registrar/domaininformation/{}/change/".format(domain_info.pk), "/admin/registrar/domaininformation/{}/change/".format(domain_info.pk),
@ -1983,6 +2126,44 @@ class TestDomainInformationAdmin(TestCase):
expected_url = "Testy Tester</a>" expected_url = "Testy Tester</a>"
self.assertContains(response, expected_url) 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 @less_console_noise_decorator
def test_contact_fields_have_detail_table(self): def test_contact_fields_have_detail_table(self):
"""Tests if the contact fields have the detail table which displays title, email, and phone""" """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_request.approve()
domain_info = DomainInformation.objects.filter(domain=domain_request.approved_domain).get() domain_info = DomainInformation.objects.filter(domain=domain_request.approved_domain).get()
p = "userpass" p = "adminpass"
self.client.login(username="staffuser", password=p) self.client.login(username="superuser", password=p)
response = self.client.get( response = self.client.get(
"/admin/registrar/domaininformation/{}/change/".format(domain_info.pk), "/admin/registrar/domaininformation/{}/change/".format(domain_info.pk),
follow=True, follow=True,

View file

@ -34,7 +34,6 @@ class TestGroups(TestCase):
"view_logentry", "view_logentry",
"change_contact", "change_contact",
"view_domain", "view_domain",
"change_domaininformation",
"add_domaininvitation", "add_domaininvitation",
"view_domaininvitation", "view_domaininvitation",
"change_domainrequest", "change_domainrequest",

View file

@ -9,10 +9,10 @@ from registrar.utility.csv_export import (
export_data_unmanaged_domains_to_csv, export_data_unmanaged_domains_to_csv,
get_sliced_domains, get_sliced_domains,
get_sliced_requests, get_sliced_requests,
write_domains_csv, write_csv_for_domains,
get_default_start_date, get_default_start_date,
get_default_end_date, get_default_end_date,
write_requests_csv, write_csv_for_requests,
) )
from django.core.management import call_command from django.core.management import call_command
@ -242,8 +242,13 @@ class ExportDataTest(MockDb, MockEppLib):
} }
self.maxDiff = None self.maxDiff = None
# Call the export functions # Call the export functions
write_domains_csv( write_csv_for_domains(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True writer,
columns,
sort_fields,
filter_condition,
should_get_domain_managers=False,
should_write_header=True,
) )
# Reset the CSV file's position to the beginning # 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() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.assertEqual(csv_content, expected_content) 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 """Test that write_body returns the
existing domain, test that sort by domain name works, existing domain, test that sort by domain name works,
test that filter works""" test that filter works"""
@ -304,8 +309,13 @@ class ExportDataTest(MockDb, MockEppLib):
], ],
} }
# Call the export functions # Call the export functions
write_domains_csv( write_csv_for_domains(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True writer,
columns,
sort_fields,
filter_condition,
should_get_domain_managers=False,
should_write_header=True,
) )
# Reset the CSV file's position to the beginning # Reset the CSV file's position to the beginning
csv_file.seek(0) csv_file.seek(0)
@ -357,8 +367,13 @@ class ExportDataTest(MockDb, MockEppLib):
], ],
} }
# Call the export functions # Call the export functions
write_domains_csv( write_csv_for_domains(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True writer,
columns,
sort_fields,
filter_condition,
should_get_domain_managers=False,
should_write_header=True,
) )
# Reset the CSV file's position to the beginning # Reset the CSV file's position to the beginning
csv_file.seek(0) csv_file.seek(0)
@ -433,20 +448,20 @@ class ExportDataTest(MockDb, MockEppLib):
} }
# Call the export functions # Call the export functions
write_domains_csv( write_csv_for_domains(
writer, writer,
columns, columns,
sort_fields, sort_fields,
filter_condition, filter_condition,
get_domain_managers=False, should_get_domain_managers=False,
should_write_header=True, should_write_header=True,
) )
write_domains_csv( write_csv_for_domains(
writer, writer,
columns, columns,
sort_fields_for_deleted_domains, sort_fields_for_deleted_domains,
filter_conditions_for_deleted_domains, filter_conditions_for_deleted_domains,
get_domain_managers=False, should_get_domain_managers=False,
should_write_header=False, should_write_header=False,
) )
# Reset the CSV file's position to the beginning # 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): def test_export_domains_to_writer_domain_managers(self):
"""Test that export_domains_to_writer returns the """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(): with less_console_noise():
# Create a CSV file in memory # Create a CSV file in memory
@ -508,8 +528,13 @@ class ExportDataTest(MockDb, MockEppLib):
} }
self.maxDiff = None self.maxDiff = None
# Call the export functions # Call the export functions
write_domains_csv( write_csv_for_domains(
writer, columns, sort_fields, filter_condition, get_domain_managers=True, should_write_header=True writer,
columns,
sort_fields,
filter_condition,
should_get_domain_managers=True,
should_write_header=True,
) )
# Reset the CSV file's position to the beginning # Reset the CSV file's position to the beginning
@ -521,14 +546,16 @@ class ExportDataTest(MockDb, MockEppLib):
expected_content = ( expected_content = (
"Domain name,Status,Expiration date,Domain type,Agency," "Domain name,Status,Expiration date,Domain type,Agency,"
"Organization name,City,State,AO,AO email," "Organization name,City,State,AO,AO email,"
"Security contact email,Domain manager email 1,Domain manager email 2,Domain manager email 3\n" "Security contact email,Domain manager 1,DM1 status,Domain manager 2,DM2 status,"
"adomain10.gov,Ready,,Federal,Armed Forces Retirement Home,,,, , ,\n" "Domain manager 3,DM3 status,Domain manager 4,DM4 status\n"
"adomain2.gov,Dns needed,,Interstate,,,,, , , ,meoward@rocks.com\n" "adomain10.gov,Ready,,Federal,Armed Forces Retirement Home,,,, , ,squeaker@rocks.com, I\n"
"cdomain11.govReadyFederal-ExecutiveWorldWarICentennialCommissionmeoward@rocks.com\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,,," "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" "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, # Normalize line endings and remove commas,
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
@ -538,7 +565,9 @@ class ExportDataTest(MockDb, MockEppLib):
def test_export_data_managed_domains_to_csv(self): def test_export_data_managed_domains_to_csv(self):
"""Test get counts for domains that have domain managers for two different dates, """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(): with less_console_noise():
# Create a CSV file in memory # Create a CSV file in memory
@ -564,10 +593,12 @@ class ExportDataTest(MockDb, MockEppLib):
"Special district,School district,Election office\n" "Special district,School district,Election office\n"
"3,2,1,0,0,0,0,0,0,0\n" "3,2,1,0,0,0,0,0,0,0\n"
"\n" "\n"
"Domain name,Domain type,Domain manager email 1,Domain manager email 2,Domain manager email 3\n" "Domain name,Domain type,Domain manager 1,DM1 status,Domain manager 2,DM2 status,"
"cdomain11.govFederal-Executivemeoward@rocks.com\n" "Domain manager 3,DM3 status,Domain manager 4,DM4 status\n"
"cdomain1.gov,Federal - Executive,meoward@rocks.com,info@example.com,big_lebowski@dude.co\n" "cdomain11.govFederal-Executivemeoward@rocks.com, R\n"
"zdomain12.govInterstatemeoward@rocks.com\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, # Normalize line endings and remove commas,
@ -642,7 +673,7 @@ class ExportDataTest(MockDb, MockEppLib):
"submission_date__lte": self.end_date, "submission_date__lte": self.end_date,
"submission_date__gte": self.start_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 # Reset the CSV file's position to the beginning
csv_file.seek(0) csv_file.seek(0)
# Read the content into a variable # Read the content into a variable
@ -686,7 +717,7 @@ class HelperFunctions(MockDb):
"domain__first_ready__lte": self.end_date, "domain__first_ready__lte": self.end_date,
} }
# Test with distinct # 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] expected_content = [3, 2, 1, 0, 0, 0, 0, 0, 0, 0]
self.assertEqual(managed_domains_sliced_at_end_date, expected_content) self.assertEqual(managed_domains_sliced_at_end_date, expected_content)

View file

@ -1,8 +1,8 @@
from collections import Counter
import csv import csv
import logging import logging
from datetime import datetime from datetime import datetime
from registrar.models.domain import Domain from registrar.models.domain import Domain
from registrar.models.domain_invitation import DomainInvitation
from registrar.models.domain_request import DomainRequest from registrar.models.domain_request import DomainRequest
from registrar.models.domain_information import DomainInformation from registrar.models.domain_information import DomainInformation
from django.utils import timezone 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 django.db.models.functions import Concat, Coalesce
from registrar.models.public_contact import PublicContact from registrar.models.public_contact import PublicContact
from registrar.models.user_domain_role import UserDomainRole
from registrar.utility.enums import DefaultEmail from registrar.utility.enums import DefaultEmail
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -33,7 +34,6 @@ def get_domain_infos(filter_condition, sort_fields):
""" """
domain_infos = ( domain_infos = (
DomainInformation.objects.select_related("domain", "authorizing_official") DomainInformation.objects.select_related("domain", "authorizing_official")
.prefetch_related("domain__permissions")
.filter(**filter_condition) .filter(**filter_condition)
.order_by(*sort_fields) .order_by(*sort_fields)
.distinct() .distinct()
@ -53,7 +53,14 @@ def get_domain_infos(filter_condition, sort_fields):
return domain_infos_cleaned 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""" """Given a set of columns, generate a new row from cleaned column data"""
# Domain should never be none when parsing this information # 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. # Grab the security email from a preset dictionary.
# If nothing exists in the dictionary, grab from .contacts. # If nothing exists in the dictionary, grab from .contacts.
if security_emails_dict is not None and domain.name in security_emails_dict: if dict_security_emails is not None and domain.name in dict_security_emails:
_email = security_emails_dict.get(domain.name) _email = dict_security_emails.get(domain.name)
security_email = _email if _email is not None else " " security_email = _email if _email is not None else " "
else: else:
# If the dictionary doesn't contain that data, lets filter for it manually. # 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, "Deleted": domain.deleted,
} }
if get_domain_managers: if should_get_domain_managers:
# Get each domain managers email and add to list # Get lists of emails for active and invited domain managers
dm_emails = [dm.user.email for dm in domain.permissions.all()]
# Set up the "matching header" + row field data dms_active_emails = dict_user_domain_roles.get(domain_info.domain.name, [])
for i, dm_email in enumerate(dm_emails, start=1): dms_invited_emails = dict_domain_invitations_with_invited_status.get(domain_info.domain.name, [])
FIELDS[f"Domain manager email {i}"] = dm_email
# 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] row = [FIELDS.get(column, "") for column in columns]
return row return row
@ -119,7 +135,7 @@ def _get_security_emails(sec_contact_ids):
""" """
Retrieve security contact emails for the given security contact IDs. Retrieve security contact emails for the given security contact IDs.
""" """
security_emails_dict = {} dict_security_emails = {}
public_contacts = ( public_contacts = (
PublicContact.objects.only("email", "domain__name") PublicContact.objects.only("email", "domain__name")
.select_related("domain") .select_related("domain")
@ -129,65 +145,151 @@ def _get_security_emails(sec_contact_ids):
# Populate a dictionary of domain names and their security contacts # Populate a dictionary of domain names and their security contacts
for contact in public_contacts: for contact in public_contacts:
domain: Domain = contact.domain domain: Domain = contact.domain
if domain is not None and domain.name not in security_emails_dict: if domain is not None and domain.name not in dict_security_emails:
security_emails_dict[domain.name] = contact.email dict_security_emails[domain.name] = contact.email
else: else:
logger.warning("csv_export -> Domain was none for PublicContact") 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, writer,
columns, columns,
sort_fields, sort_fields,
filter_condition, filter_condition,
get_domain_managers=False, should_get_domain_managers=False,
should_write_header=True, should_write_header=True,
): ):
""" """
Receives params from the parent methods and outputs a CSV with filtered and sorted domains. 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. 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 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) 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) sec_contact_ids = all_domain_infos.values_list("domain__security_contact_registry_id", flat=True)
dict_security_emails = _get_security_emails(sec_contact_ids)
security_emails_dict = _get_security_emails(sec_contact_ids)
# Reduce the memory overhead when performing the write operation
paginator = Paginator(all_domain_infos, 1000) paginator = Paginator(all_domain_infos, 1000)
# The maximum amount of domain managers an account has # Initialize variables
# We get the max so we can set the column header accurately dms_total = 0
max_dm_count = 0 should_update_columns = False
total_body_rows = [] 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: for page_num in paginator.page_range:
rows = [] rows = []
page = paginator.page(page_num) page = paginator.page(page_num)
for domain_info in page.object_list: for domain_info in page.object_list:
if should_get_domain_managers:
# Get count of all the domain managers for an account columns, dms_total, should_update_columns = update_columns_with_domain_managers(
if get_domain_managers: columns,
dm_count = domain_info.domain.permissions.count() domain_info,
if dm_count > max_dm_count: should_update_columns,
max_dm_count = dm_count dms_total,
for i in range(1, max_dm_count + 1): dict_domain_invitations_with_invited_status,
column_name = f"Domain manager email {i}" dict_user_domain_roles,
if column_name not in columns: )
columns.append(column_name)
try: 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) rows.append(row)
except ValueError: 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") logger.error("csv_export -> Error when parsing row, domain was None")
continue continue
total_body_rows.extend(rows) total_body_rows.extend(rows)
@ -208,7 +310,7 @@ def get_requests(filter_condition, sort_fields):
return requests 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""" """Given a set of columns, generate a new row from cleaned column data"""
requested_domain_name = "No requested domain" requested_domain_name = "No requested domain"
@ -240,7 +342,7 @@ def parse_request_row(columns, request: DomainRequest):
return row return row
def write_requests_csv( def write_csv_for_requests(
writer, writer,
columns, columns,
sort_fields, sort_fields,
@ -261,7 +363,7 @@ def write_requests_csv(
rows = [] rows = []
for request in page.object_list: for request in page.object_list:
try: try:
row = parse_request_row(columns, request) row = parse_row_for_requests(columns, request)
rows.append(row) rows.append(row)
except ValueError: except ValueError:
# This should not happen. If it does, just skip this row. # 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, Domain.State.ON_HOLD,
], ],
} }
write_domains_csv( write_csv_for_domains(
writer, columns, sort_fields, filter_condition, get_domain_managers=True, should_write_header=True 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, Domain.State.ON_HOLD,
], ],
} }
write_domains_csv( write_csv_for_domains(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True 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, Domain.State.ON_HOLD,
], ],
} }
write_domains_csv( write_csv_for_domains(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True 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, "domain__deleted__gte": start_date_formatted,
} }
write_domains_csv( write_csv_for_domains(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True
) )
write_domains_csv( write_csv_for_domains(
writer, writer,
columns, columns,
sort_fields_for_deleted_domains, sort_fields_for_deleted_domains,
filter_condition_for_deleted_domains, filter_condition_for_deleted_domains,
get_domain_managers=False, should_get_domain_managers=False,
should_write_header=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. """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 Pass distinct=True when filtering by permissions so we do not to count multiples
when a domain has more that one manager. when a domain has more that one manager.
""" """
# Round trip 1: Get distinct domain names based on filter condition domains = DomainInformation.objects.all().filter(**filter_condition).distinct()
domains_count = DomainInformation.objects.filter(**filter_condition).distinct().count() domains_count = domains.count()
federal = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.FEDERAL).distinct().count()
# Round trip 2: Get counts for other slices interstate = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.INTERSTATE).count()
# This will require either 8 filterd and distinct DB round trips, state_or_territory = (
# or 2 DB round trips plus iteration on domain_permissions for each domain domains.filter(generic_org_type=DomainRequest.OrganizationChoices.STATE_OR_TERRITORY).distinct().count()
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 tribal = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.TRIBAL).distinct().count()
generic_org_type_counts = Counter() county = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.COUNTY).distinct().count()
city = domains.filter(generic_org_type=DomainRequest.OrganizationChoices.CITY).distinct().count()
# Keep track of domains already counted special_district = (
domains_counted = set() domains.filter(generic_org_type=DomainRequest.OrganizationChoices.SPECIAL_DISTRICT).distinct().count()
# 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
) )
school_district = (
# Check if the domain has multiple permissions domains.filter(generic_org_type=DomainRequest.OrganizationChoices.SCHOOL_DISTRICT).distinct().count()
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) election_board = domains.filter(is_election_board=True).distinct().count()
# 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()
return [ return [
domains_count, domains_count,
@ -535,26 +602,23 @@ def get_sliced_domains(filter_condition, distinct=False):
def get_sliced_requests(filter_condition): def get_sliced_requests(filter_condition):
"""Get filtered requests counts sliced by org type and election office.""" """Get filtered requests counts sliced by org type and election office."""
# Round trip 1: Get distinct requests based on filter condition requests = DomainRequest.objects.all().filter(**filter_condition).distinct()
requests_count = DomainRequest.objects.filter(**filter_condition).distinct().count() requests_count = requests.count()
federal = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.FEDERAL).distinct().count()
# Round trip 2: Get counts for other slices interstate = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.INTERSTATE).distinct().count()
generic_org_types_query = DomainRequest.objects.filter(**filter_condition).values_list( state_or_territory = (
"generic_org_type", flat=True requests.filter(generic_org_type=DomainRequest.OrganizationChoices.STATE_OR_TERRITORY).distinct().count()
) )
generic_org_type_counts = Counter(generic_org_types_query) tribal = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.TRIBAL).distinct().count()
county = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.COUNTY).distinct().count()
federal = generic_org_type_counts.get(DomainRequest.OrganizationChoices.FEDERAL, 0) city = requests.filter(generic_org_type=DomainRequest.OrganizationChoices.CITY).distinct().count()
interstate = generic_org_type_counts.get(DomainRequest.OrganizationChoices.INTERSTATE, 0) special_district = (
state_or_territory = generic_org_type_counts.get(DomainRequest.OrganizationChoices.STATE_OR_TERRITORY, 0) requests.filter(generic_org_type=DomainRequest.OrganizationChoices.SPECIAL_DISTRICT).distinct().count()
tribal = generic_org_type_counts.get(DomainRequest.OrganizationChoices.TRIBAL, 0) )
county = generic_org_type_counts.get(DomainRequest.OrganizationChoices.COUNTY, 0) school_district = (
city = generic_org_type_counts.get(DomainRequest.OrganizationChoices.CITY, 0) requests.filter(generic_org_type=DomainRequest.OrganizationChoices.SCHOOL_DISTRICT).distinct().count()
special_district = generic_org_type_counts.get(DomainRequest.OrganizationChoices.SPECIAL_DISTRICT, 0) )
school_district = generic_org_type_counts.get(DomainRequest.OrganizationChoices.SCHOOL_DISTRICT, 0) election_board = requests.filter(is_election_board=True).distinct().count()
# Round trip 3
election_board = DomainRequest.objects.filter(is_election_board=True, **filter_condition).distinct().count()
return [ return [
requests_count, requests_count,
@ -588,7 +652,7 @@ def export_data_managed_domains_to_csv(csv_file, start_date, end_date):
"domain__permissions__isnull": False, "domain__permissions__isnull": False,
"domain__first_ready__lte": start_date_formatted, "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(["MANAGED DOMAINS COUNTS AT START DATE"])
writer.writerow( writer.writerow(
@ -612,7 +676,7 @@ def export_data_managed_domains_to_csv(csv_file, start_date, end_date):
"domain__permissions__isnull": False, "domain__permissions__isnull": False,
"domain__first_ready__lte": end_date_formatted, "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(["MANAGED DOMAINS COUNTS AT END DATE"])
writer.writerow( 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(managed_domains_sliced_at_end_date)
writer.writerow([]) writer.writerow([])
write_domains_csv( write_csv_for_domains(
writer, writer,
columns, columns,
sort_fields, sort_fields,
filter_managed_domains_end_date, filter_managed_domains_end_date,
get_domain_managers=True, should_get_domain_managers=True,
should_write_header=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__permissions__isnull": True,
"domain__first_ready__lte": start_date_formatted, "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(["UNMANAGED DOMAINS AT START DATE"])
writer.writerow( writer.writerow(
@ -685,7 +749,7 @@ def export_data_unmanaged_domains_to_csv(csv_file, start_date, end_date):
"domain__permissions__isnull": True, "domain__permissions__isnull": True,
"domain__first_ready__lte": end_date_formatted, "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(["UNMANAGED DOMAINS AT END DATE"])
writer.writerow( 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(unmanaged_domains_sliced_at_end_date)
writer.writerow([]) writer.writerow([])
write_domains_csv( write_csv_for_domains(
writer, writer,
columns, columns,
sort_fields, sort_fields,
filter_unmanaged_domains_end_date, filter_unmanaged_domains_end_date,
get_domain_managers=False, should_get_domain_managers=False,
should_write_header=True, 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, "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)

View file

@ -49,8 +49,8 @@ class AnalyticsView(View):
"domain__permissions__isnull": False, "domain__permissions__isnull": False,
"domain__first_ready__lte": end_date_formatted, "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_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, True) managed_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_managed_domains_end_date)
filter_unmanaged_domains_start_date = { filter_unmanaged_domains_start_date = {
"domain__permissions__isnull": True, "domain__permissions__isnull": True,
@ -60,10 +60,8 @@ class AnalyticsView(View):
"domain__permissions__isnull": True, "domain__permissions__isnull": True,
"domain__first_ready__lte": end_date_formatted, "domain__first_ready__lte": end_date_formatted,
} }
unmanaged_domains_sliced_at_start_date = csv_export.get_sliced_domains( unmanaged_domains_sliced_at_start_date = csv_export.get_sliced_domains(filter_unmanaged_domains_start_date)
filter_unmanaged_domains_start_date, True unmanaged_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_unmanaged_domains_end_date)
)
unmanaged_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_unmanaged_domains_end_date, True)
filter_ready_domains_start_date = { filter_ready_domains_start_date = {
"domain__state__in": [models.Domain.State.READY], "domain__state__in": [models.Domain.State.READY],

View file

@ -307,7 +307,12 @@ class UserDeleteDomainRolePermission(PermissionsLoginMixin):
domain=domain_pk, domain=domain_pk,
domain__permissions__user=self.request.user, domain__permissions__user=self.request.user,
).exists() ).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 return False
# Check if more than one manager exists on the domain. # Check if more than one manager exists on the domain.

View file

@ -1,8 +1,8 @@
-i https://pypi.python.org/simple -i https://pypi.python.org/simple
annotated-types==0.6.0; python_version >= '3.8' annotated-types==0.6.0; python_version >= '3.8'
asgiref==3.7.2; python_version >= '3.7' asgiref==3.8.1; python_version >= '3.8'
boto3==1.34.56; python_version >= '3.8' boto3==1.34.71; python_version >= '3.8'
botocore==1.34.56; python_version >= '3.8' botocore==1.34.71; python_version >= '3.8'
cachetools==5.3.3; python_version >= '3.7' cachetools==5.3.3; python_version >= '3.7'
certifi==2024.2.2; python_version >= '3.6' certifi==2024.2.2; python_version >= '3.6'
cfenv==0.5.3 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-database-url==2.1.0
dj-email-url==1.0.6 dj-email-url==1.0.6
django==4.2.10; python_version >= '3.8' django==4.2.10; python_version >= '3.8'
django-admin-multiple-choice-list-filter==0.1.1
django-allow-cidr==0.7.1 django-allow-cidr==0.7.1
django-auditlog==2.3.0; python_version >= '3.7' django-auditlog==2.3.0; python_version >= '3.7'
django-cache-url==3.4.5 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-phonenumber-field[phonenumberslite]==7.3.0; python_version >= '3.8'
django-widget-tweaks==1.5.0; python_version >= '3.8' django-widget-tweaks==1.5.0; python_version >= '3.8'
environs[django]==11.0.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 fred-epplib@ git+https://github.com/cisagov/epplib.git@d56d183f1664f34c40ca9716a3a9a345f0ef561c
furl==2.1.3 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' 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' marshmallow==3.21.1; python_version >= '3.8'
oic==1.6.1; python_version ~= '3.7' oic==1.6.1; python_version ~= '3.7'
orderedmultidict==1.0.1 orderedmultidict==1.0.1
packaging==23.2; python_version >= '3.7' packaging==24.0; python_version >= '3.7'
phonenumberslite==8.13.31 phonenumberslite==8.13.33
psycopg2-binary==2.9.9; python_version >= '3.7' psycopg2-binary==2.9.9; python_version >= '3.7'
pycparser==2.21 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' 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-core==2.16.3; python_version >= '3.8'
pydantic-settings==2.2.1; python_version >= '3.8' pydantic-settings==2.2.1; python_version >= '3.8'
pyjwkest==1.4.2 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' python-dotenv==1.0.1; python_version >= '3.8'
pyzipper==0.3.6; python_version >= '3.4' pyzipper==0.3.6; python_version >= '3.4'
requests==2.31.0; python_version >= '3.7' requests==2.31.0; python_version >= '3.7'
s3transfer==0.10.0; python_version >= '3.8' s3transfer==0.10.1; python_version >= '3.8'
setuptools==69.1.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' 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' sqlparse==0.4.4; python_version >= '3.5'
tblib==3.0.0; python_version >= '3.8' tblib==3.0.0; python_version >= '3.8'
typing-extensions==4.10.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' whitenoise==6.6.0; python_version >= '3.8'
zope.event==5.0; python_version >= '3.7' zope.event==5.0; python_version >= '3.7'
zope.interface==6.2; python_version >= '3.7' zope.interface==6.2; python_version >= '3.7'