diff --git a/.github/workflows/deploy-development.yaml b/.github/workflows/deploy-development.yaml index 686635c20..e62607d95 100644 --- a/.github/workflows/deploy-development.yaml +++ b/.github/workflows/deploy-development.yaml @@ -22,9 +22,16 @@ jobs: - name: Compile USWDS assets working-directory: ./src run: | - docker compose run node npm install && - docker compose run node npx gulp copyAssets && - docker compose run node npx gulp compile + docker compose run node bash -c "\ + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash && \ + export NVM_DIR=\"\$HOME/.nvm\" && \ + [ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" && \ + [ -s \"\$NVM_DIR/bash_completion\" ] && \. \"\$NVM_DIR/bash_completion\" && \ + nvm install 21.7.3 && \ + nvm use 21.7.3 && \ + npm install && \ + npx gulp copyAssets && \ + npx gulp compile" - name: Collect static assets working-directory: ./src run: docker compose run app python manage.py collectstatic --no-input @@ -45,4 +52,4 @@ jobs: cf_password: ${{ secrets.CF_DEVELOPMENT_PASSWORD }} cf_org: cisa-dotgov cf_space: development - cf_command: "run-task getgov-development --command 'python manage.py migrate' --name migrate" + cf_command: "run-task getgov-development --command 'python manage.py migrate' --name migrate" \ No newline at end of file diff --git a/.github/workflows/deploy-sandbox.yaml b/.github/workflows/deploy-sandbox.yaml index f7f4a0d65..d9d7cbe14 100644 --- a/.github/workflows/deploy-sandbox.yaml +++ b/.github/workflows/deploy-sandbox.yaml @@ -42,9 +42,16 @@ jobs: - name: Compile USWDS assets working-directory: ./src run: | - docker compose run node npm install && - docker compose run node npx gulp copyAssets && - docker compose run node npx gulp compile + docker compose run node bash -c "\ + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash && \ + export NVM_DIR=\"\$HOME/.nvm\" && \ + [ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" && \ + [ -s \"\$NVM_DIR/bash_completion\" ] && \. \"\$NVM_DIR/bash_completion\" && \ + nvm install 21.7.3 && \ + nvm use 21.7.3 && \ + npm install && \ + npx gulp copyAssets && \ + npx gulp compile" - name: Collect static assets working-directory: ./src run: docker compose run app python manage.py collectstatic --no-input @@ -75,4 +82,4 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, body: '🥳 Successfully deployed to developer sandbox **[${{ env.ENVIRONMENT }}](https://getgov-${{ env.ENVIRONMENT }}.app.cloud.gov/)**.' - }) + }) \ No newline at end of file diff --git a/.github/workflows/deploy-stable.yaml b/.github/workflows/deploy-stable.yaml index 0ded4a3a6..9d0573e01 100644 --- a/.github/workflows/deploy-stable.yaml +++ b/.github/workflows/deploy-stable.yaml @@ -23,9 +23,16 @@ jobs: - name: Compile USWDS assets working-directory: ./src run: | - docker compose run node npm install && - docker compose run node npx gulp copyAssets && - docker compose run node npx gulp compile + docker compose run node bash -c "\ + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash && \ + export NVM_DIR=\"\$HOME/.nvm\" && \ + [ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" && \ + [ -s \"\$NVM_DIR/bash_completion\" ] && \. \"\$NVM_DIR/bash_completion\" && \ + nvm install 21.7.3 && \ + nvm use 21.7.3 && \ + npm install && \ + npx gulp copyAssets && \ + npx gulp compile" - name: Collect static assets working-directory: ./src run: docker compose run app python manage.py collectstatic --no-input diff --git a/.github/workflows/deploy-staging.yaml b/.github/workflows/deploy-staging.yaml index 1df08f412..9584985f0 100644 --- a/.github/workflows/deploy-staging.yaml +++ b/.github/workflows/deploy-staging.yaml @@ -23,9 +23,16 @@ jobs: - name: Compile USWDS assets working-directory: ./src run: | - docker compose run node npm install && - docker compose run node npx gulp copyAssets && - docker compose run node npx gulp compile + docker compose run node bash -c "\ + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash && \ + export NVM_DIR=\"\$HOME/.nvm\" && \ + [ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" && \ + [ -s \"\$NVM_DIR/bash_completion\" ] && \. \"\$NVM_DIR/bash_completion\" && \ + nvm install 21.7.3 && \ + nvm use 21.7.3 && \ + npm install && \ + npx gulp copyAssets && \ + npx gulp compile" - name: Collect static assets working-directory: ./src run: docker compose run app python manage.py collectstatic --no-input @@ -44,4 +51,4 @@ jobs: cf_password: ${{ secrets.CF_STAGING_PASSWORD }} cf_org: cisa-dotgov cf_space: staging - cf_command: "run-task getgov-staging --command 'python manage.py migrate' --name migrate" + cf_command: "run-task getgov-staging --command 'python manage.py migrate' --name migrate" \ No newline at end of file diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md index 0846208de..e4543a28c 100644 --- a/docs/operations/data_migration.md +++ b/docs/operations/data_migration.md @@ -602,18 +602,18 @@ That data are synthesized from the generic_org_type field and the is_election_bo The latest domain_election_board csv can be found [here](https://drive.google.com/file/d/1aDeCqwHmBnXBl2arvoFCN0INoZmsEGsQ/view). After downloading this file, place it in `src/migrationdata` -#### Step 2: Upload the domain_election_board file to your sandbox +#### Step 3: Upload the domain_election_board file to your sandbox Follow [Step 1: Transfer data to sandboxes](#step-1-transfer-data-to-sandboxes) and [Step 2: Transfer uploaded files to the getgov directory](#step-2-transfer-uploaded-files-to-the-getgov-directory) from the [Set Up Migrations on Sandbox](#set-up-migrations-on-sandbox) portion of this doc. -#### Step 2: SSH into your environment +#### Step 4: SSH into your environment ```cf ssh getgov-{space}``` Example: `cf ssh getgov-za` -#### Step 3: Create a shell instance +#### Step 5: Create a shell instance ```/tmp/lifecycle/shell``` -#### Step 4: Running the script +#### Step 6: Running the script ```./manage.py populate_organization_type {domain_election_board_filename}``` - The domain_election_board_filename file must adhere to this format: @@ -642,3 +642,29 @@ Example (assuming that this is being ran from src/): | | Parameter | Description | |:-:|:------------------------------------|:-------------------------------------------------------------------| | 1 | **domain_election_board_filename** | A file containing every domain that is an election office. + + +## Populate Verification Type +This section outlines how to run the `populate_verification_type` script. +The script is used to update the verification_type field on User when it is None. + +### Running on sandboxes + +#### Step 1: Login to CloudFoundry +```cf login -a api.fr.cloud.gov --sso``` + +#### Step 2: SSH into your environment +```cf ssh getgov-{space}``` + +Example: `cf ssh getgov-za` + +#### Step 3: Create a shell instance +```/tmp/lifecycle/shell``` + +#### Step 4: Running the script +```./manage.py populate_verification_type``` + +### Running locally + +#### Step 1: Running the script +```docker-compose exec app ./manage.py populate_verification_type``` diff --git a/src/Pipfile b/src/Pipfile index c1f03f532..33abf0158 100644 --- a/src/Pipfile +++ b/src/Pipfile @@ -9,7 +9,7 @@ cfenv = "*" django-cors-headers = "*" pycryptodomex = "*" django-allow-cidr = "*" -django-auditlog = "2.3.0" +django-auditlog = "*" django-csp = "*" environs = {extras=["django"]} Faker = "*" diff --git a/src/Pipfile.lock b/src/Pipfile.lock index e485a5202..f21b833ce 100644 --- a/src/Pipfile.lock +++ b/src/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "99cf9a4f3912639c02105889046a9eede7a29822fab6f9a04ca25f95e29513c0" + "sha256": "16a0db98015509322cf1d27f06fced5b7635057c4eb98921a9419d63d51925ab" }, "pipfile-spec": 6, "requires": {}, @@ -32,20 +32,20 @@ }, "boto3": { "hashes": [ - "sha256:168894499578a9d69d6f7deb5811952bf4171c51b95749a9aef32cf67bc71f87", - "sha256:1bd4cef11b7c5f293cede50f3d33ca89fe3413c51f1864f40163c56a732dd6b3" + "sha256:2824e3dd18743ca50e5b10439d20e74647b1416e8a94509cb30beac92d27a18d", + "sha256:b2e5cb5b95efcc881e25a3bc872d7a24e75ff4e76f368138e4baf7b9d6ee3422" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.34.88" + "version": "==1.34.90" }, "botocore": { "hashes": [ - "sha256:36f2e9e8dfa856e55dbbe703aea601f134db3fddc3615f1020a755b27fd26a5e", - "sha256:e87a660599ed3e14b2a770f4efc3df2f2f6d04f3c7bfd64ddbae186667864a7b" + "sha256:113cd4c0cb63e13163ccbc2bb13d551be314ba7f8ba5bfab1c51a19ca01aa133", + "sha256:d48f152498e2c60b43ce25b579d26642346a327b6fb2c632d57219e0a4f63392" ], "markers": "python_version >= '3.8'", - "version": "==1.34.88" + "version": "==1.34.90" }, "cachetools": { "hashes": [ @@ -321,12 +321,12 @@ }, "django-auditlog": { "hashes": [ - "sha256:7bc2c87e4aff62dec9785d1b2359a2b27148f8c286f8a52b9114fc7876c5a9f7", - "sha256:b9d3acebb64f3f2785157efe3f2f802e0929aafc579d85bbfb9827db4adab532" + "sha256:92db1cf4a51ceca5c26b3ff46997d9e3305a02da1bd435e2efb5b8b6d300ce1f", + "sha256:9de49f80a4911135d136017123cd73461f869b4947eec14d5e76db4b88182f3f" ], "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==2.3.0" + "markers": "python_version >= '3.8'", + "version": "==3.0.0" }, "django-cache-url": { "hashes": [ @@ -1003,96 +1003,96 @@ }, "pydantic": { "hashes": [ - "sha256:9dee74a271705f14f9a1567671d144a851c675b072736f0a7b2608fd9e495352", - "sha256:b5ecdd42262ca2462e2624793551e80911a1e989f462910bb81aef974b4bb383" + "sha256:e029badca45266732a9a79898a15ae2e8b14840b1eabbb25844be28f0b33f3d5", + "sha256:e9dbb5eada8abe4d9ae5f46b9939aead650cd2b68f249bb3a8139dbe125803cc" ], "markers": "python_version >= '3.8'", - "version": "==2.7.0" + "version": "==2.7.1" }, "pydantic-core": { "hashes": [ - "sha256:030e4f9516f9947f38179249778709a460a3adb516bf39b5eb9066fcfe43d0e6", - "sha256:09f03dfc0ef8c22622eaa8608caa4a1e189cfb83ce847045eca34f690895eccb", - "sha256:12a05db5013ec0ca4a32cc6433f53faa2a014ec364031408540ba858c2172bb0", - "sha256:14fe73881cf8e4cbdaded8ca0aa671635b597e42447fec7060d0868b52d074e6", - "sha256:1a0c3e718f4e064efde68092d9d974e39572c14e56726ecfaeebbe6544521f47", - "sha256:1be91ad664fc9245404a789d60cba1e91c26b1454ba136d2a1bf0c2ac0c0505a", - "sha256:201713f2f462e5c015b343e86e68bd8a530a4f76609b33d8f0ec65d2b921712a", - "sha256:2027493cc44c23b598cfaf200936110433d9caa84e2c6cf487a83999638a96ac", - "sha256:250ae39445cb5475e483a36b1061af1bc233de3e9ad0f4f76a71b66231b07f88", - "sha256:2533ad2883f001efa72f3d0e733fb846710c3af6dcdd544fe5bf14fa5fe2d7db", - "sha256:25595ac311f20e5324d1941909b0d12933f1fd2171075fcff763e90f43e92a0d", - "sha256:2684a94fdfd1b146ff10689c6e4e815f6a01141781c493b97342cdc5b06f4d5d", - "sha256:27f1009dc292f3b7ca77feb3571c537276b9aad5dd4efb471ac88a8bd09024e9", - "sha256:2adaeea59849ec0939af5c5d476935f2bab4b7f0335b0110f0f069a41024278e", - "sha256:2ae80f72bb7a3e397ab37b53a2b49c62cc5496412e71bc4f1277620a7ce3f52b", - "sha256:2d5728e93d28a3c63ee513d9ffbac9c5989de8c76e049dbcb5bfe4b923a9739d", - "sha256:2e91711e36e229978d92642bfc3546333a9127ecebb3f2761372e096395fc649", - "sha256:2fe0c1ce5b129455e43f941f7a46f61f3d3861e571f2905d55cdbb8b5c6f5e2c", - "sha256:38a5024de321d672a132b1834a66eeb7931959c59964b777e8f32dbe9523f6b1", - "sha256:3e352f0191d99fe617371096845070dee295444979efb8f27ad941227de6ad09", - "sha256:48dd883db92e92519201f2b01cafa881e5f7125666141a49ffba8b9facc072b0", - "sha256:54764c083bbe0264f0f746cefcded6cb08fbbaaf1ad1d78fb8a4c30cff999a90", - "sha256:54c7375c62190a7845091f521add19b0f026bcf6ae674bdb89f296972272e86d", - "sha256:561cf62c8a3498406495cfc49eee086ed2bb186d08bcc65812b75fda42c38294", - "sha256:56823a92075780582d1ffd4489a2e61d56fd3ebb4b40b713d63f96dd92d28144", - "sha256:582cf2cead97c9e382a7f4d3b744cf0ef1a6e815e44d3aa81af3ad98762f5a9b", - "sha256:58aca931bef83217fca7a390e0486ae327c4af9c3e941adb75f8772f8eeb03a1", - "sha256:5f7973c381283783cd1043a8c8f61ea5ce7a3a58b0369f0ee0ee975eaf2f2a1b", - "sha256:6395a4435fa26519fd96fdccb77e9d00ddae9dd6c742309bd0b5610609ad7fb2", - "sha256:63d7523cd95d2fde0d28dc42968ac731b5bb1e516cc56b93a50ab293f4daeaad", - "sha256:641a018af4fe48be57a2b3d7a1f0f5dbca07c1d00951d3d7463f0ac9dac66622", - "sha256:667880321e916a8920ef49f5d50e7983792cf59f3b6079f3c9dac2b88a311d17", - "sha256:684d840d2c9ec5de9cb397fcb3f36d5ebb6fa0d94734f9886032dd796c1ead06", - "sha256:68717c38a68e37af87c4da20e08f3e27d7e4212e99e96c3d875fbf3f4812abfc", - "sha256:6b7bbb97d82659ac8b37450c60ff2e9f97e4eb0f8a8a3645a5568b9334b08b50", - "sha256:72722ce529a76a4637a60be18bd789d8fb871e84472490ed7ddff62d5fed620d", - "sha256:73c1bc8a86a5c9e8721a088df234265317692d0b5cd9e86e975ce3bc3db62a59", - "sha256:76909849d1a6bffa5a07742294f3fa1d357dc917cb1fe7b470afbc3a7579d539", - "sha256:76b86e24039c35280ceee6dce7e62945eb93a5175d43689ba98360ab31eebc4a", - "sha256:7a5d83efc109ceddb99abd2c1316298ced2adb4570410defe766851a804fcd5b", - "sha256:80e0e57cc704a52fb1b48f16d5b2c8818da087dbee6f98d9bf19546930dc64b5", - "sha256:85233abb44bc18d16e72dc05bf13848a36f363f83757541f1a97db2f8d58cfd9", - "sha256:907a4d7720abfcb1c81619863efd47c8a85d26a257a2dbebdb87c3b847df0278", - "sha256:9376d83d686ec62e8b19c0ac3bf8d28d8a5981d0df290196fb6ef24d8a26f0d6", - "sha256:94b9769ba435b598b547c762184bcfc4783d0d4c7771b04a3b45775c3589ca44", - "sha256:9a29726f91c6cb390b3c2338f0df5cd3e216ad7a938762d11c994bb37552edb0", - "sha256:9b6431559676a1079eac0f52d6d0721fb8e3c5ba43c37bc537c8c83724031feb", - "sha256:9ece8a49696669d483d206b4474c367852c44815fca23ac4e48b72b339807f80", - "sha256:a139fe9f298dc097349fb4f28c8b81cc7a202dbfba66af0e14be5cfca4ef7ce5", - "sha256:a32204489259786a923e02990249c65b0f17235073149d0033efcebe80095570", - "sha256:a3982b0a32d0a88b3907e4b0dc36809fda477f0757c59a505d4e9b455f384b8b", - "sha256:aad17e462f42ddbef5984d70c40bfc4146c322a2da79715932cd8976317054de", - "sha256:b560b72ed4816aee52783c66854d96157fd8175631f01ef58e894cc57c84f0f6", - "sha256:b6b0e4912030c6f28bcb72b9ebe4989d6dc2eebcd2a9cdc35fefc38052dd4fe8", - "sha256:baf1c7b78cddb5af00971ad5294a4583188bda1495b13760d9f03c9483bb6203", - "sha256:c0295d52b012cbe0d3059b1dba99159c3be55e632aae1999ab74ae2bd86a33d7", - "sha256:c562b49c96906b4029b5685075fe1ebd3b5cc2601dfa0b9e16c2c09d6cbce048", - "sha256:c69567ddbac186e8c0aadc1f324a60a564cfe25e43ef2ce81bcc4b8c3abffbae", - "sha256:ca71d501629d1fa50ea7fa3b08ba884fe10cefc559f5c6c8dfe9036c16e8ae89", - "sha256:ca976884ce34070799e4dfc6fbd68cb1d181db1eefe4a3a94798ddfb34b8867f", - "sha256:d0491006a6ad20507aec2be72e7831a42efc93193d2402018007ff827dc62926", - "sha256:d074b07a10c391fc5bbdcb37b2f16f20fcd9e51e10d01652ab298c0d07908ee2", - "sha256:d2ce426ee691319d4767748c8e0895cfc56593d725594e415f274059bcf3cb76", - "sha256:d4284c621f06a72ce2cb55f74ea3150113d926a6eb78ab38340c08f770eb9b4d", - "sha256:d5e6b7155b8197b329dc787356cfd2684c9d6a6b1a197f6bbf45f5555a98d411", - "sha256:d816f44a51ba5175394bc6c7879ca0bd2be560b2c9e9f3411ef3a4cbe644c2e9", - "sha256:dd3f79e17b56741b5177bcc36307750d50ea0698df6aa82f69c7db32d968c1c2", - "sha256:dd63cec4e26e790b70544ae5cc48d11b515b09e05fdd5eff12e3195f54b8a586", - "sha256:de9d3e8717560eb05e28739d1b35e4eac2e458553a52a301e51352a7ffc86a35", - "sha256:df4249b579e75094f7e9bb4bd28231acf55e308bf686b952f43100a5a0be394c", - "sha256:e178e5b66a06ec5bf51668ec0d4ac8cfb2bdcb553b2c207d58148340efd00143", - "sha256:e60defc3c15defb70bb38dd605ff7e0fae5f6c9c7cbfe0ad7868582cb7e844a6", - "sha256:ee2794111c188548a4547eccc73a6a8527fe2af6cf25e1a4ebda2fd01cdd2e60", - "sha256:ee7ccc7fb7e921d767f853b47814c3048c7de536663e82fbc37f5eb0d532224b", - "sha256:ee9cf33e7fe14243f5ca6977658eb7d1042caaa66847daacbd2117adb258b226", - "sha256:f0f17814c505f07806e22b28856c59ac80cee7dd0fbb152aed273e116378f519", - "sha256:f3202a429fe825b699c57892d4371c74cc3456d8d71b7f35d6028c96dfecad31", - "sha256:f7054fdc556f5421f01e39cbb767d5ec5c1139ea98c3e5b350e02e62201740c7", - "sha256:fd1a9edb9dd9d79fbeac1ea1f9a8dd527a6113b18d2e9bcc0d541d308dae639b" + "sha256:0098300eebb1c837271d3d1a2cd2911e7c11b396eac9661655ee524a7f10587b", + "sha256:042473b6280246b1dbf530559246f6842b56119c2926d1e52b631bdc46075f2a", + "sha256:05b7133a6e6aeb8df37d6f413f7705a37ab4031597f64ab56384c94d98fa0e90", + "sha256:0680b1f1f11fda801397de52c36ce38ef1c1dc841a0927a94f226dea29c3ae3d", + "sha256:0d69b4c2f6bb3e130dba60d34c0845ba31b69babdd3f78f7c0c8fae5021a253e", + "sha256:1404c69d6a676245199767ba4f633cce5f4ad4181f9d0ccb0577e1f66cf4c46d", + "sha256:182245ff6b0039e82b6bb585ed55a64d7c81c560715d1bad0cbad6dfa07b4027", + "sha256:1a388a77e629b9ec814c1b1e6b3b595fe521d2cdc625fcca26fbc2d44c816804", + "sha256:1d90c3265ae107f91a4f279f4d6f6f1d4907ac76c6868b27dc7fb33688cfb347", + "sha256:20aca1e2298c56ececfd8ed159ae4dde2df0781988c97ef77d5c16ff4bd5b400", + "sha256:219da3f096d50a157f33645a1cf31c0ad1fe829a92181dd1311022f986e5fbe3", + "sha256:22057013c8c1e272eb8d0eebc796701167d8377441ec894a8fed1af64a0bf399", + "sha256:223ee893d77a310a0391dca6df00f70bbc2f36a71a895cecd9a0e762dc37b349", + "sha256:224c421235f6102e8737032483f43c1a8cfb1d2f45740c44166219599358c2cd", + "sha256:2334ce8c673ee93a1d6a65bd90327588387ba073c17e61bf19b4fd97d688d63c", + "sha256:269322dcc3d8bdb69f054681edff86276b2ff972447863cf34c8b860f5188e2e", + "sha256:2728b01246a3bba6de144f9e3115b532ee44bd6cf39795194fb75491824a1413", + "sha256:2b8ed04b3582771764538f7ee7001b02e1170223cf9b75dff0bc698fadb00cf3", + "sha256:2e29d20810dfc3043ee13ac7d9e25105799817683348823f305ab3f349b9386e", + "sha256:36789b70d613fbac0a25bb07ab3d9dba4d2e38af609c020cf4d888d165ee0bf3", + "sha256:390193c770399861d8df9670fb0d1874f330c79caaca4642332df7c682bf6b91", + "sha256:3a6515ebc6e69d85502b4951d89131ca4e036078ea35533bb76327f8424531ce", + "sha256:3f9a801e7c8f1ef8718da265bba008fa121243dfe37c1cea17840b0944dfd72c", + "sha256:43f0f463cf89ace478de71a318b1b4f05ebc456a9b9300d027b4b57c1a2064fb", + "sha256:4456f2dca97c425231d7315737d45239b2b51a50dc2b6f0c2bb181fce6207664", + "sha256:470b94480bb5ee929f5acba6995251ada5e059a5ef3e0dfc63cca287283ebfa6", + "sha256:4774f3184d2ef3e14e8693194f661dea5a4d6ca4e3dc8e39786d33a94865cefd", + "sha256:4b4356d3538c3649337df4074e81b85f0616b79731fe22dd11b99499b2ebbdf3", + "sha256:553ef617b6836fc7e4df130bb851e32fe357ce36336d897fd6646d6058d980af", + "sha256:6132dd3bd52838acddca05a72aafb6eab6536aa145e923bb50f45e78b7251043", + "sha256:6a46e22a707e7ad4484ac9ee9f290f9d501df45954184e23fc29408dfad61350", + "sha256:6e5c584d357c4e2baf0ff7baf44f4994be121e16a2c88918a5817331fc7599d7", + "sha256:75250dbc5290e3f1a0f4618db35e51a165186f9034eff158f3d490b3fed9f8a0", + "sha256:75f7e9488238e920ab6204399ded280dc4c307d034f3924cd7f90a38b1829563", + "sha256:78363590ef93d5d226ba21a90a03ea89a20738ee5b7da83d771d283fd8a56761", + "sha256:7ca4ae5a27ad7a4ee5170aebce1574b375de390bc01284f87b18d43a3984df72", + "sha256:800d60565aec896f25bc3cfa56d2277d52d5182af08162f7954f938c06dc4ee3", + "sha256:82d5d4d78e4448683cb467897fe24e2b74bb7b973a541ea1dcfec1d3cbce39fb", + "sha256:852e966fbd035a6468fc0a3496589b45e2208ec7ca95c26470a54daed82a0788", + "sha256:868649da93e5a3d5eacc2b5b3b9235c98ccdbfd443832f31e075f54419e1b96b", + "sha256:886eec03591b7cf058467a70a87733b35f44707bd86cf64a615584fd72488b7c", + "sha256:8b172601454f2d7701121bbec3425dd71efcb787a027edf49724c9cefc14c038", + "sha256:95b9d5e72481d3780ba3442eac863eae92ae43a5f3adb5b4d0a1de89d42bb250", + "sha256:98758d627ff397e752bc339272c14c98199c613f922d4a384ddc07526c86a2ec", + "sha256:997abc4df705d1295a42f95b4eec4950a37ad8ae46d913caeee117b6b198811c", + "sha256:9b5155ff768083cb1d62f3e143b49a8a3432e6789a3abee8acd005c3c7af1c74", + "sha256:9e08e867b306f525802df7cd16c44ff5ebbe747ff0ca6cf3fde7f36c05a59a81", + "sha256:9fdad8e35f278b2c3eb77cbdc5c0a49dada440657bf738d6905ce106dc1de439", + "sha256:a1874c6dd4113308bd0eb568418e6114b252afe44319ead2b4081e9b9521fe75", + "sha256:a8309f67285bdfe65c372ea3722b7a5642680f3dba538566340a9d36e920b5f0", + "sha256:ae0a8a797a5e56c053610fa7be147993fe50960fa43609ff2a9552b0e07013e8", + "sha256:b14d82cdb934e99dda6d9d60dc84a24379820176cc4a0d123f88df319ae9c150", + "sha256:b1bd7e47b1558ea872bd16c8502c414f9e90dcf12f1395129d7bb42a09a95438", + "sha256:b3ef08e20ec49e02d5c6717a91bb5af9b20f1805583cb0adfe9ba2c6b505b5ae", + "sha256:b89ed9eb7d616ef5714e5590e6cf7f23b02d0d539767d33561e3675d6f9e3857", + "sha256:c4fcf5cd9c4b655ad666ca332b9a081112cd7a58a8b5a6ca7a3104bc950f2038", + "sha256:c6fdc8627910eed0c01aed6a390a252fe3ea6d472ee70fdde56273f198938374", + "sha256:c9bd70772c720142be1020eac55f8143a34ec9f82d75a8e7a07852023e46617f", + "sha256:ca7b0c1f1c983e064caa85f3792dd2fe3526b3505378874afa84baf662e12241", + "sha256:cbca948f2d14b09d20268cda7b0367723d79063f26c4ffc523af9042cad95592", + "sha256:cc1cfd88a64e012b74e94cd00bbe0f9c6df57049c97f02bb07d39e9c852e19a4", + "sha256:ccdd111c03bfd3666bd2472b674c6899550e09e9f298954cfc896ab92b5b0e6d", + "sha256:cfeecd1ac6cc1fb2692c3d5110781c965aabd4ec5d32799773ca7b1456ac636b", + "sha256:d4d938ec0adf5167cb335acb25a4ee69a8107e4984f8fbd2e897021d9e4ca21b", + "sha256:d7d904828195733c183d20a54230c0df0eb46ec746ea1a666730787353e87182", + "sha256:d91cb5ea8b11607cc757675051f61b3d93f15eca3cefb3e6c704a5d6e8440f4e", + "sha256:d9319e499827271b09b4e411905b24a426b8fb69464dfa1696258f53a3334641", + "sha256:e0e8b1be28239fc64a88a8189d1df7fad8be8c1ae47fcc33e43d4be15f99cc70", + "sha256:e18609ceaa6eed63753037fc06ebb16041d17d28199ae5aba0052c51449650a9", + "sha256:e1b395e58b10b73b07b7cf740d728dd4ff9365ac46c18751bf8b3d8cca8f625a", + "sha256:e23ec367a948b6d812301afc1b13f8094ab7b2c280af66ef450efc357d2ae543", + "sha256:e25add29b8f3b233ae90ccef2d902d0ae0432eb0d45370fe315d1a5cf231004b", + "sha256:e6dac87ddb34aaec85f873d737e9d06a3555a1cc1a8e0c44b7f8d5daeb89d86f", + "sha256:ef26c9e94a8c04a1b2924149a9cb081836913818e55681722d7f29af88fe7b38", + "sha256:eff2de745698eb46eeb51193a9f41d67d834d50e424aef27df2fcdee1b153845", + "sha256:f0a21cbaa69900cbe1a2e7cad2aa74ac3cf21b10c3efb0fa0b80305274c0e8a2", + "sha256:f459a5ce8434614dfd39bbebf1041952ae01da6bed9855008cb33b875cb024c0", + "sha256:f93a8a2e3938ff656a7c1bc57193b1319960ac015b6e87d76c76bf14fe0244b4", + "sha256:fb2bd7be70c0fe4dfd32c951bc813d9fe6ebcbfdd15a07527796c8204bd36242" ], "markers": "python_version >= '3.8'", - "version": "==2.18.1" + "version": "==2.18.2" }, "pydantic-settings": { "hashes": [ @@ -1411,12 +1411,12 @@ }, "boto3": { "hashes": [ - "sha256:168894499578a9d69d6f7deb5811952bf4171c51b95749a9aef32cf67bc71f87", - "sha256:1bd4cef11b7c5f293cede50f3d33ca89fe3413c51f1864f40163c56a732dd6b3" + "sha256:2824e3dd18743ca50e5b10439d20e74647b1416e8a94509cb30beac92d27a18d", + "sha256:b2e5cb5b95efcc881e25a3bc872d7a24e75ff4e76f368138e4baf7b9d6ee3422" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.34.88" + "version": "==1.34.90" }, "boto3-mocking": { "hashes": [ @@ -1429,28 +1429,28 @@ }, "boto3-stubs": { "hashes": [ - "sha256:23ca9e0cd0d3e7702d6631a1e94a4208a26b39fa6b12c734427e68a7fa649477", - "sha256:8f472d1bf09743c3d33304ecc8830d70ebe3ca19ac9604ae8da9af55421b0fce" + "sha256:7361f162523168ddcfb3e0cc70e5208e78f95b9f1f2553032036a2b67ab33355", + "sha256:c82f3db8558e28f766361ba1eea7c77dff735f72fef2a0b9dffaa9c0d9ae76a3" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.34.88" + "version": "==1.34.90" }, "botocore": { "hashes": [ - "sha256:36f2e9e8dfa856e55dbbe703aea601f134db3fddc3615f1020a755b27fd26a5e", - "sha256:e87a660599ed3e14b2a770f4efc3df2f2f6d04f3c7bfd64ddbae186667864a7b" + "sha256:113cd4c0cb63e13163ccbc2bb13d551be314ba7f8ba5bfab1c51a19ca01aa133", + "sha256:d48f152498e2c60b43ce25b579d26642346a327b6fb2c632d57219e0a4f63392" ], "markers": "python_version >= '3.8'", - "version": "==1.34.88" + "version": "==1.34.90" }, "botocore-stubs": { "hashes": [ - "sha256:656e966ea152a4f2828892aa7a9673bc91799998f5a8efd8e8fe390f61c2f4f1", - "sha256:f55b03ae2e1706bd56299fd2975bb048f96aa49012a866e931a040a74f85c3cc" + "sha256:b2d7416b524bce7325aa5fe09bb5e0b6bc9531d4136f4407fa39b6bc58507f34", + "sha256:d9b66542cbb8fbe28eef3c22caf941ae22d36cc1ef55b93fc0b52239457cab57" ], "markers": "python_version >= '3.8' and python_version < '4.0'", - "version": "==1.34.88" + "version": "==1.34.89" }, "click": { "hashes": [ @@ -1627,11 +1627,11 @@ }, "platformdirs": { "hashes": [ - "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068", - "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768" + "sha256:031cd18d4ec63ec53e82dceaac0417d218a6863f7745dfcc9efe7793b7039bdf", + "sha256:17d5a1161b3fd67b390023cb2d3b026bbd40abde6fdb052dfbd3a29c3ba22ee1" ], "markers": "python_version >= '3.8'", - "version": "==4.2.0" + "version": "==4.2.1" }, "pycodestyle": { "hashes": [ diff --git a/src/djangooidc/tests/test_views.py b/src/djangooidc/tests/test_views.py index f10afcbaf..bdd61b346 100644 --- a/src/djangooidc/tests/test_views.py +++ b/src/djangooidc/tests/test_views.py @@ -4,8 +4,10 @@ from django.http import HttpResponse from django.test import Client, TestCase, RequestFactory from django.urls import reverse +from api.tests.common import less_console_noise_decorator from djangooidc.exceptions import StateMismatch, InternalError from ..views import login_callback +from registrar.models import User, Contact, VerifiedByStaff, DomainInvitation, TransitionDomain, Domain from .common import less_console_noise @@ -16,6 +18,14 @@ class ViewsTest(TestCase): self.client = Client() self.factory = RequestFactory() + def tearDown(self): + User.objects.all().delete() + Contact.objects.all().delete() + DomainInvitation.objects.all().delete() + VerifiedByStaff.objects.all().delete() + TransitionDomain.objects.all().delete() + Domain.objects.all().delete() + def say_hi(*args): return HttpResponse("Hi") @@ -229,6 +239,140 @@ class ViewsTest(TestCase): self.assertEqual(response.status_code, 302) self.assertEqual(response.url, "/") + @less_console_noise_decorator + def test_login_callback_sets_verification_type_regular(self, mock_client): + """ + Test that openid sets the verification type to regular on the returned user. + Regular, in this context, means that this user was "Verifed by Login.gov" + """ + # SETUP + session = self.client.session + session.save() + # MOCK + # mock that callback returns user_info; this is the expected behavior + mock_client.callback.side_effect = self.user_info + # patch that the request does not require step up auth + with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch( + "djangooidc.views._initialize_client" + ) as mock_init_client: + with patch("djangooidc.views._client_is_none", return_value=True): + # TEST + # test the login callback url + response = self.client.get(reverse("openid_login_callback")) + + # assert that _initialize_client was called + mock_init_client.assert_called_once() + + # Assert that we get a redirect + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "/") + + # Test the created user object + created_user = User.objects.get(email="test@example.com") + self.assertEqual(created_user.verification_type, User.VerificationTypeChoices.REGULAR) + + @less_console_noise_decorator + def test_login_callback_sets_verification_type_invited(self, mock_client): + """Test that openid sets the verification type to invited on the returned user + when they exist in the DomainInvitation table""" + # SETUP + session = self.client.session + session.save() + + domain, _ = Domain.objects.get_or_create(name="test123.gov") + invitation, _ = DomainInvitation.objects.get_or_create(email="test@example.com", domain=domain) + # MOCK + # mock that callback returns user_info; this is the expected behavior + mock_client.callback.side_effect = self.user_info + # patch that the request does not require step up auth + with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch( + "djangooidc.views._initialize_client" + ) as mock_init_client: + with patch("djangooidc.views._client_is_none", return_value=True): + # TEST + # test the login callback url + response = self.client.get(reverse("openid_login_callback")) + + # assert that _initialize_client was called + mock_init_client.assert_called_once() + + # Assert that we get a redirect + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "/") + + # Test the created user object + created_user = User.objects.get(email="test@example.com") + self.assertEqual(created_user.email, invitation.email) + self.assertEqual(created_user.verification_type, User.VerificationTypeChoices.INVITED) + + @less_console_noise_decorator + def test_login_callback_sets_verification_type_grandfathered(self, mock_client): + """Test that openid sets the verification type to grandfathered + on a user which exists in our TransitionDomain table""" + # SETUP + session = self.client.session + session.save() + # MOCK + # mock that callback returns user_info; this is the expected behavior + mock_client.callback.side_effect = self.user_info + + td, _ = TransitionDomain.objects.get_or_create(username="test@example.com", domain_name="test123.gov") + + # patch that the request does not require step up auth + with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch( + "djangooidc.views._initialize_client" + ) as mock_init_client: + with patch("djangooidc.views._client_is_none", return_value=True): + # TEST + # test the login callback url + response = self.client.get(reverse("openid_login_callback")) + + # assert that _initialize_client was called + mock_init_client.assert_called_once() + + # Assert that we get a redirect + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "/") + + # Test the created user object + created_user = User.objects.get(email="test@example.com") + self.assertEqual(created_user.email, td.username) + self.assertEqual(created_user.verification_type, User.VerificationTypeChoices.GRANDFATHERED) + + @less_console_noise_decorator + def test_login_callback_sets_verification_type_verified_by_staff(self, mock_client): + """Test that openid sets the verification type to verified_by_staff + on a user which exists in our VerifiedByStaff table""" + # SETUP + session = self.client.session + session.save() + # MOCK + # mock that callback returns user_info; this is the expected behavior + mock_client.callback.side_effect = self.user_info + + vip, _ = VerifiedByStaff.objects.get_or_create(email="test@example.com") + + # patch that the request does not require step up auth + with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch( + "djangooidc.views._initialize_client" + ) as mock_init_client: + with patch("djangooidc.views._client_is_none", return_value=True): + # TEST + # test the login callback url + response = self.client.get(reverse("openid_login_callback")) + + # assert that _initialize_client was called + mock_init_client.assert_called_once() + + # Assert that we get a redirect + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "/") + + # Test the created user object + created_user = User.objects.get(email="test@example.com") + self.assertEqual(created_user.email, vip.email) + self.assertEqual(created_user.verification_type, User.VerificationTypeChoices.VERIFIED_BY_STAFF) + def test_login_callback_no_step_up_auth(self, mock_client): """Walk through login_callback when _requires_step_up_auth returns False and assert that we have a redirect to /""" diff --git a/src/djangooidc/views.py b/src/djangooidc/views.py index ab81ccff1..815df4ecf 100644 --- a/src/djangooidc/views.py +++ b/src/djangooidc/views.py @@ -99,8 +99,22 @@ def login_callback(request): return CLIENT.create_authn_request(request.session) user = authenticate(request=request, **userinfo) if user: + + # Fixture users kind of exist in a superposition of verification types, + # because while the system "verified" them, if they login, + # we don't know how the user themselves was verified through login.gov until + # they actually try logging in. This edge-case only matters in non-production environments. + fixture_user = User.VerificationTypeChoices.FIXTURE_USER + is_fixture_user = user.verification_type and user.verification_type == fixture_user + + # Set the verification type if it doesn't already exist or if its a fixture user + if not user.verification_type or is_fixture_user: + user.set_user_verification_type() + user.save() + login(request, user) logger.info("Successfully logged in user %s" % user) + # Clear the flag if the exception is not caught request.session.pop("redirect_attempted", None) return redirect(request.session.get("next", "/")) diff --git a/src/docker-compose.yml b/src/docker-compose.yml index c69c21192..1a9064ac8 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -108,7 +108,7 @@ services: - pa11y owasp: - image: owasp/zap2docker-stable + image: ghcr.io/zaproxy/zaproxy:stable command: zap-baseline.py -t http://app:8080 -c zap.conf -I -r zap_report.html volumes: - .:/zap/wrk/ diff --git a/src/node.Dockerfile b/src/node.Dockerfile index b478a8a26..cf0b6acc6 100644 --- a/src/node.Dockerfile +++ b/src/node.Dockerfile @@ -1,5 +1,5 @@ FROM docker.io/cimg/node:current-browsers - +FROM node:21.7.3 WORKDIR /app # Install app dependencies @@ -7,4 +7,6 @@ WORKDIR /app # where available (npm@5+) COPY --chown=circleci:circleci package*.json ./ -RUN npm install + +RUN npm install -g npm@10.5.0 +RUN npm install \ No newline at end of file diff --git a/src/package-lock.json b/src/package-lock.json index dc1464ee8..9df99a739 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -15,6 +15,10 @@ }, "devDependencies": { "@uswds/compile": "^1.0.0-beta.3" + }, + "engines": { + "node": "21.7.3", + "npm": "10.5.0" } }, "node_modules/@gulp-sourcemaps/identity-map": { diff --git a/src/package.json b/src/package.json index 274e0e282..3afce297f 100644 --- a/src/package.json +++ b/src/package.json @@ -3,6 +3,11 @@ "version": "1.0.0", "description": "========================", "main": "index.js", + "engines": { + "node": "21.7.3", + "npm": "10.5.0" + }, + "engineStrict": true, "scripts": { "pa11y-ci": "pa11y-ci", "test": "echo \"Error: no test specified\" && exit 1" @@ -17,4 +22,4 @@ "devDependencies": { "@uswds/compile": "^1.0.0-beta.3" } -} +} \ No newline at end of file diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 7231c634e..744babb95 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -119,6 +119,34 @@ class MyUserAdminForm(UserChangeForm): "user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False), } + def __init__(self, *args, **kwargs): + """Custom init to modify the user form""" + super(MyUserAdminForm, self).__init__(*args, **kwargs) + self._override_base_help_texts() + + def _override_base_help_texts(self): + """ + Used to override pre-existing help texts in AbstractUser. + This is done to avoid modifying the base AbstractUser class. + """ + is_superuser = self.fields.get("is_superuser") + is_staff = self.fields.get("is_staff") + password = self.fields.get("password") + + if is_superuser is not None: + is_superuser.help_text = "For development purposes only; provides superuser access on the database level." + + if is_staff is not None: + is_staff.help_text = "Designates whether the user can log in to this admin site." + + if password is not None: + # Link is copied from the base implementation of UserChangeForm. + link = f"../../{self.instance.pk}/password/" + password.help_text = ( + "Raw passwords are not stored, so they will not display here. " + f'You can change the password using this form.' + ) + class DomainInformationAdminForm(forms.ModelForm): """This form utilizes the custom widget for its class's ManyToMany UIs.""" @@ -582,7 +610,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): fieldsets = ( ( None, - {"fields": ("username", "password", "status")}, + {"fields": ("username", "password", "status", "verification_type")}, ), ("Personal Info", {"fields": ("first_name", "last_name", "email")}), ( @@ -600,13 +628,20 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): ("Important dates", {"fields": ("last_login", "date_joined")}), ) + readonly_fields = ("verification_type",) + # Hide Username (uuid), Groups and Permissions # Q: Now that we're using Groups and Permissions, # do we expose those to analysts to view? analyst_fieldsets = ( ( None, - {"fields": ("password", "status")}, + { + "fields": ( + "status", + "verification_type", + ) + }, ), ("Personal Info", {"fields": ("first_name", "last_name", "email")}), ( @@ -632,7 +667,6 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): # NOT all fields are readonly for admin, otherwise we would have # set this at the permissions level. The exception is 'status' analyst_readonly_fields = [ - "password", "Personal Info", "first_name", "last_name", @@ -727,11 +761,14 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): return [] def get_readonly_fields(self, request, obj=None): + readonly_fields = list(self.readonly_fields) + if request.user.has_perm("registrar.full_access_permission"): - return () # No read-only fields for all access users - # Return restrictive Read-only fields for analysts and - # users who might not belong to groups - return self.analyst_readonly_fields + return readonly_fields + else: + # Return restrictive Read-only fields for analysts and + # users who might not belong to groups + return self.analyst_readonly_fields class HostIPInline(admin.StackedInline): @@ -1063,9 +1100,10 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): }, ), ( - "More details", + "Show details", { - "classes": ["collapse"], + "classes": ["collapse--dotgov"], + "description": "Extends type of organization", "fields": [ "federal_type", # "updated_federal_agency", @@ -1088,9 +1126,10 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): }, ), ( - "More details", + "Show details", { - "classes": ["collapse"], + "classes": ["collapse--dotgov"], + "description": "Extends organization name and mailing address", "fields": [ "address_line1", "address_line2", @@ -1299,7 +1338,17 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): }, ), (".gov domain", {"fields": ["requested_domain", "alternative_domains"]}), - ("Contacts", {"fields": ["authorizing_official", "other_contacts", "no_other_contacts_rationale"]}), + ( + "Contacts", + { + "fields": [ + "authorizing_official", + "other_contacts", + "no_other_contacts_rationale", + "cisa_representative_email", + ] + }, + ), ("Background info", {"fields": ["purpose", "anything_else", "current_websites"]}), ( "Type of organization", @@ -1312,9 +1361,10 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): }, ), ( - "More details", + "Show details", { - "classes": ["collapse"], + "classes": ["collapse--dotgov"], + "description": "Extends type of organization", "fields": [ "federal_type", # "updated_federal_agency", @@ -1337,9 +1387,10 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): }, ), ( - "More details", + "Show details", { - "classes": ["collapse"], + "classes": ["collapse--dotgov"], + "description": "Extends organization name and mailing address", "fields": [ "address_line1", "address_line2", @@ -1372,6 +1423,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): "no_other_contacts_rationale", "anything_else", "is_policy_acknowledged", + "cisa_representative_email", ] autocomplete_fields = [ "approved_domain", @@ -1827,21 +1879,27 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): if domain is not None and hasattr(domain, "domain_info"): extra_context["original_object"] = domain.domain_info + extra_context["state_help_message"] = Domain.State.get_admin_help_text(domain.state) + extra_context["domain_state"] = domain.get_state_display() + # Pass in what the an extended expiration date would be for the expiration date modal - years_to_extend_by = self._get_calculated_years_for_exp_date(domain) - try: - curr_exp_date = domain.registry_expiration_date - except KeyError: - # No expiration date was found. Return none. - extra_context["extended_expiration_date"] = None - return super().changeform_view(request, object_id, form_url, extra_context) - new_date = curr_exp_date + relativedelta(years=years_to_extend_by) - extra_context["extended_expiration_date"] = new_date - else: - extra_context["extended_expiration_date"] = None + self._set_expiration_date_context(domain, extra_context) return super().changeform_view(request, object_id, form_url, extra_context) + def _set_expiration_date_context(self, domain, extra_context): + """Given a domain, calculate the an extended expiration date + from the current registry expiration date.""" + years_to_extend_by = self._get_calculated_years_for_exp_date(domain) + try: + curr_exp_date = domain.registry_expiration_date + except KeyError: + # No expiration date was found. Return none. + extra_context["extended_expiration_date"] = None + else: + new_date = curr_exp_date + relativedelta(years=years_to_extend_by) + extra_context["extended_expiration_date"] = new_date + def response_change(self, request, obj): # Create dictionary of action functions ACTION_FUNCTIONS = { diff --git a/src/registrar/assets/js/dja-collapse.js b/src/registrar/assets/js/dja-collapse.js new file mode 100644 index 000000000..c33954192 --- /dev/null +++ b/src/registrar/assets/js/dja-collapse.js @@ -0,0 +1,45 @@ +/* + * We will run our own version of + * https://github.com/django/django/blob/195d885ca01b14e3ce9a1881c3b8f7074f953736/django/contrib/admin/static/admin/js/collapse.js + * Works with our fieldset override +*/ + +/*global gettext*/ +'use strict'; +{ + window.addEventListener('load', function() { + // Add anchor tag for Show/Hide link + const fieldsets = document.querySelectorAll('fieldset.collapse--dotgov'); + for (const [i, elem] of fieldsets.entries()) { + // Don't hide if fields in this fieldset have errors + if (elem.querySelectorAll('div.errors, ul.errorlist').length === 0) { + elem.classList.add('collapsed'); + const button = elem.querySelector('button'); + button.id = 'fieldsetcollapser' + i; + button.className = 'collapse-toggle--dotgov usa-button usa-button--unstyled'; + } + } + // Add toggle to hide/show anchor tag + const toggleFuncDotgov = function(e) { + e.preventDefault(); + e.stopPropagation(); + const fieldset = this.closest('fieldset'); + const spanElement = this.querySelector('span'); + const useElement = this.querySelector('use'); + if (fieldset.classList.contains('collapsed')) { + // Show + spanElement.textContent = 'Hide details'; + useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less'); + fieldset.classList.remove('collapsed'); + } else { + // Hide + spanElement.textContent = 'Show details'; + useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more'); + fieldset.classList.add('collapsed'); + } + }; + document.querySelectorAll('.collapse-toggle--dotgov').forEach(function(el) { + el.addEventListener('click', toggleFuncDotgov); + }); + }); +} diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 2909a48be..f38afd252 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -233,10 +233,8 @@ function openInNewTab(el, removeAttribute = false){ // Initialize custom filter_horizontal widgets; each widget has a "from" select list // and a "to" select list; initialization is based off of the presence of the // "to" select list - checkToListThenInitWidget('id_other_contacts_to', 0); - checkToListThenInitWidget('id_domain_info-0-other_contacts_to', 0); - checkToListThenInitWidget('id_current_websites_to', 0); - checkToListThenInitWidget('id_alternative_domains_to', 0); + checkToListThenInitWidget('id_groups_to', 0); + checkToListThenInitWidget('id_user_permissions_to', 0); })(); // Function to check for the existence of the "to" select list element in the DOM, and if and when found, @@ -245,219 +243,60 @@ function checkToListThenInitWidget(toListId, attempts) { let toList = document.getElementById(toListId); attempts++; - if (attempts < 6) { - if ((toList !== null)) { + if (attempts < 12) { + if (toList) { // toList found, handle it - // Add an event listener on the element - // Add disabled buttons on the element's great-grandparent - initializeWidgetOnToList(toList, toListId); + // Then get fromList and handle it + initializeWidgetOnList(toList, ".selector-chosen"); + let fromList = toList.closest('.selector').querySelector(".selector-available select"); + initializeWidgetOnList(fromList, ".selector-available"); } else { // Element not found, check again after a delay - setTimeout(() => checkToListThenInitWidget(toListId, attempts), 1000); // Check every 1000 milliseconds (1 second) + setTimeout(() => checkToListThenInitWidget(toListId, attempts), 300); // Check every 300 milliseconds } } } // Initialize the widget: -// add related buttons to the widget for edit, delete and view -// add event listeners on the from list, the to list, and selector buttons which either enable or disable the related buttons -function initializeWidgetOnToList(toList, toListId) { - // create the change button - let changeLink = createAndCustomizeLink( - toList, - toListId, - 'related-widget-wrapper-link change-related', - 'Change', - '/public/admin/img/icon-changelink.svg', - { - 'contacts': '/admin/registrar/contact/__fk__/change/?_to_field=id&_popup=1', - 'websites': '/admin/registrar/website/__fk__/change/?_to_field=id&_popup=1', - 'alternative_domains': '/admin/registrar/website/__fk__/change/?_to_field=id&_popup=1', - }, - true, - true - ); +// Replace h2 with more semantic h3 +function initializeWidgetOnList(list, parentId) { + if (list) { + // Get h2 and its container + const parentElement = list.closest(parentId); + const h2Element = parentElement.querySelector('h2'); - let hasDeletePermission = hasDeletePermissionOnPage(); + // One last check + if (parentElement && h2Element) { + // Create a new

element + const h3Element = document.createElement('h3'); - let deleteLink = null; - if (hasDeletePermission) { - // create the delete button if user has permission to delete - deleteLink = createAndCustomizeLink( - toList, - toListId, - 'related-widget-wrapper-link delete-related', - 'Delete', - '/public/admin/img/icon-deletelink.svg', - { - 'contacts': '/admin/registrar/contact/__fk__/delete/?_to_field=id&_popup=1', - 'websites': '/admin/registrar/website/__fk__/delete/?_to_field=id&_popup=1', - 'alternative_domains': '/admin/registrar/website/__fk__/delete/?_to_field=id&_popup=1', - }, - true, - false - ); - } + // Copy the text content from the

element to the

element + h3Element.textContent = h2Element.textContent; - // create the view button - let viewLink = createAndCustomizeLink( - toList, - toListId, - 'related-widget-wrapper-link view-related', - 'View', - '/public/admin/img/icon-viewlink.svg', - { - 'contacts': '/admin/registrar/contact/__fk__/change/?_to_field=id', - 'websites': '/admin/registrar/website/__fk__/change/?_to_field=id', - 'alternative_domains': '/admin/registrar/website/__fk__/change/?_to_field=id', - }, - // NOTE: If we open view in the same window then use the back button - // to go back, the 'chosen' list will fail to initialize correctly in - // sandbozes (but will work fine on local). This is related to how the - // Django JS runs (SelectBox.js) and is probably due to a race condition. - true, - false - ); + // Find the nested element inside the

+ const nestedSpan = h2Element.querySelector('span[class][title]'); - // identify the fromList element in the DOM - let fromList = toList.closest('.selector').querySelector(".selector-available select"); + // If the nested element exists + if (nestedSpan) { + // Create a new element + const newSpan = document.createElement('span'); - fromList.addEventListener('click', function(event) { - handleSelectClick(fromList, changeLink, deleteLink, viewLink); - }); - - toList.addEventListener('click', function(event) { - handleSelectClick(toList, changeLink, deleteLink, viewLink); - }); - - // Disable buttons when the selectors are interacted with (items are moved from one column to the other) - let selectorButtons = []; - selectorButtons.push(toList.closest(".selector").querySelector(".selector-chooseall")); - selectorButtons.push(toList.closest(".selector").querySelector(".selector-add")); - selectorButtons.push(toList.closest(".selector").querySelector(".selector-remove")); + // Copy the class and title attributes from the nested element + newSpan.className = nestedSpan.className; + newSpan.title = nestedSpan.title; - selectorButtons.forEach((selector) => { - selector.addEventListener("click", ()=>{disableRelatedWidgetButtons(changeLink, deleteLink, viewLink)}); - }); -} - -// create and customize the button, then add to the DOM, relative to the toList -// toList - the element in the DOM for the toList -// toListId - the ID of the element in the DOM -// className - className to add to the created link -// action - the action to perform on the item {change, delete, view} -// imgSrc - the img.src for the created link -// dataMappings - dictionary which relates toListId to href for the created link -// dataPopup - boolean for whether the link should produce a popup window -// firstPosition - boolean indicating if link should be first position in list of links, otherwise, should be last link -function createAndCustomizeLink(toList, toListId, className, action, imgSrc, dataMappings, dataPopup, firstPosition) { - // Create a link element - var link = document.createElement('a'); - - // Set class attribute for the link - link.className = className; - - // Set id - // Determine function {change, link, view} from the className - // Add {function}_ to the beginning of the string - let modifiedLinkString = className.split('-')[0] + '_' + toListId; - // Remove '_to' from the end of the string - modifiedLinkString = modifiedLinkString.replace('_to', ''); - link.id = modifiedLinkString; - - // Set data-href-template - for (const [idPattern, template] of Object.entries(dataMappings)) { - if (toListId.includes(idPattern)) { - link.setAttribute('data-href-template', template); - break; // Stop checking once a match is found - } - } - - if (dataPopup) - link.setAttribute('data-popup', 'yes'); - - link.setAttribute('title-template', action + " selected item") - link.title = link.getAttribute('title-template'); - - // Create an 'img' element - var img = document.createElement('img'); - - // Set attributes for the new image - img.src = imgSrc; - img.alt = action; - - // Append the image to the link - link.appendChild(img); - - let relatedWidgetWrapper = toList.closest('.related-widget-wrapper'); - // If firstPosition is true, insert link as the first child element - if (firstPosition) { - relatedWidgetWrapper.insertBefore(link, relatedWidgetWrapper.children[0]); - } else { - // otherwise, insert the link prior to the last child (which is a div) - // and also prior to any text elements immediately preceding the last - // child node - var lastChild = relatedWidgetWrapper.lastChild; - - // Check if lastChild is an element node (not a text node, comment, etc.) - if (lastChild.nodeType === 1) { - var previousSibling = lastChild.previousSibling; - // need to work around some white space which has been inserted into the dom - while (previousSibling.nodeType !== 1) { - previousSibling = previousSibling.previousSibling; + // Append the new element to the

element + h3Element.appendChild(newSpan); } - relatedWidgetWrapper.insertBefore(link, previousSibling.nextSibling); + + // Replace the

element with the new

element + parentElement.replaceChild(h3Element, h2Element); } } - - // Return the link, which we'll use in the disable and enable functions - return link; -} - -// Either enable or disable widget buttons when select is clicked. Action (enable or disable) taken depends on the count -// of selected items in selectElement. If exactly one item is selected, buttons are enabled, and urls for the buttons are -// associated with the selected item -function handleSelectClick(selectElement, changeLink, deleteLink, viewLink) { - - // If one item is selected (across selectElement and relatedSelectElement), enable buttons; otherwise, disable them - if (selectElement.selectedOptions.length === 1) { - // enable buttons for selected item in selectElement - enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, selectElement.selectedOptions[0].value, selectElement.selectedOptions[0].text); - } else { - disableRelatedWidgetButtons(changeLink, deleteLink, viewLink); - } -} - -// return true if there exist elements on the page with classname of delete-related. -// presence of one or more of these elements indicates user has permission to delete -function hasDeletePermissionOnPage() { - return document.querySelector('.delete-related') != null -} - -function disableRelatedWidgetButtons(changeLink, deleteLink, viewLink) { - changeLink.removeAttribute('href'); - changeLink.setAttribute('title', changeLink.getAttribute('title-template')); - if (deleteLink) { - deleteLink.removeAttribute('href'); - deleteLink.setAttribute('title', deleteLink.getAttribute('title-template')); - } - viewLink.removeAttribute('href'); - viewLink.setAttribute('title', viewLink.getAttribute('title-template')); -} - -function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk, elementText) { - changeLink.setAttribute('href', changeLink.getAttribute('data-href-template').replace('__fk__', elementPk)); - changeLink.setAttribute('title', changeLink.getAttribute('title-template').replace('selected item', elementText)); - if (deleteLink) { - deleteLink.setAttribute('href', deleteLink.getAttribute('data-href-template').replace('__fk__', elementPk)); - deleteLink.setAttribute('title', deleteLink.getAttribute('title-template').replace('selected item', elementText)); - } - viewLink.setAttribute('href', viewLink.getAttribute('data-href-template').replace('__fk__', elementPk)); - viewLink.setAttribute('title', viewLink.getAttribute('title-template').replace('selected item', elementText)); } /** An IIFE for admin in DjangoAdmin to listen to changes on the domain request - * status select amd to show/hide the rejection reason + * status select and to show/hide the rejection reason */ (function (){ let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason') diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index b4c41ecf1..e7260ee21 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -193,6 +193,65 @@ function clearValidators(el) { toggleInputValidity(el, true); } +/** Hookup listeners for yes/no togglers for form fields + * Parameters: + * - radioButtonName: The "name=" value for the radio buttons being used as togglers + * - elementIdToShowIfYes: The Id of the element (eg. a div) to show if selected value of the given + * radio button is true (hides this element if false) + * - elementIdToShowIfNo: The Id of the element (eg. a div) to show if selected value of the given + * radio button is false (hides this element if true) + * **/ +function HookupYesNoListener(radioButtonName, elementIdToShowIfYes, elementIdToShowIfNo) { + // Get the radio buttons + let radioButtons = document.querySelectorAll('input[name="'+radioButtonName+'"]'); + + function handleRadioButtonChange() { + // Check the value of the selected radio button + // Attempt to find the radio button element that is checked + let radioButtonChecked = document.querySelector('input[name="'+radioButtonName+'"]:checked'); + + // Check if the element exists before accessing its value + let selectedValue = radioButtonChecked ? radioButtonChecked.value : null; + + switch (selectedValue) { + case 'True': + toggleTwoDomElements(elementIdToShowIfYes, elementIdToShowIfNo, 1); + break; + + case 'False': + toggleTwoDomElements(elementIdToShowIfYes, elementIdToShowIfNo, 2); + break; + + default: + toggleTwoDomElements(elementIdToShowIfYes, elementIdToShowIfNo, 0); + } + } + + if (radioButtons.length) { + // Add event listener to each radio button + radioButtons.forEach(function (radioButton) { + radioButton.addEventListener('change', handleRadioButtonChange); + }); + + // initialize + handleRadioButtonChange(); + } +} + +// A generic display none/block toggle function that takes an integer param to indicate how the elements toggle +function toggleTwoDomElements(ele1, ele2, index) { + let element1 = document.getElementById(ele1); + let element2 = document.getElementById(ele2); + if (element1 || element2) { + // Toggle display based on the index + if (element1) {element1.style.display = index === 1 ? 'block' : 'none';} + if (element2) {element2.style.display = index === 2 ? 'block' : 'none';} + } + else { + console.error('Unable to find elements to toggle'); + } +} + // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> // Event handlers. @@ -712,58 +771,41 @@ function hideDeletedForms() { } })(); -// A generic display none/block toggle function that takes an integer param to indicate how the elements toggle -function toggleTwoDomElements(ele1, ele2, index) { - let element1 = document.getElementById(ele1); - let element2 = document.getElementById(ele2); - if (element1 && element2) { - // Toggle display based on the index - element1.style.display = index === 1 ? 'block' : 'none'; - element2.style.display = index === 2 ? 'block' : 'none'; - } else { - console.error('One or both elements not found.'); - } -} /** * An IIFE that listens to the other contacts radio form on DAs and toggles the contacts/no other contacts forms * */ (function otherContactsFormListener() { - // Get the radio buttons - let radioButtons = document.querySelectorAll('input[name="other_contacts-has_other_contacts"]'); + HookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees') +})(); - function handleRadioButtonChange() { - // Check the value of the selected radio button - // Attempt to find the radio button element that is checked - let radioButtonChecked = document.querySelector('input[name="other_contacts-has_other_contacts"]:checked'); - // Check if the element exists before accessing its value - let selectedValue = radioButtonChecked ? radioButtonChecked.value : null; +/** + * An IIFE that listens to the yes/no radio buttons on the anything else form and toggles form field visibility accordingly + * + */ +(function anythingElseFormListener() { + HookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null) +})(); - switch (selectedValue) { - case 'True': - toggleTwoDomElements('other-employees', 'no-other-employees', 1); - break; - - case 'False': - toggleTwoDomElements('other-employees', 'no-other-employees', 2); - break; - - default: - toggleTwoDomElements('other-employees', 'no-other-employees', 0); +/** + * An IIFE that disables the delete buttons on nameserver forms on page load if < 3 forms + * + */ +(function nameserversFormListener() { + let isNameserversForm = document.querySelector(".nameservers-form"); + if (isNameserversForm) { + let forms = document.querySelectorAll(".repeatable-form"); + if (forms.length < 3) { + // Hide the delete buttons on the 2 nameservers + forms.forEach((form) => { + Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => { + deleteButton.setAttribute("disabled", "true"); + }); + }); } } - - if (radioButtons.length) { - // Add event listener to each radio button - radioButtons.forEach(function (radioButton) { - radioButton.addEventListener('change', handleRadioButtonChange); - }); - - // initialize - handleRadioButtonChange(); - } })(); /** @@ -784,3 +826,11 @@ function toggleTwoDomElements(ele1, ele2, index) { } } })(); + +/** + * An IIFE that listens to the yes/no radio buttons on the CISA representatives form and toggles form field visibility accordingly + * + */ +(function cisaRepresentativesFormListener() { + HookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null) +})(); diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index f5717d067..b4b590acb 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -112,12 +112,20 @@ html[data-theme="light"] { .change-list .usa-table--borderless thead th, .change-list .usa-table thead td, .change-list .usa-table thead th, + .change-form .usa-table, + .change-form .usa-table--striped tbody tr:nth-child(odd) td, + .change-form .usa-table--borderless thead th, + .change-form .usa-table thead td, + .change-form .usa-table thead th, body.dashboard, body.change-list, body.change-form, .analytics { color: var(--body-fg); } + .usa-table td { + background-color: transparent; + } } // Firefox needs this to be specifically set @@ -127,11 +135,20 @@ html[data-theme="dark"] { .change-list .usa-table--borderless thead th, .change-list .usa-table thead td, .change-list .usa-table thead th, + .change-form .usa-table, + .change-form .usa-table--striped tbody tr:nth-child(odd) td, + .change-form .usa-table--borderless thead th, + .change-form .usa-table thead td, + .change-form .usa-table thead th, body.dashboard, body.change-list, - body.change-form { + body.change-form, + .analytics { color: var(--body-fg); } + .usa-table td { + background-color: transparent; + } } #branding h1 a:link, #branding h1 a:visited { @@ -525,17 +542,30 @@ address.dja-address-contact-list { } // Collapse button styles for fieldsets -.module.collapse { +.module.collapse--dotgov { margin-top: -35px; padding-top: 0; border: none; - h2 { + button { background: none; - color: var(--body-fg)!important; text-transform: none; - } - a { color: var(--link-fg); + margin-top: 8px; + margin-left: 10px; + span { + text-decoration: underline; + font-size: 13px; + font-feature-settings: "kern"; + font-kerning: normal; + line-height: 13px; + font-family: -apple-system, "system-ui", "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + } + } +} +.collapse--dotgov.collapsed .collapse-toggle--dotgov { + display: inline-block!important; + * { + display: inline-block; } } @@ -617,3 +647,32 @@ address.dja-address-contact-list { .usa-button__small-text { font-size: small; } + +// Get rid of padding on all help texts +form .aligned p.help, form .aligned div.help { + padding-left: 0px !important; +} + +// We override the DJA header on multi list selects from h2 to h3 +// The following block of code styles our generated h3s to match the old h2s +.selector .selector-available h3 { + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +.selector-available h3, .selector-chosen h3 { + border: 1px solid var(--border-color); + border-radius: 4px 4px 0 0; + margin: 0; + padding: 8px; + font-size: 0.8125rem; + text-align: left; + margin: 0; + padding: 8px; + line-height: 1.3; +} + +.selector .selector-chosen h3 { + background: var(--primary); + color: var(--header-link-color); +} diff --git a/src/registrar/assets/sass/_theme/_links.scss b/src/registrar/assets/sass/_theme/_links.scss new file mode 100644 index 000000000..6d2e75a68 --- /dev/null +++ b/src/registrar/assets/sass/_theme/_links.scss @@ -0,0 +1,21 @@ +@use "uswds-core" as *; + +.dotgov-table { + a { + display: flex; + align-items: flex-start; + color: color('primary'); + + &:visited { + color: color('primary'); + } + } +} + +a { + .usa-icon { + // align icon with x height + margin-top: units(0.5); + margin-right: units(0.5); + } +} diff --git a/src/registrar/assets/sass/_theme/_tables.scss b/src/registrar/assets/sass/_theme/_tables.scss index 0d58b5878..26d90d291 100644 --- a/src/registrar/assets/sass/_theme/_tables.scss +++ b/src/registrar/assets/sass/_theme/_tables.scss @@ -56,22 +56,6 @@ .dotgov-table { width: 100%; - a { - display: flex; - align-items: flex-start; - color: color('primary'); - - &:visited { - color: color('primary'); - } - - .usa-icon { - // align icon with x height - margin-top: units(0.5); - margin-right: units(0.5); - } - } - th[data-sortable]:not([aria-sort]) .usa-table__header__button { right: auto; } @@ -108,12 +92,51 @@ padding: units(2) units(2) units(2) 0; } - th:first-of-type { - padding-left: 0; - } - thead tr:first-child th:first-child { border-top: none; } } } +@media (min-width: 1040px){ + .dotgov-table__domain-requests { + th:nth-of-type(1) { + width: 200px; + } + + th:nth-of-type(2) { + width: 158px; + } + + th:nth-of-type(3) { + width: 120px; + } + + th:nth-of-type(4) { + width: 95px; + } + + th:nth-of-type(5) { + width: 85px; + } + } +} + +@media (min-width: 1040px){ + .dotgov-table__registered-domains { + th:nth-of-type(1) { + width: 200px; + } + + th:nth-of-type(2) { + width: 158px; + } + + th:nth-of-type(3) { + width: 215px; + } + + th:nth-of-type(4) { + width: 95px; + } + } +} \ No newline at end of file diff --git a/src/registrar/assets/sass/_theme/styles.scss b/src/registrar/assets/sass/_theme/styles.scss index 942501110..64b113a29 100644 --- a/src/registrar/assets/sass/_theme/styles.scss +++ b/src/registrar/assets/sass/_theme/styles.scss @@ -10,6 +10,7 @@ --- Custom Styles ---------------------------------*/ @forward "base"; @forward "typography"; +@forward "links"; @forward "lists"; @forward "buttons"; @forward "forms"; diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 62e676839..062e2492e 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -782,3 +782,11 @@ if DEBUG: # due to Docker, bypass Debug Toolbar's check on INTERNAL_IPS "SHOW_TOOLBAR_CALLBACK": lambda _: True, } + +# From https://django-auditlog.readthedocs.io/en/latest/upgrade.html +# Run: +# cf run-task getgov-<> --wait --command 'python manage.py auditlogmigratejson --traceback' --name auditlogmigratejson +# on our staging and stable, then remove these 2 variables or set to False +AUDITLOG_TWO_STEP_MIGRATION = True + +AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT = True diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 3918fa087..720034150 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -46,7 +46,7 @@ for step, view in [ (Step.PURPOSE, views.Purpose), (Step.YOUR_CONTACT, views.YourContact), (Step.OTHER_CONTACTS, views.OtherContacts), - (Step.ANYTHING_ELSE, views.AnythingElse), + (Step.ADDITIONAL_DETAILS, views.AdditionalDetails), (Step.REQUIREMENTS, views.Requirements), (Step.REVIEW, views.Review), ]: diff --git a/src/registrar/fixtures_users.py b/src/registrar/fixtures_users.py index 99fe4910e..7f991fa0e 100644 --- a/src/registrar/fixtures_users.py +++ b/src/registrar/fixtures_users.py @@ -7,6 +7,7 @@ from registrar.models import ( UserGroup, ) + fake = Faker() logger = logging.getLogger(__name__) @@ -93,6 +94,12 @@ class UserFixture: "last_name": "Chin", "email": "szu.chin@associates.cisa.dhs.gov", }, + { + "username": "66bb1a5a-a091-4d7f-a6cf-4d772b4711c7", + "first_name": "Christina", + "last_name": "Burnett", + "email": "christina.burnett@cisa.dhs.gov", + }, { "username": "012f844d-8a0f-4225-9d82-cbf87bff1d3e", "first_name": "Riley", @@ -169,6 +176,12 @@ class UserFixture: "last_name": "Chin-Analyst", "email": "szu.chin@ecstech.com", }, + { + "username": "22f88aa5-3b54-4b1f-9c57-201fb02ddba7", + "first_name": "Christina-Analyst", + "last_name": "Burnett-Analyst", + "email": "christina.burnett@gwe.cisa.dhs.gov", + }, { "username": "d9839768-0c17-4fa2-9c8e-36291eef5c11", "first_name": "Alex-Analyst", @@ -195,6 +208,10 @@ class UserFixture: user.email = user_data["email"] user.is_staff = True user.is_active = True + # This verification type will get reverted to "regular" (or whichever is applicables) + # once the user logs in for the first time (as they then got verified through different means). + # In the meantime, we can still describe how the user got here in the first place. + user.verification_type = User.VerificationTypeChoices.FIXTURE_USER group = UserGroup.objects.get(name=group_name) user.groups.add(group) user.save() diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index c3ac3b4c2..8d74f6f35 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -1,15 +1,18 @@ from __future__ import annotations # allows forward references in annotations -from itertools import zip_longest import logging -from typing import Callable from api.views import DOMAIN_API_MESSAGES from phonenumber_field.formfields import PhoneNumberField # type: ignore from django import forms from django.core.validators import RegexValidator, MaxLengthValidator from django.utils.safestring import mark_safe -from django.db.models.fields.related import ForeignObjectRel +from registrar.forms.utility.wizard_form_helper import ( + RegistrarForm, + RegistrarFormSet, + BaseYesNoForm, + BaseDeletableRegistrarForm, +) from registrar.models import Contact, DomainRequest, DraftDomain, Domain from registrar.templatetags.url_helpers import public_site_url from registrar.utility.enums import ValidationReturnType @@ -17,157 +20,6 @@ from registrar.utility.enums import ValidationReturnType logger = logging.getLogger(__name__) -class RegistrarForm(forms.Form): - """ - A common set of methods and configuration. - - The registrar's domain request is several pages of "steps". - Each step is an HTML form containing one or more Django "forms". - - Subclass this class to create new forms. - """ - - def __init__(self, *args, **kwargs): - kwargs.setdefault("label_suffix", "") - # save a reference to a domain request object - self.domain_request = kwargs.pop("domain_request", None) - super(RegistrarForm, self).__init__(*args, **kwargs) - - def to_database(self, obj: DomainRequest | Contact): - """ - Adds this form's cleaned data to `obj` and saves `obj`. - - Does nothing if form is not valid. - """ - if not self.is_valid(): - return - for name, value in self.cleaned_data.items(): - setattr(obj, name, value) - obj.save() - - @classmethod - def from_database(cls, obj: DomainRequest | Contact | None): - """Returns a dict of form field values gotten from `obj`.""" - if obj is None: - return {} - return {name: getattr(obj, name) for name in cls.declared_fields.keys()} # type: ignore - - -class RegistrarFormSet(forms.BaseFormSet): - """ - As with RegistrarForm, a common set of methods and configuration. - - Subclass this class to create new formsets. - """ - - def __init__(self, *args, **kwargs): - # save a reference to an domain_request object - self.domain_request = kwargs.pop("domain_request", None) - super(RegistrarFormSet, self).__init__(*args, **kwargs) - # quick workaround to ensure that the HTML `required` - # attribute shows up on required fields for any forms - # in the formset which have data already (stated another - # way: you can leave a form in the formset blank, but - # if you opt to fill it out, you must fill it out _right_) - for index in range(self.initial_form_count()): - self.forms[index].use_required_attribute = True - - def should_delete(self, cleaned): - """Should this entry be deleted from the database?""" - raise NotImplementedError - - def pre_update(self, db_obj, cleaned): - """Code to run before an item in the formset is saved.""" - for key, value in cleaned.items(): - setattr(db_obj, key, value) - - def pre_create(self, db_obj, cleaned): - """Code to run before an item in the formset is created in the database.""" - return cleaned - - def to_database(self, obj: DomainRequest): - """ - Adds this form's cleaned data to `obj` and saves `obj`. - - Does nothing if form is not valid. - - Hint: Subclass should call `self._to_database(...)`. - """ - raise NotImplementedError - - def _to_database( - self, - obj: DomainRequest, - join: str, - should_delete: Callable, - pre_update: Callable, - pre_create: Callable, - ): - """ - Performs the actual work of saving. - - Has hooks such as `should_delete` and `pre_update` by which the - subclass can control behavior. Add more hooks whenever needed. - """ - if not self.is_valid(): - return - obj.save() - - query = getattr(obj, join).order_by("created_at").all() # order matters - - # get the related name for the join defined for the db_obj for this form. - # the related name will be the reference on a related object back to db_obj - related_name = "" - field = obj._meta.get_field(join) - if isinstance(field, ForeignObjectRel) and callable(field.related_query_name): - related_name = field.related_query_name() - elif hasattr(field, "related_query_name") and callable(field.related_query_name): - related_name = field.related_query_name() - - # the use of `zip` pairs the forms in the formset with the - # related objects gotten from the database -- there should always be - # at least as many forms as database entries: extra forms means new - # entries, but fewer forms is _not_ the correct way to delete items - # (likely a client-side error or an attempt at data tampering) - for db_obj, post_data in zip_longest(query, self.forms, fillvalue=None): - cleaned = post_data.cleaned_data if post_data is not None else {} - - # matching database object exists, update it - if db_obj is not None and cleaned: - if should_delete(cleaned): - if hasattr(db_obj, "has_more_than_one_join") and db_obj.has_more_than_one_join(related_name): - # Remove the specific relationship without deleting the object - getattr(db_obj, related_name).remove(self.domain_request) - else: - # If there are no other relationships, delete the object - db_obj.delete() - else: - if hasattr(db_obj, "has_more_than_one_join") and db_obj.has_more_than_one_join(related_name): - # create a new db_obj and disconnect existing one - getattr(db_obj, related_name).remove(self.domain_request) - kwargs = pre_create(db_obj, cleaned) - getattr(obj, join).create(**kwargs) - else: - pre_update(db_obj, cleaned) - db_obj.save() - - # no matching database object, create it - # make sure not to create a database object if cleaned has 'delete' attribute - elif db_obj is None and cleaned and not cleaned.get("DELETE", False): - kwargs = pre_create(db_obj, cleaned) - getattr(obj, join).create(**kwargs) - - @classmethod - def on_fetch(cls, query): - """Code to run when fetching formset's objects from the database.""" - return query.values() - - @classmethod - def from_database(cls, obj: DomainRequest, join: str, on_fetch: Callable): - """Returns a dict of form field values gotten from `obj`.""" - return on_fetch(getattr(obj, join).order_by("created_at")) # order matters - - class OrganizationTypeForm(RegistrarForm): generic_org_type = forms.ChoiceField( # use the long names in the domain request form @@ -588,28 +440,24 @@ class YourContactForm(RegistrarForm): ) -class OtherContactsYesNoForm(RegistrarForm): - def __init__(self, *args, **kwargs): - """Extend the initialization of the form from RegistrarForm __init__""" - super().__init__(*args, **kwargs) - # set the initial value based on attributes of domain request - if self.domain_request and self.domain_request.has_other_contacts(): - initial_value = True - elif self.domain_request and self.domain_request.has_rationale(): - initial_value = False +class OtherContactsYesNoForm(BaseYesNoForm): + """The yes/no field for the OtherContacts form.""" + + form_choices = ((True, "Yes, I can name other employees."), (False, "No. (We’ll ask you to explain why.)")) + field_name = "has_other_contacts" + + @property + def form_is_checked(self): + """ + Determines the initial checked state of the form based on the domain_request's attributes. + """ + if self.domain_request.has_other_contacts(): + return True + elif self.domain_request.has_rationale(): + return False else: # No pre-selection for new domain requests - initial_value = None - - self.fields["has_other_contacts"] = forms.TypedChoiceField( - coerce=lambda x: x.lower() == "true" if x is not None else None, # coerce strings to bool, excepting None - choices=((True, "Yes, I can name other employees."), (False, "No. (We’ll ask you to explain why.)")), - initial=initial_value, - widget=forms.RadioSelect, - error_messages={ - "required": "This question is required.", - }, - ) + return None class OtherContactsForm(RegistrarForm): @@ -779,7 +627,7 @@ OtherContactsFormSet = forms.formset_factory( ) -class NoOtherContactsForm(RegistrarForm): +class NoOtherContactsForm(BaseDeletableRegistrarForm): no_other_contacts_rationale = forms.CharField( required=True, # label has to end in a space to get the label_suffix to show @@ -794,59 +642,35 @@ class NoOtherContactsForm(RegistrarForm): error_messages={"required": ("Rationale for no other employees is required.")}, ) - def __init__(self, *args, **kwargs): - self.form_data_marked_for_deletion = False - super().__init__(*args, **kwargs) - def mark_form_for_deletion(self): - """Marks no_other_contacts form for deletion. - This changes behavior of validity checks and to_database - methods.""" - self.form_data_marked_for_deletion = True - - def clean(self): - """ - This method overrides the default behavior for forms. - This cleans the form after field validation has already taken place. - In this override, remove errors associated with the form if form data - is marked for deletion. - """ - - if self.form_data_marked_for_deletion: - # clear any errors raised by the form fields - # (before this clean() method is run, each field - # performs its own clean, which could result in - # errors that we wish to ignore at this point) - # - # NOTE: we cannot just clear() the errors list. - # That causes problems. - for field in self.fields: - if field in self.errors: - del self.errors[field] - - return self.cleaned_data - - def to_database(self, obj): - """ - This method overrides the behavior of RegistrarForm. - If form data is marked for deletion, set relevant fields - to None before saving. - Do nothing if form is not valid. - """ - if not self.is_valid(): - return - if self.form_data_marked_for_deletion: - for field_name, _ in self.fields.items(): - setattr(obj, field_name, None) - else: - for name, value in self.cleaned_data.items(): - setattr(obj, name, value) - obj.save() +class CisaRepresentativeForm(BaseDeletableRegistrarForm): + cisa_representative_email = forms.EmailField( + required=True, + max_length=None, + label="Your representative’s email", + validators=[ + MaxLengthValidator( + 320, + message="Response must be less than 320 characters.", + ) + ], + error_messages={ + "invalid": ("Enter your email address in the required format, like name@example.com."), + "required": ("Enter the email address of your CISA regional representative."), + }, + ) -class AnythingElseForm(RegistrarForm): +class CisaRepresentativeYesNoForm(BaseYesNoForm): + """Yes/no toggle for the CISA regions question on additional details""" + + form_is_checked = property(lambda self: self.domain_request.has_cisa_representative) # type: ignore + field_name = "has_cisa_representative" + + +class AdditionalDetailsForm(BaseDeletableRegistrarForm): anything_else = forms.CharField( - required=False, + required=True, label="Anything else?", widget=forms.Textarea(), validators=[ @@ -855,9 +679,22 @@ class AnythingElseForm(RegistrarForm): message="Response must be less than 2000 characters.", ) ], + error_messages={ + "required": ( + "Provide additional details you’d like us to know. " "If you have nothing to add, select “No.”" + ) + }, ) +class AdditionalDetailsYesNoForm(BaseYesNoForm): + """Yes/no toggle for the anything else question on additional details""" + + # Note that these can be set as functions/init if you need more fine-grained control. + form_is_checked = property(lambda self: self.domain_request.has_anything_else_text) # type: ignore + field_name = "has_anything_else_text" + + class RequirementsForm(RegistrarForm): is_policy_acknowledged = forms.BooleanField( label="I read and agree to the requirements for operating a .gov domain.", diff --git a/src/registrar/forms/utility/wizard_form_helper.py b/src/registrar/forms/utility/wizard_form_helper.py new file mode 100644 index 000000000..2ae50f908 --- /dev/null +++ b/src/registrar/forms/utility/wizard_form_helper.py @@ -0,0 +1,280 @@ +"""Containers helpers and base classes for the domain_request_wizard.py file""" + +from itertools import zip_longest +from typing import Callable +from django.db.models.fields.related import ForeignObjectRel +from django import forms + +from registrar.models import DomainRequest, Contact + + +class RegistrarForm(forms.Form): + """ + A common set of methods and configuration. + + The registrar's domain request is several pages of "steps". + Each step is an HTML form containing one or more Django "forms". + + Subclass this class to create new forms. + """ + + def __init__(self, *args, **kwargs): + kwargs.setdefault("label_suffix", "") + # save a reference to a domain request object + self.domain_request = kwargs.pop("domain_request", None) + super(RegistrarForm, self).__init__(*args, **kwargs) + + def to_database(self, obj: DomainRequest | Contact): + """ + Adds this form's cleaned data to `obj` and saves `obj`. + + Does nothing if form is not valid. + """ + if not self.is_valid(): + return + for name, value in self.cleaned_data.items(): + setattr(obj, name, value) + obj.save() + + @classmethod + def from_database(cls, obj: DomainRequest | Contact | None): + """Returns a dict of form field values gotten from `obj`.""" + if obj is None: + return {} + return {name: getattr(obj, name) for name in cls.declared_fields.keys()} # type: ignore + + +class RegistrarFormSet(forms.BaseFormSet): + """ + As with RegistrarForm, a common set of methods and configuration. + + Subclass this class to create new formsets. + """ + + def __init__(self, *args, **kwargs): + # save a reference to an domain_request object + self.domain_request = kwargs.pop("domain_request", None) + super(RegistrarFormSet, self).__init__(*args, **kwargs) + # quick workaround to ensure that the HTML `required` + # attribute shows up on required fields for any forms + # in the formset which have data already (stated another + # way: you can leave a form in the formset blank, but + # if you opt to fill it out, you must fill it out _right_) + for index in range(self.initial_form_count()): + self.forms[index].use_required_attribute = True + + def should_delete(self, cleaned): + """Should this entry be deleted from the database?""" + raise NotImplementedError + + def pre_update(self, db_obj, cleaned): + """Code to run before an item in the formset is saved.""" + for key, value in cleaned.items(): + setattr(db_obj, key, value) + + def pre_create(self, db_obj, cleaned): + """Code to run before an item in the formset is created in the database.""" + return cleaned + + def to_database(self, obj: DomainRequest): + """ + Adds this form's cleaned data to `obj` and saves `obj`. + + Does nothing if form is not valid. + + Hint: Subclass should call `self._to_database(...)`. + """ + raise NotImplementedError + + def _to_database( + self, + obj: DomainRequest, + join: str, + should_delete: Callable, + pre_update: Callable, + pre_create: Callable, + ): + """ + Performs the actual work of saving. + + Has hooks such as `should_delete` and `pre_update` by which the + subclass can control behavior. Add more hooks whenever needed. + """ + if not self.is_valid(): + return + obj.save() + + query = getattr(obj, join).order_by("created_at").all() # order matters + + # get the related name for the join defined for the db_obj for this form. + # the related name will be the reference on a related object back to db_obj + related_name = "" + field = obj._meta.get_field(join) + if isinstance(field, ForeignObjectRel) and callable(field.related_query_name): + related_name = field.related_query_name() + elif hasattr(field, "related_query_name") and callable(field.related_query_name): + related_name = field.related_query_name() + + # the use of `zip` pairs the forms in the formset with the + # related objects gotten from the database -- there should always be + # at least as many forms as database entries: extra forms means new + # entries, but fewer forms is _not_ the correct way to delete items + # (likely a client-side error or an attempt at data tampering) + for db_obj, post_data in zip_longest(query, self.forms, fillvalue=None): + cleaned = post_data.cleaned_data if post_data is not None else {} + + # matching database object exists, update it + if db_obj is not None and cleaned: + if should_delete(cleaned): + if hasattr(db_obj, "has_more_than_one_join") and db_obj.has_more_than_one_join(related_name): + # Remove the specific relationship without deleting the object + getattr(db_obj, related_name).remove(self.domain_request) + else: + # If there are no other relationships, delete the object + db_obj.delete() + else: + if hasattr(db_obj, "has_more_than_one_join") and db_obj.has_more_than_one_join(related_name): + # create a new db_obj and disconnect existing one + getattr(db_obj, related_name).remove(self.domain_request) + kwargs = pre_create(db_obj, cleaned) + getattr(obj, join).create(**kwargs) + else: + pre_update(db_obj, cleaned) + db_obj.save() + + # no matching database object, create it + # make sure not to create a database object if cleaned has 'delete' attribute + elif db_obj is None and cleaned and not cleaned.get("DELETE", False): + kwargs = pre_create(db_obj, cleaned) + getattr(obj, join).create(**kwargs) + + @classmethod + def on_fetch(cls, query): + """Code to run when fetching formset's objects from the database.""" + return query.values() + + @classmethod + def from_database(cls, obj: DomainRequest, join: str, on_fetch: Callable): + """Returns a dict of form field values gotten from `obj`.""" + return on_fetch(getattr(obj, join).order_by("created_at")) # order matters + + +class BaseDeletableRegistrarForm(RegistrarForm): + """Adds special validation and delete functionality. + Used by forms that are tied to a Yes/No form.""" + + def __init__(self, *args, **kwargs): + self.form_data_marked_for_deletion = False + super().__init__(*args, **kwargs) + + def mark_form_for_deletion(self): + """Marks this form for deletion. + This changes behavior of validity checks and to_database + methods.""" + self.form_data_marked_for_deletion = True + + def clean(self): + """ + This method overrides the default behavior for forms. + This cleans the form after field validation has already taken place. + In this override, remove errors associated with the form if form data + is marked for deletion. + """ + + if self.form_data_marked_for_deletion: + # clear any errors raised by the form fields + # (before this clean() method is run, each field + # performs its own clean, which could result in + # errors that we wish to ignore at this point) + # + # NOTE: we cannot just clear() the errors list. + # That causes problems. + for field in self.fields: + if field in self.errors: + del self.errors[field] + + return self.cleaned_data + + def to_database(self, obj): + """ + This method overrides the behavior of RegistrarForm. + If form data is marked for deletion, set relevant fields + to None before saving. + Do nothing if form is not valid. + """ + if not self.is_valid(): + return + if self.form_data_marked_for_deletion: + for field_name, _ in self.fields.items(): + setattr(obj, field_name, None) + else: + for name, value in self.cleaned_data.items(): + setattr(obj, name, value) + obj.save() + + +class BaseYesNoForm(RegistrarForm): + """ + Base class used for forms with a yes/no form with a hidden input on toggle. + Use this class when you need something similar to the AdditionalDetailsYesNoForm. + + Attributes: + form_is_checked (bool): Determines the default state (checked or not) of the Yes/No toggle. + field_name (str): Specifies the form field name that the Yes/No toggle controls. + required_error_message (str): Custom error message displayed when the field is required but not provided. + form_choices (tuple): Defines the choice options for the form field, defaulting to Yes/No choices. + + Usage: + Subclass this form to implement specific Yes/No fields in various parts of the application, customizing + `form_is_checked` and `field_name` as necessary for the context. + """ + + form_is_checked: bool + + # What field does the yes/no button hook to? + # For instance, this could be "has_other_contacts" + field_name: str + + required_error_message = "This question is required." + + # Default form choice mapping. Default is suitable for most cases. + form_choices = ((True, "Yes"), (False, "No")) + + def __init__(self, *args, **kwargs): + """Extend the initialization of the form from RegistrarForm __init__""" + super().__init__(*args, **kwargs) + + self.fields[self.field_name] = self.get_typed_choice_field() + + def get_typed_choice_field(self): + """ + Creates a TypedChoiceField for the form with specified initial value and choices. + Returns: + TypedChoiceField: A Django form field specifically configured for selecting between + predefined choices with type coercion and custom error messages. + """ + choice_field = forms.TypedChoiceField( + coerce=lambda x: x.lower() == "true" if x is not None else None, + choices=self.form_choices, + initial=self.get_initial_value(), + widget=forms.RadioSelect, + error_messages={ + "required": self.required_error_message, + }, + ) + + return choice_field + + def get_initial_value(self): + """ + Determines the initial value for TypedChoiceField. + More directly, this controls the "initial" field on forms.TypedChoiceField. + + Returns: + bool | None: The initial value for the form field. If the domain request is set, + this will always return the value of self.form_is_checked. + Otherwise, None will be returned as a new domain request can't start out checked. + """ + # No pre-selection for new domain requests + initial_value = self.form_is_checked if self.domain_request else None + return initial_value diff --git a/src/registrar/management/commands/populate_domain_updated_federal_agency.py b/src/registrar/management/commands/populate_domain_updated_federal_agency.py new file mode 100644 index 000000000..dd8ceb3b2 --- /dev/null +++ b/src/registrar/management/commands/populate_domain_updated_federal_agency.py @@ -0,0 +1,121 @@ +"""" +Data migration: Renaming deprecated Federal Agencies to +their new updated names ie (U.S. Peace Corps to Peace Corps) +within Domain Information and Domain Requests +""" + +import logging + +from django.core.management import BaseCommand +from registrar.models import DomainInformation, DomainRequest, FederalAgency +from registrar.management.commands.utility.terminal_helper import ScriptDataHelper + + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Transfers Domain Request and Domain Information federal agency field from string to FederalAgency object" + + # Deprecated federal agency names mapped to designated replacements {old_value, new value} + rename_deprecated_federal_agency = { + "Appraisal Subcommittee": "Appraisal Subcommittee of the Federal Financial Institutions Examination Council", + "Barry Goldwater Scholarship and Excellence in Education Program": "Barry Goldwater Scholarship and Excellence in Education Foundation", # noqa + "Federal Reserve System": "Federal Reserve Board of Governors", + "Harry S Truman Scholarship Foundation": "Harry S. Truman Scholarship Foundation", + "Japan-US Friendship Commission": "Japan-U.S. Friendship Commission", + "Japan-United States Friendship Commission": "Japan-U.S. Friendship Commission", + "John F. Kennedy Center for Performing Arts": "John F. Kennedy Center for the Performing Arts", + "Occupational Safety & Health Review Commission": "Occupational Safety and Health Review Commission", + "Corporation for National & Community Service": "Corporation for National and Community Service", + "Export/Import Bank of the U.S.": "Export-Import Bank of the United States", + "Medical Payment Advisory Commission": "Medicare Payment Advisory Commission", + "U.S. Peace Corps": "Peace Corps", + "Chemical Safety Board": "U.S. Chemical Safety Board", + "Nuclear Waste Technical Review Board": "U.S. Nuclear Waste Technical Review Board", + "State, Local, and Tribal Government": "Non-Federal Agency", + # "U.S. China Economic and Security Review Commission": "U.S.-China Economic and Security Review Commission", + } + + def find_federal_agency_row(self, domain_object): + federal_agency = domain_object.federal_agency + # Domain Information objects without a federal agency default to Non-Federal Agency + if (federal_agency is None) or (federal_agency == ""): + federal_agency = "Non-Federal Agency" + if federal_agency in self.rename_deprecated_federal_agency.keys(): + federal_agency = self.rename_deprecated_federal_agency[federal_agency] + return FederalAgency.objects.filter(agency=federal_agency).get() + + def handle(self, **options): + """ + Renames the Federal Agency to the correct new naming + for both Domain Information and Domain Requests objects. + + NOTE: If it's None for a domain request, we skip it as + a user most likely hasn't gotten to it yet. + """ + logger.info("Transferring federal agencies to FederalAgency object") + # DomainInformation object we populate with updated_federal_agency which are then bulk updated + domain_infos_to_update = [] + domain_requests_to_update = [] + # Domain Requests with null federal_agency that are not populated with updated_federal_agency + domain_requests_skipped = [] + domain_infos_with_errors = [] + domain_requests_with_errors = [] + + domain_infos = DomainInformation.objects.all() + domain_requests = DomainRequest.objects.all() + + logger.info(f"Found {len(domain_infos)} DomainInfo objects with federal agency.") + logger.info(f"Found {len(domain_requests)} Domain Request objects with federal agency.") + + for domain_info in domain_infos: + try: + federal_agency_row = self.find_federal_agency_row(domain_info) + domain_info.updated_federal_agency = federal_agency_row + domain_infos_to_update.append(domain_info) + logger.info( + f"DomainInformation {domain_info} => updated_federal_agency set to: \ + {domain_info.updated_federal_agency}" + ) + except Exception as err: + domain_infos_with_errors.append(domain_info) + logger.info( + f"DomainInformation {domain_info} failed to update updated_federal_agency \ + from federal_agency {domain_info.federal_agency}. Error: {err}" + ) + + ScriptDataHelper.bulk_update_fields(DomainInformation, domain_infos_to_update, ["updated_federal_agency"]) + + for domain_request in domain_requests: + try: + if (domain_request.federal_agency is None) or (domain_request.federal_agency == ""): + domain_requests_skipped.append(domain_request) + else: + federal_agency_row = self.find_federal_agency_row(domain_request) + domain_request.updated_federal_agency = federal_agency_row + domain_requests_to_update.append(domain_request) + logger.info( + f"DomainRequest {domain_request} => updated_federal_agency set to: \ + {domain_request.updated_federal_agency}" + ) + except Exception as err: + domain_requests_with_errors.append(domain_request) + logger.info( + f"DomainRequest {domain_request} failed to update updated_federal_agency \ + from federal_agency {domain_request.federal_agency}. Error: {err}" + ) + + ScriptDataHelper.bulk_update_fields(DomainRequest, domain_requests_to_update, ["updated_federal_agency"]) + + logger.info(f"{len(domain_infos_to_update)} DomainInformation rows updated update_federal_agency.") + logger.info( + f"{len(domain_infos_with_errors)} DomainInformation rows errored when updating update_federal_agency. \ + {domain_infos_with_errors}" + ) + logger.info(f"{len(domain_requests_to_update)} DomainRequest rows updated update_federal_agency.") + logger.info(f"{len(domain_requests_skipped)} DomainRequest rows with null federal_agency skipped.") + logger.info( + f"{len(domain_requests_with_errors)} DomainRequest rows errored when updating update_federal_agency. \ + {domain_requests_with_errors}" + ) diff --git a/src/registrar/management/commands/populate_verification_type.py b/src/registrar/management/commands/populate_verification_type.py new file mode 100644 index 000000000..b61521977 --- /dev/null +++ b/src/registrar/management/commands/populate_verification_type.py @@ -0,0 +1,23 @@ +import logging +from django.core.management import BaseCommand +from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors +from registrar.models import User + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand, PopulateScriptTemplate): + help = "Loops through each valid User object and updates its verification_type value" + + def handle(self, **kwargs): + """Loops through each valid User object and updates its verification_type value""" + filter_condition = {"verification_type__isnull": True} + self.mass_populate_field(User, filter_condition, ["verification_type"]) + + def populate_field(self, field_to_update): + """Defines how we update the verification_type field""" + field_to_update.set_user_verification_type() + logger.info( + f"{TerminalColors.OKCYAN}Updating {field_to_update} => " + f"{field_to_update.verification_type}{TerminalColors.OKCYAN}" + ) diff --git a/src/registrar/management/commands/utility/terminal_helper.py b/src/registrar/management/commands/utility/terminal_helper.py index b54209750..db3e4a9d3 100644 --- a/src/registrar/management/commands/utility/terminal_helper.py +++ b/src/registrar/management/commands/utility/terminal_helper.py @@ -1,5 +1,6 @@ import logging import sys +from abc import ABC, abstractmethod from django.core.paginator import Paginator from typing import List from registrar.utility.enums import LogCode @@ -58,6 +59,55 @@ class ScriptDataHelper: model_class.objects.bulk_update(page.object_list, fields_to_update) +class PopulateScriptTemplate(ABC): + """ + Contains an ABC for generic populate scripts + """ + + def mass_populate_field(self, sender, filter_conditions, fields_to_update): + """Loops through each valid "sender" object - specified by filter_conditions - and + updates fields defined by fields_to_update using populate_function. + + You must define populate_field before you can use this function. + """ + + objects = sender.objects.filter(**filter_conditions) + + # Code execution will stop here if the user prompts "N" + TerminalHelper.prompt_for_execution( + system_exit_on_terminate=True, + info_to_inspect=f""" + ==Proposed Changes== + Number of {sender} objects to change: {len(objects)} + These fields will be updated on each record: {fields_to_update} + """, + prompt_title="Do you wish to patch this data?", + ) + logger.info("Updating...") + + to_update: List[sender] = [] + failed_to_update: List[sender] = [] + for updated_object in objects: + try: + self.populate_field(updated_object) + to_update.append(updated_object) + except Exception as err: + failed_to_update.append(updated_object) + logger.error(err) + logger.error(f"{TerminalColors.FAIL}" f"Failed to update {updated_object}" f"{TerminalColors.ENDC}") + + # Do a bulk update on the first_ready field + ScriptDataHelper.bulk_update_fields(sender, to_update, fields_to_update) + + # Log what happened + TerminalHelper.log_script_run_summary(to_update, failed_to_update, skipped=[], debug=True) + + @abstractmethod + def populate_field(self, field_to_update): + """Defines how we update each field. Must be defined before using mass_populate_field.""" + raise NotImplementedError + + class TerminalHelper: @staticmethod def log_script_run_summary(to_update, failed_to_update, skipped, debug: bool, log_header=None): diff --git a/src/registrar/migrations/0087_alter_domain_deleted_alter_domain_expiration_date_and_more.py b/src/registrar/migrations/0087_alter_domain_deleted_alter_domain_expiration_date_and_more.py new file mode 100644 index 000000000..edbadcb4e --- /dev/null +++ b/src/registrar/migrations/0087_alter_domain_deleted_alter_domain_expiration_date_and_more.py @@ -0,0 +1,1233 @@ +# Generated by Django 4.2.10 on 2024-04-23 15:03 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import django_fsm + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0086_domaininformation_updated_federal_agency_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="domain", + name="deleted", + field=models.DateField( + editable=False, + help_text='Will appear blank unless the domain is in "deleted" state', + null=True, + verbose_name="deleted on", + ), + ), + migrations.AlterField( + model_name="domain", + name="expiration_date", + field=models.DateField(help_text="Date the domain expires in the registry", null=True), + ), + migrations.AlterField( + model_name="domain", + name="first_ready", + field=models.DateField( + editable=False, + help_text='Date when this domain first moved into "ready" state; date will never change', + null=True, + verbose_name="first ready on", + ), + ), + migrations.AlterField( + model_name="domain", + name="state", + field=django_fsm.FSMField( + choices=[ + ("unknown", "Unknown"), + ("dns needed", "Dns needed"), + ("ready", "Ready"), + ("on hold", "On hold"), + ("deleted", "Deleted"), + ], + default="unknown", + help_text=" ", + max_length=21, + protected=True, + verbose_name="domain state", + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="about_your_organization", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="domaininformation", + name="address_line1", + field=models.CharField(blank=True, null=True, verbose_name="address line 1"), + ), + migrations.AlterField( + model_name="domaininformation", + name="address_line2", + field=models.CharField(blank=True, null=True, verbose_name="address line 2"), + ), + migrations.AlterField( + model_name="domaininformation", + name="anything_else", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="domaininformation", + name="city", + field=models.CharField(blank=True, null=True), + ), + migrations.AlterField( + model_name="domaininformation", + name="creator", + field=models.ForeignKey( + help_text="Person who submitted the domain request", + on_delete=django.db.models.deletion.PROTECT, + related_name="information_created", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="domain", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="domain_info", + to="registrar.domain", + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="domain_request", + field=models.OneToOneField( + blank=True, + help_text="Request associated with this domain", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="DomainRequest_info", + to="registrar.domainrequest", + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="federal_agency", + field=models.CharField( + blank=True, + choices=[ + ( + "Administrative Conference of the United States", + "Administrative Conference of the United States", + ), + ("Advisory Council on Historic Preservation", "Advisory Council on Historic Preservation"), + ("American Battle Monuments Commission", "American Battle Monuments Commission"), + ("AMTRAK", "AMTRAK"), + ("Appalachian Regional Commission", "Appalachian Regional Commission"), + ( + "Appraisal Subcommittee of the Federal Financial Institutions Examination Council", + "Appraisal Subcommittee of the Federal Financial Institutions Examination Council", + ), + ("Appraisal Subcommittee", "Appraisal Subcommittee"), + ("Architect of the Capitol", "Architect of the Capitol"), + ("Armed Forces Retirement Home", "Armed Forces Retirement Home"), + ( + "Barry Goldwater Scholarship and Excellence in Education Foundation", + "Barry Goldwater Scholarship and Excellence in Education Foundation", + ), + ( + "Barry Goldwater Scholarship and Excellence in Education Program", + "Barry Goldwater Scholarship and Excellence in Education Program", + ), + ("Central Intelligence Agency", "Central Intelligence Agency"), + ("Chemical Safety Board", "Chemical Safety Board"), + ("Christopher Columbus Fellowship Foundation", "Christopher Columbus Fellowship Foundation"), + ("Civil Rights Cold Case Records Review Board", "Civil Rights Cold Case Records Review Board"), + ( + "Commission for the Preservation of America's Heritage Abroad", + "Commission for the Preservation of America's Heritage Abroad", + ), + ("Commission of Fine Arts", "Commission of Fine Arts"), + ( + "Committee for Purchase From People Who Are Blind or Severely Disabled", + "Committee for Purchase From People Who Are Blind or Severely Disabled", + ), + ("Commodity Futures Trading Commission", "Commodity Futures Trading Commission"), + ("Congressional Budget Office", "Congressional Budget Office"), + ("Consumer Financial Protection Bureau", "Consumer Financial Protection Bureau"), + ("Consumer Product Safety Commission", "Consumer Product Safety Commission"), + ("Corporation for National & Community Service", "Corporation for National & Community Service"), + ( + "Corporation for National and Community Service", + "Corporation for National and Community Service", + ), + ( + "Council of Inspectors General on Integrity and Efficiency", + "Council of Inspectors General on Integrity and Efficiency", + ), + ("Court Services and Offender Supervision", "Court Services and Offender Supervision"), + ("Cyberspace Solarium Commission", "Cyberspace Solarium Commission"), + ( + "DC Court Services and Offender Supervision Agency", + "DC Court Services and Offender Supervision Agency", + ), + ("DC Pre-trial Services", "DC Pre-trial Services"), + ("Defense Nuclear Facilities Safety Board", "Defense Nuclear Facilities Safety Board"), + ("Delta Regional Authority", "Delta Regional Authority"), + ("Denali Commission", "Denali Commission"), + ("Department of Agriculture", "Department of Agriculture"), + ("Department of Commerce", "Department of Commerce"), + ("Department of Defense", "Department of Defense"), + ("Department of Education", "Department of Education"), + ("Department of Energy", "Department of Energy"), + ("Department of Health and Human Services", "Department of Health and Human Services"), + ("Department of Homeland Security", "Department of Homeland Security"), + ("Department of Housing and Urban Development", "Department of Housing and Urban Development"), + ("Department of Justice", "Department of Justice"), + ("Department of Labor", "Department of Labor"), + ("Department of State", "Department of State"), + ("Department of the Interior", "Department of the Interior"), + ("Department of the Treasury", "Department of the Treasury"), + ("Department of Transportation", "Department of Transportation"), + ("Department of Veterans Affairs", "Department of Veterans Affairs"), + ("Director of National Intelligence", "Director of National Intelligence"), + ("Dwight D. Eisenhower Memorial Commission", "Dwight D. Eisenhower Memorial Commission"), + ("Election Assistance Commission", "Election Assistance Commission"), + ("Environmental Protection Agency", "Environmental Protection Agency"), + ("Equal Employment Opportunity Commission", "Equal Employment Opportunity Commission"), + ("Executive Office of the President", "Executive Office of the President"), + ("Export-Import Bank of the United States", "Export-Import Bank of the United States"), + ("Export/Import Bank of the U.S.", "Export/Import Bank of the U.S."), + ("Farm Credit Administration", "Farm Credit Administration"), + ("Farm Credit System Insurance Corporation", "Farm Credit System Insurance Corporation"), + ("Federal Communications Commission", "Federal Communications Commission"), + ("Federal Deposit Insurance Corporation", "Federal Deposit Insurance Corporation"), + ("Federal Election Commission", "Federal Election Commission"), + ("Federal Energy Regulatory Commission", "Federal Energy Regulatory Commission"), + ( + "Federal Financial Institutions Examination Council", + "Federal Financial Institutions Examination Council", + ), + ("Federal Housing Finance Agency", "Federal Housing Finance Agency"), + ("Federal Judiciary", "Federal Judiciary"), + ("Federal Labor Relations Authority", "Federal Labor Relations Authority"), + ("Federal Maritime Commission", "Federal Maritime Commission"), + ("Federal Mediation and Conciliation Service", "Federal Mediation and Conciliation Service"), + ( + "Federal Mine Safety and Health Review Commission", + "Federal Mine Safety and Health Review Commission", + ), + ( + "Federal Permitting Improvement Steering Council", + "Federal Permitting Improvement Steering Council", + ), + ("Federal Reserve Board of Governors", "Federal Reserve Board of Governors"), + ("Federal Reserve System", "Federal Reserve System"), + ("Federal Trade Commission", "Federal Trade Commission"), + ("General Services Administration", "General Services Administration"), + ("gov Administration", "gov Administration"), + ("Government Accountability Office", "Government Accountability Office"), + ("Government Publishing Office", "Government Publishing Office"), + ("Gulf Coast Ecosystem Restoration Council", "Gulf Coast Ecosystem Restoration Council"), + ("Harry S Truman Scholarship Foundation", "Harry S Truman Scholarship Foundation"), + ("Harry S. Truman Scholarship Foundation", "Harry S. Truman Scholarship Foundation"), + ("Institute of Museum and Library Services", "Institute of Museum and Library Services"), + ("Institute of Peace", "Institute of Peace"), + ("Inter-American Foundation", "Inter-American Foundation"), + ( + "International Boundary and Water Commission: United States and Mexico", + "International Boundary and Water Commission: United States and Mexico", + ), + ( + "International Boundary Commission: United States and Canada", + "International Boundary Commission: United States and Canada", + ), + ( + "International Joint Commission: United States and Canada", + "International Joint Commission: United States and Canada", + ), + ("James Madison Memorial Fellowship Foundation", "James Madison Memorial Fellowship Foundation"), + ("Japan-United States Friendship Commission", "Japan-United States Friendship Commission"), + ("Japan-US Friendship Commission", "Japan-US Friendship Commission"), + ("John F. Kennedy Center for Performing Arts", "John F. Kennedy Center for Performing Arts"), + ( + "John F. Kennedy Center for the Performing Arts", + "John F. Kennedy Center for the Performing Arts", + ), + ("Legal Services Corporation", "Legal Services Corporation"), + ("Legislative Branch", "Legislative Branch"), + ("Library of Congress", "Library of Congress"), + ("Marine Mammal Commission", "Marine Mammal Commission"), + ( + "Medicaid and CHIP Payment and Access Commission", + "Medicaid and CHIP Payment and Access Commission", + ), + ("Medical Payment Advisory Commission", "Medical Payment Advisory Commission"), + ("Medicare Payment Advisory Commission", "Medicare Payment Advisory Commission"), + ("Merit Systems Protection Board", "Merit Systems Protection Board"), + ("Millennium Challenge Corporation", "Millennium Challenge Corporation"), + ( + "Morris K. Udall and Stewart L. Udall Foundation", + "Morris K. Udall and Stewart L. Udall Foundation", + ), + ("National Aeronautics and Space Administration", "National Aeronautics and Space Administration"), + ("National Archives and Records Administration", "National Archives and Records Administration"), + ("National Capital Planning Commission", "National Capital Planning Commission"), + ("National Council on Disability", "National Council on Disability"), + ("National Credit Union Administration", "National Credit Union Administration"), + ("National Endowment for the Arts", "National Endowment for the Arts"), + ("National Endowment for the Humanities", "National Endowment for the Humanities"), + ( + "National Foundation on the Arts and the Humanities", + "National Foundation on the Arts and the Humanities", + ), + ("National Gallery of Art", "National Gallery of Art"), + ("National Indian Gaming Commission", "National Indian Gaming Commission"), + ("National Labor Relations Board", "National Labor Relations Board"), + ("National Mediation Board", "National Mediation Board"), + ("National Science Foundation", "National Science Foundation"), + ( + "National Security Commission on Artificial Intelligence", + "National Security Commission on Artificial Intelligence", + ), + ("National Transportation Safety Board", "National Transportation Safety Board"), + ( + "Networking Information Technology Research and Development", + "Networking Information Technology Research and Development", + ), + ("Non-Federal Agency", "Non-Federal Agency"), + ("Northern Border Regional Commission", "Northern Border Regional Commission"), + ("Nuclear Regulatory Commission", "Nuclear Regulatory Commission"), + ("Nuclear Safety Oversight Committee", "Nuclear Safety Oversight Committee"), + ("Nuclear Waste Technical Review Board", "Nuclear Waste Technical Review Board"), + ( + "Occupational Safety & Health Review Commission", + "Occupational Safety & Health Review Commission", + ), + ( + "Occupational Safety and Health Review Commission", + "Occupational Safety and Health Review Commission", + ), + ("Office of Compliance", "Office of Compliance"), + ("Office of Congressional Workplace Rights", "Office of Congressional Workplace Rights"), + ("Office of Government Ethics", "Office of Government Ethics"), + ("Office of Navajo and Hopi Indian Relocation", "Office of Navajo and Hopi Indian Relocation"), + ("Office of Personnel Management", "Office of Personnel Management"), + ("Open World Leadership Center", "Open World Leadership Center"), + ("Overseas Private Investment Corporation", "Overseas Private Investment Corporation"), + ("Peace Corps", "Peace Corps"), + ("Pension Benefit Guaranty Corporation", "Pension Benefit Guaranty Corporation"), + ("Postal Regulatory Commission", "Postal Regulatory Commission"), + ("Presidio Trust", "Presidio Trust"), + ("Privacy and Civil Liberties Oversight Board", "Privacy and Civil Liberties Oversight Board"), + ("Public Buildings Reform Board", "Public Buildings Reform Board"), + ( + "Public Defender Service for the District of Columbia", + "Public Defender Service for the District of Columbia", + ), + ("Railroad Retirement Board", "Railroad Retirement Board"), + ("Securities and Exchange Commission", "Securities and Exchange Commission"), + ("Selective Service System", "Selective Service System"), + ("Small Business Administration", "Small Business Administration"), + ("Smithsonian Institution", "Smithsonian Institution"), + ("Social Security Administration", "Social Security Administration"), + ("Social Security Advisory Board", "Social Security Advisory Board"), + ("Southeast Crescent Regional Commission", "Southeast Crescent Regional Commission"), + ("Southwest Border Regional Commission", "Southwest Border Regional Commission"), + ("State Justice Institute", "State Justice Institute"), + ("State, Local, and Tribal Government", "State, Local, and Tribal Government"), + ("Stennis Center for Public Service", "Stennis Center for Public Service"), + ("Surface Transportation Board", "Surface Transportation Board"), + ("Tennessee Valley Authority", "Tennessee Valley Authority"), + ("The Executive Office of the President", "The Executive Office of the President"), + ("The Intelligence Community", "The Intelligence Community"), + ("The Legislative Branch", "The Legislative Branch"), + ("The Supreme Court", "The Supreme Court"), + ( + "The United States World War One Centennial Commission", + "The United States World War One Centennial Commission", + ), + ("U.S. Access Board", "U.S. Access Board"), + ("U.S. Agency for Global Media", "U.S. Agency for Global Media"), + ("U.S. Agency for International Development", "U.S. Agency for International Development"), + ("U.S. Capitol Police", "U.S. Capitol Police"), + ("U.S. Chemical Safety Board", "U.S. Chemical Safety Board"), + ( + "U.S. China Economic and Security Review Commission", + "U.S. China Economic and Security Review Commission", + ), + ( + "U.S. Commission for the Preservation of Americas Heritage Abroad", + "U.S. Commission for the Preservation of Americas Heritage Abroad", + ), + ("U.S. Commission of Fine Arts", "U.S. Commission of Fine Arts"), + ("U.S. Commission on Civil Rights", "U.S. Commission on Civil Rights"), + ( + "U.S. Commission on International Religious Freedom", + "U.S. Commission on International Religious Freedom", + ), + ("U.S. Courts", "U.S. Courts"), + ("U.S. Department of Agriculture", "U.S. Department of Agriculture"), + ("U.S. Interagency Council on Homelessness", "U.S. Interagency Council on Homelessness"), + ("U.S. International Trade Commission", "U.S. International Trade Commission"), + ("U.S. Nuclear Waste Technical Review Board", "U.S. Nuclear Waste Technical Review Board"), + ("U.S. Office of Special Counsel", "U.S. Office of Special Counsel"), + ("U.S. Peace Corps", "U.S. Peace Corps"), + ("U.S. Postal Service", "U.S. Postal Service"), + ("U.S. Semiquincentennial Commission", "U.S. Semiquincentennial Commission"), + ("U.S. Trade and Development Agency", "U.S. Trade and Development Agency"), + ( + "U.S.-China Economic and Security Review Commission", + "U.S.-China Economic and Security Review Commission", + ), + ("Udall Foundation", "Udall Foundation"), + ("United States AbilityOne", "United States AbilityOne"), + ("United States Access Board", "United States Access Board"), + ("United States African Development Foundation", "United States African Development Foundation"), + ("United States Agency for Global Media", "United States Agency for Global Media"), + ("United States Arctic Research Commission", "United States Arctic Research Commission"), + ("United States Global Change Research Program", "United States Global Change Research Program"), + ("United States Holocaust Memorial Museum", "United States Holocaust Memorial Museum"), + ("United States Institute of Peace", "United States Institute of Peace"), + ( + "United States Interagency Council on Homelessness", + "United States Interagency Council on Homelessness", + ), + ( + "United States International Development Finance Corporation", + "United States International Development Finance Corporation", + ), + ("United States International Trade Commission", "United States International Trade Commission"), + ("United States Postal Service", "United States Postal Service"), + ("United States Senate", "United States Senate"), + ("United States Trade and Development Agency", "United States Trade and Development Agency"), + ( + "Utah Reclamation Mitigation and Conservation Commission", + "Utah Reclamation Mitigation and Conservation Commission", + ), + ("Vietnam Education Foundation", "Vietnam Education Foundation"), + ("Western Hemisphere Drug Policy Commission", "Western Hemisphere Drug Policy Commission"), + ( + "Woodrow Wilson International Center for Scholars", + "Woodrow Wilson International Center for Scholars", + ), + ("World War I Centennial Commission", "World War I Centennial Commission"), + ], + null=True, + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="federal_type", + field=models.CharField( + blank=True, + choices=[("executive", "Executive"), ("judicial", "Judicial"), ("legislative", "Legislative")], + max_length=50, + null=True, + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="federally_recognized_tribe", + field=models.BooleanField(null=True), + ), + migrations.AlterField( + model_name="domaininformation", + name="is_election_board", + field=models.BooleanField(blank=True, null=True, verbose_name="election office"), + ), + migrations.AlterField( + model_name="domaininformation", + name="no_other_contacts_rationale", + field=models.TextField( + blank=True, help_text="Required if creator does not list other employees", null=True + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="notes", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="domaininformation", + name="organization_name", + field=models.CharField(blank=True, db_index=True, null=True), + ), + migrations.AlterField( + model_name="domaininformation", + name="organization_type", + field=models.CharField( + blank=True, + choices=[ + ("federal", "Federal"), + ("interstate", "Interstate"), + ("state_or_territory", "State or territory"), + ("tribal", "Tribal"), + ("county", "County"), + ("city", "City"), + ("special_district", "Special district"), + ("school_district", "School district"), + ("state_or_territory_election", "State or territory - Election"), + ("tribal_election", "Tribal - Election"), + ("county_election", "County - Election"), + ("city_election", "City - Election"), + ("special_district_election", "Special district - Election"), + ], + help_text='"Election" appears after the org type if it\'s an election office.', + max_length=255, + null=True, + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="state_recognized_tribe", + field=models.BooleanField(null=True), + ), + migrations.AlterField( + model_name="domaininformation", + name="state_territory", + field=models.CharField( + blank=True, + choices=[ + ("AL", "Alabama (AL)"), + ("AK", "Alaska (AK)"), + ("AS", "American Samoa (AS)"), + ("AZ", "Arizona (AZ)"), + ("AR", "Arkansas (AR)"), + ("CA", "California (CA)"), + ("CO", "Colorado (CO)"), + ("CT", "Connecticut (CT)"), + ("DE", "Delaware (DE)"), + ("DC", "District of Columbia (DC)"), + ("FL", "Florida (FL)"), + ("GA", "Georgia (GA)"), + ("GU", "Guam (GU)"), + ("HI", "Hawaii (HI)"), + ("ID", "Idaho (ID)"), + ("IL", "Illinois (IL)"), + ("IN", "Indiana (IN)"), + ("IA", "Iowa (IA)"), + ("KS", "Kansas (KS)"), + ("KY", "Kentucky (KY)"), + ("LA", "Louisiana (LA)"), + ("ME", "Maine (ME)"), + ("MD", "Maryland (MD)"), + ("MA", "Massachusetts (MA)"), + ("MI", "Michigan (MI)"), + ("MN", "Minnesota (MN)"), + ("MS", "Mississippi (MS)"), + ("MO", "Missouri (MO)"), + ("MT", "Montana (MT)"), + ("NE", "Nebraska (NE)"), + ("NV", "Nevada (NV)"), + ("NH", "New Hampshire (NH)"), + ("NJ", "New Jersey (NJ)"), + ("NM", "New Mexico (NM)"), + ("NY", "New York (NY)"), + ("NC", "North Carolina (NC)"), + ("ND", "North Dakota (ND)"), + ("MP", "Northern Mariana Islands (MP)"), + ("OH", "Ohio (OH)"), + ("OK", "Oklahoma (OK)"), + ("OR", "Oregon (OR)"), + ("PA", "Pennsylvania (PA)"), + ("PR", "Puerto Rico (PR)"), + ("RI", "Rhode Island (RI)"), + ("SC", "South Carolina (SC)"), + ("SD", "South Dakota (SD)"), + ("TN", "Tennessee (TN)"), + ("TX", "Texas (TX)"), + ("UM", "United States Minor Outlying Islands (UM)"), + ("UT", "Utah (UT)"), + ("VT", "Vermont (VT)"), + ("VI", "Virgin Islands (VI)"), + ("VA", "Virginia (VA)"), + ("WA", "Washington (WA)"), + ("WV", "West Virginia (WV)"), + ("WI", "Wisconsin (WI)"), + ("WY", "Wyoming (WY)"), + ("AA", "Armed Forces Americas (AA)"), + ("AE", "Armed Forces Africa, Canada, Europe, Middle East (AE)"), + ("AP", "Armed Forces Pacific (AP)"), + ], + max_length=2, + null=True, + verbose_name="state / territory", + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="submitter", + field=models.ForeignKey( + blank=True, + help_text='Person listed under "your contact information" in the request form', + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="submitted_domain_requests_information", + to="registrar.contact", + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="tribe_name", + field=models.CharField(blank=True, null=True), + ), + migrations.AlterField( + model_name="domaininformation", + name="urbanization", + field=models.CharField( + blank=True, help_text="Required for Puerto Rico only", null=True, verbose_name="urbanization" + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="zipcode", + field=models.CharField(blank=True, db_index=True, max_length=10, null=True, verbose_name="zip code"), + ), + migrations.AlterField( + model_name="domainrequest", + name="about_your_organization", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="domainrequest", + name="address_line1", + field=models.CharField(blank=True, null=True, verbose_name="Address line 1"), + ), + migrations.AlterField( + model_name="domainrequest", + name="address_line2", + field=models.CharField(blank=True, null=True, verbose_name="Address line 2"), + ), + migrations.AlterField( + model_name="domainrequest", + name="alternative_domains", + field=models.ManyToManyField( + blank=True, + help_text="Other domain names the creator provided for consideration", + related_name="alternatives+", + to="registrar.website", + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="anything_else", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="domainrequest", + name="approved_domain", + field=models.OneToOneField( + blank=True, + help_text="Domain associated with this request; will be blank until request is approved", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="domain_request", + to="registrar.domain", + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="city", + field=models.CharField(blank=True, null=True), + ), + migrations.AlterField( + model_name="domainrequest", + name="creator", + field=models.ForeignKey( + help_text="Person who submitted the domain request; will not receive email updates", + on_delete=django.db.models.deletion.PROTECT, + related_name="domain_requests_created", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="federal_agency", + field=models.CharField( + blank=True, + choices=[ + ( + "Administrative Conference of the United States", + "Administrative Conference of the United States", + ), + ("Advisory Council on Historic Preservation", "Advisory Council on Historic Preservation"), + ("American Battle Monuments Commission", "American Battle Monuments Commission"), + ("AMTRAK", "AMTRAK"), + ("Appalachian Regional Commission", "Appalachian Regional Commission"), + ( + "Appraisal Subcommittee of the Federal Financial Institutions Examination Council", + "Appraisal Subcommittee of the Federal Financial Institutions Examination Council", + ), + ("Appraisal Subcommittee", "Appraisal Subcommittee"), + ("Architect of the Capitol", "Architect of the Capitol"), + ("Armed Forces Retirement Home", "Armed Forces Retirement Home"), + ( + "Barry Goldwater Scholarship and Excellence in Education Foundation", + "Barry Goldwater Scholarship and Excellence in Education Foundation", + ), + ( + "Barry Goldwater Scholarship and Excellence in Education Program", + "Barry Goldwater Scholarship and Excellence in Education Program", + ), + ("Central Intelligence Agency", "Central Intelligence Agency"), + ("Chemical Safety Board", "Chemical Safety Board"), + ("Christopher Columbus Fellowship Foundation", "Christopher Columbus Fellowship Foundation"), + ("Civil Rights Cold Case Records Review Board", "Civil Rights Cold Case Records Review Board"), + ( + "Commission for the Preservation of America's Heritage Abroad", + "Commission for the Preservation of America's Heritage Abroad", + ), + ("Commission of Fine Arts", "Commission of Fine Arts"), + ( + "Committee for Purchase From People Who Are Blind or Severely Disabled", + "Committee for Purchase From People Who Are Blind or Severely Disabled", + ), + ("Commodity Futures Trading Commission", "Commodity Futures Trading Commission"), + ("Congressional Budget Office", "Congressional Budget Office"), + ("Consumer Financial Protection Bureau", "Consumer Financial Protection Bureau"), + ("Consumer Product Safety Commission", "Consumer Product Safety Commission"), + ("Corporation for National & Community Service", "Corporation for National & Community Service"), + ( + "Corporation for National and Community Service", + "Corporation for National and Community Service", + ), + ( + "Council of Inspectors General on Integrity and Efficiency", + "Council of Inspectors General on Integrity and Efficiency", + ), + ("Court Services and Offender Supervision", "Court Services and Offender Supervision"), + ("Cyberspace Solarium Commission", "Cyberspace Solarium Commission"), + ( + "DC Court Services and Offender Supervision Agency", + "DC Court Services and Offender Supervision Agency", + ), + ("DC Pre-trial Services", "DC Pre-trial Services"), + ("Defense Nuclear Facilities Safety Board", "Defense Nuclear Facilities Safety Board"), + ("Delta Regional Authority", "Delta Regional Authority"), + ("Denali Commission", "Denali Commission"), + ("Department of Agriculture", "Department of Agriculture"), + ("Department of Commerce", "Department of Commerce"), + ("Department of Defense", "Department of Defense"), + ("Department of Education", "Department of Education"), + ("Department of Energy", "Department of Energy"), + ("Department of Health and Human Services", "Department of Health and Human Services"), + ("Department of Homeland Security", "Department of Homeland Security"), + ("Department of Housing and Urban Development", "Department of Housing and Urban Development"), + ("Department of Justice", "Department of Justice"), + ("Department of Labor", "Department of Labor"), + ("Department of State", "Department of State"), + ("Department of the Interior", "Department of the Interior"), + ("Department of the Treasury", "Department of the Treasury"), + ("Department of Transportation", "Department of Transportation"), + ("Department of Veterans Affairs", "Department of Veterans Affairs"), + ("Director of National Intelligence", "Director of National Intelligence"), + ("Dwight D. Eisenhower Memorial Commission", "Dwight D. Eisenhower Memorial Commission"), + ("Election Assistance Commission", "Election Assistance Commission"), + ("Environmental Protection Agency", "Environmental Protection Agency"), + ("Equal Employment Opportunity Commission", "Equal Employment Opportunity Commission"), + ("Executive Office of the President", "Executive Office of the President"), + ("Export-Import Bank of the United States", "Export-Import Bank of the United States"), + ("Export/Import Bank of the U.S.", "Export/Import Bank of the U.S."), + ("Farm Credit Administration", "Farm Credit Administration"), + ("Farm Credit System Insurance Corporation", "Farm Credit System Insurance Corporation"), + ("Federal Communications Commission", "Federal Communications Commission"), + ("Federal Deposit Insurance Corporation", "Federal Deposit Insurance Corporation"), + ("Federal Election Commission", "Federal Election Commission"), + ("Federal Energy Regulatory Commission", "Federal Energy Regulatory Commission"), + ( + "Federal Financial Institutions Examination Council", + "Federal Financial Institutions Examination Council", + ), + ("Federal Housing Finance Agency", "Federal Housing Finance Agency"), + ("Federal Judiciary", "Federal Judiciary"), + ("Federal Labor Relations Authority", "Federal Labor Relations Authority"), + ("Federal Maritime Commission", "Federal Maritime Commission"), + ("Federal Mediation and Conciliation Service", "Federal Mediation and Conciliation Service"), + ( + "Federal Mine Safety and Health Review Commission", + "Federal Mine Safety and Health Review Commission", + ), + ( + "Federal Permitting Improvement Steering Council", + "Federal Permitting Improvement Steering Council", + ), + ("Federal Reserve Board of Governors", "Federal Reserve Board of Governors"), + ("Federal Reserve System", "Federal Reserve System"), + ("Federal Trade Commission", "Federal Trade Commission"), + ("General Services Administration", "General Services Administration"), + ("gov Administration", "gov Administration"), + ("Government Accountability Office", "Government Accountability Office"), + ("Government Publishing Office", "Government Publishing Office"), + ("Gulf Coast Ecosystem Restoration Council", "Gulf Coast Ecosystem Restoration Council"), + ("Harry S Truman Scholarship Foundation", "Harry S Truman Scholarship Foundation"), + ("Harry S. Truman Scholarship Foundation", "Harry S. Truman Scholarship Foundation"), + ("Institute of Museum and Library Services", "Institute of Museum and Library Services"), + ("Institute of Peace", "Institute of Peace"), + ("Inter-American Foundation", "Inter-American Foundation"), + ( + "International Boundary and Water Commission: United States and Mexico", + "International Boundary and Water Commission: United States and Mexico", + ), + ( + "International Boundary Commission: United States and Canada", + "International Boundary Commission: United States and Canada", + ), + ( + "International Joint Commission: United States and Canada", + "International Joint Commission: United States and Canada", + ), + ("James Madison Memorial Fellowship Foundation", "James Madison Memorial Fellowship Foundation"), + ("Japan-United States Friendship Commission", "Japan-United States Friendship Commission"), + ("Japan-US Friendship Commission", "Japan-US Friendship Commission"), + ("John F. Kennedy Center for Performing Arts", "John F. Kennedy Center for Performing Arts"), + ( + "John F. Kennedy Center for the Performing Arts", + "John F. Kennedy Center for the Performing Arts", + ), + ("Legal Services Corporation", "Legal Services Corporation"), + ("Legislative Branch", "Legislative Branch"), + ("Library of Congress", "Library of Congress"), + ("Marine Mammal Commission", "Marine Mammal Commission"), + ( + "Medicaid and CHIP Payment and Access Commission", + "Medicaid and CHIP Payment and Access Commission", + ), + ("Medical Payment Advisory Commission", "Medical Payment Advisory Commission"), + ("Medicare Payment Advisory Commission", "Medicare Payment Advisory Commission"), + ("Merit Systems Protection Board", "Merit Systems Protection Board"), + ("Millennium Challenge Corporation", "Millennium Challenge Corporation"), + ( + "Morris K. Udall and Stewart L. Udall Foundation", + "Morris K. Udall and Stewart L. Udall Foundation", + ), + ("National Aeronautics and Space Administration", "National Aeronautics and Space Administration"), + ("National Archives and Records Administration", "National Archives and Records Administration"), + ("National Capital Planning Commission", "National Capital Planning Commission"), + ("National Council on Disability", "National Council on Disability"), + ("National Credit Union Administration", "National Credit Union Administration"), + ("National Endowment for the Arts", "National Endowment for the Arts"), + ("National Endowment for the Humanities", "National Endowment for the Humanities"), + ( + "National Foundation on the Arts and the Humanities", + "National Foundation on the Arts and the Humanities", + ), + ("National Gallery of Art", "National Gallery of Art"), + ("National Indian Gaming Commission", "National Indian Gaming Commission"), + ("National Labor Relations Board", "National Labor Relations Board"), + ("National Mediation Board", "National Mediation Board"), + ("National Science Foundation", "National Science Foundation"), + ( + "National Security Commission on Artificial Intelligence", + "National Security Commission on Artificial Intelligence", + ), + ("National Transportation Safety Board", "National Transportation Safety Board"), + ( + "Networking Information Technology Research and Development", + "Networking Information Technology Research and Development", + ), + ("Non-Federal Agency", "Non-Federal Agency"), + ("Northern Border Regional Commission", "Northern Border Regional Commission"), + ("Nuclear Regulatory Commission", "Nuclear Regulatory Commission"), + ("Nuclear Safety Oversight Committee", "Nuclear Safety Oversight Committee"), + ("Nuclear Waste Technical Review Board", "Nuclear Waste Technical Review Board"), + ( + "Occupational Safety & Health Review Commission", + "Occupational Safety & Health Review Commission", + ), + ( + "Occupational Safety and Health Review Commission", + "Occupational Safety and Health Review Commission", + ), + ("Office of Compliance", "Office of Compliance"), + ("Office of Congressional Workplace Rights", "Office of Congressional Workplace Rights"), + ("Office of Government Ethics", "Office of Government Ethics"), + ("Office of Navajo and Hopi Indian Relocation", "Office of Navajo and Hopi Indian Relocation"), + ("Office of Personnel Management", "Office of Personnel Management"), + ("Open World Leadership Center", "Open World Leadership Center"), + ("Overseas Private Investment Corporation", "Overseas Private Investment Corporation"), + ("Peace Corps", "Peace Corps"), + ("Pension Benefit Guaranty Corporation", "Pension Benefit Guaranty Corporation"), + ("Postal Regulatory Commission", "Postal Regulatory Commission"), + ("Presidio Trust", "Presidio Trust"), + ("Privacy and Civil Liberties Oversight Board", "Privacy and Civil Liberties Oversight Board"), + ("Public Buildings Reform Board", "Public Buildings Reform Board"), + ( + "Public Defender Service for the District of Columbia", + "Public Defender Service for the District of Columbia", + ), + ("Railroad Retirement Board", "Railroad Retirement Board"), + ("Securities and Exchange Commission", "Securities and Exchange Commission"), + ("Selective Service System", "Selective Service System"), + ("Small Business Administration", "Small Business Administration"), + ("Smithsonian Institution", "Smithsonian Institution"), + ("Social Security Administration", "Social Security Administration"), + ("Social Security Advisory Board", "Social Security Advisory Board"), + ("Southeast Crescent Regional Commission", "Southeast Crescent Regional Commission"), + ("Southwest Border Regional Commission", "Southwest Border Regional Commission"), + ("State Justice Institute", "State Justice Institute"), + ("State, Local, and Tribal Government", "State, Local, and Tribal Government"), + ("Stennis Center for Public Service", "Stennis Center for Public Service"), + ("Surface Transportation Board", "Surface Transportation Board"), + ("Tennessee Valley Authority", "Tennessee Valley Authority"), + ("The Executive Office of the President", "The Executive Office of the President"), + ("The Intelligence Community", "The Intelligence Community"), + ("The Legislative Branch", "The Legislative Branch"), + ("The Supreme Court", "The Supreme Court"), + ( + "The United States World War One Centennial Commission", + "The United States World War One Centennial Commission", + ), + ("U.S. Access Board", "U.S. Access Board"), + ("U.S. Agency for Global Media", "U.S. Agency for Global Media"), + ("U.S. Agency for International Development", "U.S. Agency for International Development"), + ("U.S. Capitol Police", "U.S. Capitol Police"), + ("U.S. Chemical Safety Board", "U.S. Chemical Safety Board"), + ( + "U.S. China Economic and Security Review Commission", + "U.S. China Economic and Security Review Commission", + ), + ( + "U.S. Commission for the Preservation of Americas Heritage Abroad", + "U.S. Commission for the Preservation of Americas Heritage Abroad", + ), + ("U.S. Commission of Fine Arts", "U.S. Commission of Fine Arts"), + ("U.S. Commission on Civil Rights", "U.S. Commission on Civil Rights"), + ( + "U.S. Commission on International Religious Freedom", + "U.S. Commission on International Religious Freedom", + ), + ("U.S. Courts", "U.S. Courts"), + ("U.S. Department of Agriculture", "U.S. Department of Agriculture"), + ("U.S. Interagency Council on Homelessness", "U.S. Interagency Council on Homelessness"), + ("U.S. International Trade Commission", "U.S. International Trade Commission"), + ("U.S. Nuclear Waste Technical Review Board", "U.S. Nuclear Waste Technical Review Board"), + ("U.S. Office of Special Counsel", "U.S. Office of Special Counsel"), + ("U.S. Peace Corps", "U.S. Peace Corps"), + ("U.S. Postal Service", "U.S. Postal Service"), + ("U.S. Semiquincentennial Commission", "U.S. Semiquincentennial Commission"), + ("U.S. Trade and Development Agency", "U.S. Trade and Development Agency"), + ( + "U.S.-China Economic and Security Review Commission", + "U.S.-China Economic and Security Review Commission", + ), + ("Udall Foundation", "Udall Foundation"), + ("United States AbilityOne", "United States AbilityOne"), + ("United States Access Board", "United States Access Board"), + ("United States African Development Foundation", "United States African Development Foundation"), + ("United States Agency for Global Media", "United States Agency for Global Media"), + ("United States Arctic Research Commission", "United States Arctic Research Commission"), + ("United States Global Change Research Program", "United States Global Change Research Program"), + ("United States Holocaust Memorial Museum", "United States Holocaust Memorial Museum"), + ("United States Institute of Peace", "United States Institute of Peace"), + ( + "United States Interagency Council on Homelessness", + "United States Interagency Council on Homelessness", + ), + ( + "United States International Development Finance Corporation", + "United States International Development Finance Corporation", + ), + ("United States International Trade Commission", "United States International Trade Commission"), + ("United States Postal Service", "United States Postal Service"), + ("United States Senate", "United States Senate"), + ("United States Trade and Development Agency", "United States Trade and Development Agency"), + ( + "Utah Reclamation Mitigation and Conservation Commission", + "Utah Reclamation Mitigation and Conservation Commission", + ), + ("Vietnam Education Foundation", "Vietnam Education Foundation"), + ("Western Hemisphere Drug Policy Commission", "Western Hemisphere Drug Policy Commission"), + ( + "Woodrow Wilson International Center for Scholars", + "Woodrow Wilson International Center for Scholars", + ), + ("World War I Centennial Commission", "World War I Centennial Commission"), + ], + null=True, + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="federal_type", + field=models.CharField( + blank=True, + choices=[("executive", "Executive"), ("judicial", "Judicial"), ("legislative", "Legislative")], + max_length=50, + null=True, + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="federally_recognized_tribe", + field=models.BooleanField(null=True), + ), + migrations.AlterField( + model_name="domainrequest", + name="generic_org_type", + field=models.CharField( + blank=True, + choices=[ + ("federal", "Federal"), + ("interstate", "Interstate"), + ("state_or_territory", "State or territory"), + ("tribal", "Tribal"), + ("county", "County"), + ("city", "City"), + ("special_district", "Special district"), + ("school_district", "School district"), + ], + max_length=255, + null=True, + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="is_election_board", + field=models.BooleanField(blank=True, null=True, verbose_name="election office"), + ), + migrations.AlterField( + model_name="domainrequest", + name="no_other_contacts_rationale", + field=models.TextField( + blank=True, help_text="Required if creator does not list other employees", null=True + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="notes", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="domainrequest", + name="organization_name", + field=models.CharField(blank=True, db_index=True, null=True), + ), + migrations.AlterField( + model_name="domainrequest", + name="organization_type", + field=models.CharField( + blank=True, + choices=[ + ("federal", "Federal"), + ("interstate", "Interstate"), + ("state_or_territory", "State or territory"), + ("tribal", "Tribal"), + ("county", "County"), + ("city", "City"), + ("special_district", "Special district"), + ("school_district", "School district"), + ("state_or_territory_election", "State or territory - Election"), + ("tribal_election", "Tribal - Election"), + ("county_election", "County - Election"), + ("city_election", "City - Election"), + ("special_district_election", "Special district - Election"), + ], + help_text='"Election" appears after the org type if it\'s an election office.', + max_length=255, + null=True, + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="purpose", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="domainrequest", + name="requested_domain", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="domain_request", + to="registrar.draftdomain", + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="state_recognized_tribe", + field=models.BooleanField(null=True), + ), + migrations.AlterField( + model_name="domainrequest", + name="state_territory", + field=models.CharField( + blank=True, + choices=[ + ("AL", "Alabama (AL)"), + ("AK", "Alaska (AK)"), + ("AS", "American Samoa (AS)"), + ("AZ", "Arizona (AZ)"), + ("AR", "Arkansas (AR)"), + ("CA", "California (CA)"), + ("CO", "Colorado (CO)"), + ("CT", "Connecticut (CT)"), + ("DE", "Delaware (DE)"), + ("DC", "District of Columbia (DC)"), + ("FL", "Florida (FL)"), + ("GA", "Georgia (GA)"), + ("GU", "Guam (GU)"), + ("HI", "Hawaii (HI)"), + ("ID", "Idaho (ID)"), + ("IL", "Illinois (IL)"), + ("IN", "Indiana (IN)"), + ("IA", "Iowa (IA)"), + ("KS", "Kansas (KS)"), + ("KY", "Kentucky (KY)"), + ("LA", "Louisiana (LA)"), + ("ME", "Maine (ME)"), + ("MD", "Maryland (MD)"), + ("MA", "Massachusetts (MA)"), + ("MI", "Michigan (MI)"), + ("MN", "Minnesota (MN)"), + ("MS", "Mississippi (MS)"), + ("MO", "Missouri (MO)"), + ("MT", "Montana (MT)"), + ("NE", "Nebraska (NE)"), + ("NV", "Nevada (NV)"), + ("NH", "New Hampshire (NH)"), + ("NJ", "New Jersey (NJ)"), + ("NM", "New Mexico (NM)"), + ("NY", "New York (NY)"), + ("NC", "North Carolina (NC)"), + ("ND", "North Dakota (ND)"), + ("MP", "Northern Mariana Islands (MP)"), + ("OH", "Ohio (OH)"), + ("OK", "Oklahoma (OK)"), + ("OR", "Oregon (OR)"), + ("PA", "Pennsylvania (PA)"), + ("PR", "Puerto Rico (PR)"), + ("RI", "Rhode Island (RI)"), + ("SC", "South Carolina (SC)"), + ("SD", "South Dakota (SD)"), + ("TN", "Tennessee (TN)"), + ("TX", "Texas (TX)"), + ("UM", "United States Minor Outlying Islands (UM)"), + ("UT", "Utah (UT)"), + ("VT", "Vermont (VT)"), + ("VI", "Virgin Islands (VI)"), + ("VA", "Virginia (VA)"), + ("WA", "Washington (WA)"), + ("WV", "West Virginia (WV)"), + ("WI", "Wisconsin (WI)"), + ("WY", "Wyoming (WY)"), + ("AA", "Armed Forces Americas (AA)"), + ("AE", "Armed Forces Africa, Canada, Europe, Middle East (AE)"), + ("AP", "Armed Forces Pacific (AP)"), + ], + max_length=2, + null=True, + verbose_name="state / territory", + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="submitter", + field=models.ForeignKey( + blank=True, + help_text='Person listed under "your contact information" in the request form; will receive email updates', + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="submitted_domain_requests", + to="registrar.contact", + ), + ), + migrations.AlterField( + model_name="domainrequest", + name="tribe_name", + field=models.CharField(blank=True, null=True), + ), + migrations.AlterField( + model_name="domainrequest", + name="urbanization", + field=models.CharField(blank=True, help_text="Required for Puerto Rico only", null=True), + ), + migrations.AlterField( + model_name="domainrequest", + name="zipcode", + field=models.CharField(blank=True, db_index=True, max_length=10, null=True, verbose_name="zip code"), + ), + migrations.AlterField( + model_name="host", + name="domain", + field=models.ForeignKey( + help_text="Domain associated with this host", + on_delete=django.db.models.deletion.PROTECT, + related_name="host", + to="registrar.domain", + ), + ), + migrations.AlterField( + model_name="host", + name="name", + field=models.CharField(default=None, max_length=253, verbose_name="host name"), + ), + migrations.AlterField( + model_name="hostip", + name="address", + field=models.CharField( + default=None, + max_length=46, + validators=[django.core.validators.validate_ipv46_address], + verbose_name="IP address", + ), + ), + migrations.AlterField( + model_name="hostip", + name="host", + field=models.ForeignKey( + help_text="IP associated with this host", + on_delete=django.db.models.deletion.PROTECT, + related_name="ip", + to="registrar.host", + ), + ), + migrations.AlterField( + model_name="user", + name="status", + field=models.CharField( + blank=True, + choices=[("restricted", "restricted")], + default=None, + help_text='Users in "restricted" status cannot make updates in the registrar or start a new request.', + max_length=10, + null=True, + verbose_name="user status", + ), + ), + migrations.AlterField( + model_name="verifiedbystaff", + name="email", + field=models.EmailField(db_index=True, max_length=254), + ), + migrations.AlterField( + model_name="verifiedbystaff", + name="notes", + field=models.TextField(), + ), + migrations.AlterField( + model_name="verifiedbystaff", + name="requestor", + field=models.ForeignKey( + blank=True, + help_text="Person who verified this user", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="verifiedby_user", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="website", + name="website", + field=models.CharField( + help_text="An alternative domain or current website listed on a domain request", max_length=255 + ), + ), + ] diff --git a/src/registrar/migrations/0088_domaininformation_cisa_representative_email_and_more.py b/src/registrar/migrations/0088_domaininformation_cisa_representative_email_and_more.py new file mode 100644 index 000000000..95450fb3d --- /dev/null +++ b/src/registrar/migrations/0088_domaininformation_cisa_representative_email_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.10 on 2024-04-25 16:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0087_alter_domain_deleted_alter_domain_expiration_date_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="domaininformation", + name="cisa_representative_email", + field=models.EmailField(blank=True, max_length=320, null=True, verbose_name="CISA regional representative"), + ), + migrations.AddField( + model_name="domainrequest", + name="cisa_representative_email", + field=models.EmailField(blank=True, max_length=320, null=True, verbose_name="CISA regional representative"), + ), + migrations.AddField( + model_name="domainrequest", + name="has_anything_else_text", + field=models.BooleanField( + blank=True, help_text="Determines if the user has a anything_else or not", null=True + ), + ), + migrations.AddField( + model_name="domainrequest", + name="has_cisa_representative", + field=models.BooleanField( + blank=True, help_text="Determines if the user has a representative email or not", null=True + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="anything_else", + field=models.TextField(blank=True, null=True, verbose_name="Additional details"), + ), + migrations.AlterField( + model_name="domainrequest", + name="anything_else", + field=models.TextField(blank=True, null=True, verbose_name="Additional details"), + ), + ] diff --git a/src/registrar/migrations/0089_user_verification_type.py b/src/registrar/migrations/0089_user_verification_type.py new file mode 100644 index 000000000..e021e89e1 --- /dev/null +++ b/src/registrar/migrations/0089_user_verification_type.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.10 on 2024-04-26 14:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0088_domaininformation_cisa_representative_email_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="verification_type", + field=models.CharField( + blank=True, + choices=[ + ("grandfathered", "Legacy user"), + ("verified_by_staff", "Verified by staff"), + ("regular", "Verified by Login.gov"), + ("invited", "Invited by a domain manager"), + ("fixture_user", "Created by fixtures"), + ], + help_text="The means through which this user was verified", + null=True, + ), + ), + ] diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 0d0d8020d..7f53bb234 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -159,6 +159,31 @@ class Domain(TimeStampedModel, DomainHelper): return help_texts.get(state, "") + @classmethod + def get_admin_help_text(cls, state): + """Returns a help message for a desired state for /admin. If none is found, an empty string is returned""" + admin_help_texts = { + cls.UNKNOWN: ( + "The creator of the associated domain request has not logged in to " + "manage the domain since it was approved. " + 'The state will switch to "DNS needed" after they access the domain in the registrar.' + ), + cls.DNS_NEEDED: ( + "Before this domain can be used, name server addresses need to be added within the registrar." + ), + cls.READY: "This domain has name servers and is ready for use.", + cls.ON_HOLD: ( + "While on hold, this domain won't resolve in DNS and " + "any infrastructure (like websites) will be offline." + ), + cls.DELETED: ( + "This domain was permanently removed from the registry. " + "The domain no longer resolves in DNS and any infrastructure (like websites) is offline." + ), + } + + return admin_help_texts.get(state, "") + class Cache(property): """ Python descriptor to turn class methods into properties. @@ -992,22 +1017,25 @@ class Domain(TimeStampedModel, DomainHelper): blank=False, default=None, # prevent saving without a value unique=True, - verbose_name="domain", help_text="Fully qualified domain name", + verbose_name="domain", ) state = FSMField( max_length=21, choices=State.choices, default=State.UNKNOWN, - protected=True, # cannot change state directly, particularly in Django admin + # cannot change state directly, particularly in Django admin + protected=True, + # This must be defined for custom state help messages, + # as otherwise the view will purge the help field as it does not exist. + help_text=" ", verbose_name="domain state", - help_text="Very basic info about the lifecycle of this domain object", ) expiration_date = DateField( null=True, - help_text=("Duplication of registry's expiration date saved for ease of reporting"), + help_text=("Date the domain expires in the registry"), ) security_contact_registry_id = TextField( @@ -1019,15 +1047,15 @@ class Domain(TimeStampedModel, DomainHelper): deleted = DateField( null=True, editable=False, + help_text='Will appear blank unless the domain is in "deleted" state', verbose_name="deleted on", - help_text="Deleted at date", ) first_ready = DateField( null=True, editable=False, + help_text='Date when this domain first moved into "ready" state; date will never change', verbose_name="first ready on", - help_text="The last time this domain moved into the READY state", ) def isActive(self): diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index 3c206457e..c724423ce 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -47,6 +47,7 @@ class DomainInformation(TimeStampedModel): "registrar.User", on_delete=models.PROTECT, related_name="information_created", + help_text="Person who submitted the domain request", ) domain_request = models.OneToOneField( @@ -55,7 +56,7 @@ class DomainInformation(TimeStampedModel): blank=True, null=True, related_name="DomainRequest_info", - help_text="Associated domain request", + help_text="Request associated with this domain", unique=True, ) @@ -73,7 +74,6 @@ class DomainInformation(TimeStampedModel): null=True, blank=True, verbose_name="election office", - help_text="Is your organization an election office?", ) # TODO - Ticket #1911: stub this data from DomainRequest @@ -82,30 +82,26 @@ class DomainInformation(TimeStampedModel): choices=DomainRequest.OrgChoicesElectionOffice.choices, null=True, blank=True, - help_text="Type of organization - Election office", + help_text='"Election" appears after the org type if it\'s an election office.', ) federally_recognized_tribe = models.BooleanField( null=True, - help_text="Is the tribe federally recognized", ) state_recognized_tribe = models.BooleanField( null=True, - help_text="Is the tribe recognized by a state", ) tribe_name = models.CharField( null=True, blank=True, - help_text="Name of tribe", ) federal_agency = models.CharField( choices=AGENCY_CHOICES, null=True, blank=True, - help_text="Federal agency", ) federal_type = models.CharField( @@ -113,38 +109,32 @@ class DomainInformation(TimeStampedModel): choices=BranchChoices.choices, null=True, blank=True, - help_text="Federal government branch", ) is_election_board = models.BooleanField( null=True, blank=True, verbose_name="election office", - help_text="Is your organization an election office?", ) organization_name = models.CharField( null=True, blank=True, - help_text="Organization name", db_index=True, ) address_line1 = models.CharField( null=True, blank=True, - help_text="Street address", verbose_name="address line 1", ) address_line2 = models.CharField( null=True, blank=True, - help_text="Street address line 2 (optional)", verbose_name="address line 2", ) city = models.CharField( null=True, blank=True, - help_text="City", ) state_territory = models.CharField( max_length=2, @@ -152,27 +142,24 @@ class DomainInformation(TimeStampedModel): null=True, blank=True, verbose_name="state / territory", - help_text="State, territory, or military post", ) zipcode = models.CharField( max_length=10, null=True, blank=True, - help_text="Zip code", - verbose_name="zip code", db_index=True, + verbose_name="zip code", ) urbanization = models.CharField( null=True, blank=True, - help_text="Urbanization (required for Puerto Rico only)", + help_text="Required for Puerto Rico only", verbose_name="urbanization", ) about_your_organization = models.TextField( null=True, blank=True, - help_text="Information about your organization", ) authorizing_official = models.ForeignKey( @@ -190,7 +177,6 @@ class DomainInformation(TimeStampedModel): null=True, # Access this information via Domain as "domain.domain_info" related_name="domain_info", - help_text="Domain to which this information belongs", ) # This is the contact information provided by the domain requestor. The @@ -201,6 +187,7 @@ class DomainInformation(TimeStampedModel): blank=True, related_name="submitted_domain_requests_information", on_delete=models.PROTECT, + help_text='Person listed under "your contact information" in the request form', ) purpose = models.TextField( @@ -219,13 +206,20 @@ class DomainInformation(TimeStampedModel): no_other_contacts_rationale = models.TextField( null=True, blank=True, - help_text="Reason for listing no additional contacts", + help_text="Required if creator does not list other employees", ) anything_else = models.TextField( null=True, blank=True, - help_text="Anything else?", + verbose_name="Additional details", + ) + + cisa_representative_email = models.EmailField( + null=True, + blank=True, + verbose_name="CISA regional representative", + max_length=320, ) is_policy_acknowledged = models.BooleanField( @@ -237,7 +231,6 @@ class DomainInformation(TimeStampedModel): notes = models.TextField( null=True, blank=True, - help_text="Notes about the request", ) def __str__(self): diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 5b8fc59e3..75fbadc3e 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -16,12 +16,21 @@ from .utility.time_stamped_model import TimeStampedModel from ..utility.email import send_templated_email, EmailSendingError from itertools import chain +from auditlog.models import AuditlogHistoryField # type: ignore + logger = logging.getLogger(__name__) class DomainRequest(TimeStampedModel): """A registrant's domain request for a new domain.""" + # https://django-auditlog.readthedocs.io/en/latest/usage.html#object-history + # If we note any performace degradation due to this addition, + # we can query the auditlogs table in admin.py and add the results to + # extra_context in the change_view method for DomainRequestAdmin. + # This is the more straightforward way so trying it first. + history = AuditlogHistoryField() + # Constants for choice fields class DomainRequestStatus(models.TextChoices): STARTED = "started", "Started" @@ -464,6 +473,7 @@ class DomainRequest(TimeStampedModel): "registrar.User", on_delete=models.PROTECT, related_name="domain_requests_created", + help_text="Person who submitted the domain request; will not receive email updates", ) investigator = models.ForeignKey( @@ -481,14 +491,12 @@ class DomainRequest(TimeStampedModel): choices=OrganizationChoices.choices, null=True, blank=True, - help_text="Type of organization", ) is_election_board = models.BooleanField( null=True, blank=True, verbose_name="election office", - help_text="Is your organization an election office?", ) # TODO - Ticket #1911: stub this data from DomainRequest @@ -497,30 +505,26 @@ class DomainRequest(TimeStampedModel): choices=OrgChoicesElectionOffice.choices, null=True, blank=True, - help_text="Type of organization - Election office", + help_text='"Election" appears after the org type if it\'s an election office.', ) federally_recognized_tribe = models.BooleanField( null=True, - help_text="Is the tribe federally recognized", ) state_recognized_tribe = models.BooleanField( null=True, - help_text="Is the tribe recognized by a state", ) tribe_name = models.CharField( null=True, blank=True, - help_text="Name of tribe", ) federal_agency = models.CharField( choices=AGENCY_CHOICES, null=True, blank=True, - help_text="Federal agency", ) federal_type = models.CharField( @@ -528,32 +532,27 @@ class DomainRequest(TimeStampedModel): choices=BranchChoices.choices, null=True, blank=True, - help_text="Federal government branch", ) organization_name = models.CharField( null=True, blank=True, - help_text="Organization name", db_index=True, ) address_line1 = models.CharField( null=True, blank=True, - help_text="Street address", verbose_name="Address line 1", ) address_line2 = models.CharField( null=True, blank=True, - help_text="Street address line 2 (optional)", verbose_name="Address line 2", ) city = models.CharField( null=True, blank=True, - help_text="City", ) state_territory = models.CharField( max_length=2, @@ -561,26 +560,23 @@ class DomainRequest(TimeStampedModel): null=True, blank=True, verbose_name="state / territory", - help_text="State, territory, or military post", ) zipcode = models.CharField( max_length=10, null=True, blank=True, verbose_name="zip code", - help_text="Zip code", db_index=True, ) urbanization = models.CharField( null=True, blank=True, - help_text="Urbanization (required for Puerto Rico only)", + help_text="Required for Puerto Rico only", ) about_your_organization = models.TextField( null=True, blank=True, - help_text="Information about your organization", ) authorizing_official = models.ForeignKey( @@ -603,7 +599,7 @@ class DomainRequest(TimeStampedModel): "Domain", null=True, blank=True, - help_text="The approved domain", + help_text="Domain associated with this request; will be blank until request is approved", related_name="domain_request", on_delete=models.SET_NULL, ) @@ -612,7 +608,6 @@ class DomainRequest(TimeStampedModel): "DraftDomain", null=True, blank=True, - help_text="The requested domain", related_name="domain_request", on_delete=models.PROTECT, ) @@ -621,6 +616,7 @@ class DomainRequest(TimeStampedModel): "registrar.Website", blank=True, related_name="alternatives+", + help_text="Other domain names the creator provided for consideration", ) # This is the contact information provided by the domain requestor. The @@ -631,12 +627,12 @@ class DomainRequest(TimeStampedModel): blank=True, related_name="submitted_domain_requests", on_delete=models.PROTECT, + help_text='Person listed under "your contact information" in the request form; will receive email updates', ) purpose = models.TextField( null=True, blank=True, - help_text="Purpose of your domain", ) other_contacts = models.ManyToManyField( @@ -649,13 +645,38 @@ class DomainRequest(TimeStampedModel): no_other_contacts_rationale = models.TextField( null=True, blank=True, - help_text="Reason for listing no additional contacts", + help_text="Required if creator does not list other employees", ) anything_else = models.TextField( null=True, blank=True, - help_text="Anything else?", + verbose_name="Additional details", + ) + + # This is a drop-in replacement for a has_anything_else_text() function. + # In order to track if the user has clicked the yes/no field (while keeping a none default), we need + # a tertiary state. We should not display this in /admin. + has_anything_else_text = models.BooleanField( + null=True, + blank=True, + help_text="Determines if the user has a anything_else or not", + ) + + cisa_representative_email = models.EmailField( + null=True, + blank=True, + verbose_name="CISA regional representative", + max_length=320, + ) + + # This is a drop-in replacement for an has_cisa_representative() function. + # In order to track if the user has clicked the yes/no field (while keeping a none default), we need + # a tertiary state. We should not display this in /admin. + has_cisa_representative = models.BooleanField( + null=True, + blank=True, + help_text="Determines if the user has a representative email or not", ) is_policy_acknowledged = models.BooleanField( @@ -676,7 +697,6 @@ class DomainRequest(TimeStampedModel): notes = models.TextField( null=True, blank=True, - help_text="Notes about this request", ) def sync_organization_type(self): @@ -711,8 +731,33 @@ class DomainRequest(TimeStampedModel): def save(self, *args, **kwargs): """Save override for custom properties""" self.sync_organization_type() + self.sync_yes_no_form_fields() + super().save(*args, **kwargs) + def sync_yes_no_form_fields(self): + """Some yes/no forms use a db field to track whether it was checked or not. + We handle that here for def save(). + """ + + # This ensures that if we have prefilled data, the form is prepopulated + if self.cisa_representative_email is not None: + self.has_cisa_representative = self.cisa_representative_email != "" + + # This check is required to ensure that the form doesn't start out checked + if self.has_cisa_representative is not None: + self.has_cisa_representative = ( + self.cisa_representative_email != "" and self.cisa_representative_email is not None + ) + + # This ensures that if we have prefilled data, the form is prepopulated + if self.anything_else is not None: + self.has_anything_else_text = self.anything_else != "" + + # This check is required to ensure that the form doesn't start out checked. + if self.has_anything_else_text is not None: + self.has_anything_else_text = self.anything_else != "" and self.anything_else is not None + def __str__(self): try: if self.requested_domain and self.requested_domain.name: @@ -1051,6 +1096,16 @@ class DomainRequest(TimeStampedModel): """Does this domain request have other contacts listed?""" return self.other_contacts.exists() + def has_additional_details(self) -> bool: + """Combines the has_anything_else_text and has_cisa_representative fields, + then returns if this domain request has either of them.""" + # Split out for linter + has_details = False + if self.has_anything_else_text or self.has_cisa_representative: + has_details = True + + return has_details + def is_federal(self) -> Union[bool, None]: """Is this domain request for a federal agency? diff --git a/src/registrar/models/host.py b/src/registrar/models/host.py index 2fb4b980b..97ee1f8aa 100644 --- a/src/registrar/models/host.py +++ b/src/registrar/models/host.py @@ -22,14 +22,13 @@ class Host(TimeStampedModel): default=None, # prevent saving without a value unique=False, verbose_name="host name", - help_text="Fully qualified domain name", ) domain = models.ForeignKey( "registrar.Domain", on_delete=models.PROTECT, related_name="host", # access this Host via the Domain as `domain.host` - help_text="Domain to which this host belongs", + help_text="Domain associated with this host", ) def __str__(self): diff --git a/src/registrar/models/host_ip.py b/src/registrar/models/host_ip.py index 216ad9eca..e0c5bb1c9 100644 --- a/src/registrar/models/host_ip.py +++ b/src/registrar/models/host_ip.py @@ -21,12 +21,11 @@ class HostIP(TimeStampedModel): default=None, # prevent saving without a value validators=[validate_ipv46_address], verbose_name="IP address", - help_text="IP address", ) host = models.ForeignKey( "registrar.Host", on_delete=models.PROTECT, related_name="ip", # access this HostIP via the Host as `host.ip` - help_text="Host to which this IP address belongs", + help_text="IP associated with this host", ) diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index cf027e70c..5e4c88f63 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -2,6 +2,7 @@ import logging from django.contrib.auth.models import AbstractUser from django.db import models +from django.db.models import Q from registrar.models.user_domain_role import UserDomainRole @@ -23,6 +24,28 @@ class User(AbstractUser): but can be customized later. """ + class VerificationTypeChoices(models.TextChoices): + """ + Users achieve access to our system in a few different ways. + These choices reflect those pathways. + + Overview of verification types: + - GRANDFATHERED: User exists in the `TransitionDomain` table + - VERIFIED_BY_STAFF: User exists in the `VerifiedByStaff` table + - INVITED: User exists in the `DomainInvitation` table + - REGULAR: User was verified through IAL2 + - FIXTURE_USER: User was created by fixtures + """ + + GRANDFATHERED = "grandfathered", "Legacy user" + VERIFIED_BY_STAFF = "verified_by_staff", "Verified by staff" + REGULAR = "regular", "Verified by Login.gov" + INVITED = "invited", "Invited by a domain manager" + # We need a type for fixture users (rather than using verified by staff) + # because those users still do get "verified" through normal means + # after they login. + FIXTURE_USER = "fixture_user", "Created by fixtures" + # #### Constants for choice fields #### RESTRICTED = "restricted" STATUS_CHOICES = ((RESTRICTED, RESTRICTED),) @@ -34,6 +57,7 @@ class User(AbstractUser): null=True, # Allow the field to be null blank=True, # Allow the field to be blank verbose_name="user status", + help_text='Users in "restricted" status cannot make updates in the registrar or start a new request.', ) domains = models.ManyToManyField( @@ -49,6 +73,13 @@ class User(AbstractUser): db_index=True, ) + verification_type = models.CharField( + choices=VerificationTypeChoices.choices, + null=True, + blank=True, + help_text="The means through which this user was verified", + ) + def __str__(self): # this info is pulled from Login.gov if self.first_name or self.last_name: @@ -113,23 +144,61 @@ class User(AbstractUser): except Exception as err: raise err - # A new incoming user who is a domain manager for one of the domains - # that we inputted from Verisign (that is, their email address appears - # in the username field of a TransitionDomain) - if TransitionDomain.objects.filter(username=email).exists(): - return False + # We can't set the verification type here because the user may not + # always exist at this point. We do it down the line. + verification_type = cls.get_verification_type_from_email(email) - # New users flagged by Staff to bypass ial2 - if VerifiedByStaff.objects.filter(email=email).exists(): - return False + # Checks if the user needs verification. + # The user needs identity verification if they don't meet + # any special criteria, i.e. we are validating them "regularly" + return verification_type == cls.VerificationTypeChoices.REGULAR - # A new incoming user who is being invited to be a domain manager (that is, - # their email address is in DomainInvitation for an invitation that is not yet "retrieved"). - invited = DomainInvitation.DomainInvitationStatus.INVITED - if DomainInvitation.objects.filter(email=email, status=invited).exists(): - return False + def set_user_verification_type(self): + """ + Given pre-existing data from TransitionDomain, VerifiedByStaff, and DomainInvitation, + set the verification "type" defined in VerificationTypeChoices. + """ + email_or_username = self.email if self.email else self.username + retrieved = DomainInvitation.DomainInvitationStatus.RETRIEVED + verification_type = self.get_verification_type_from_email(email_or_username, invitation_status=retrieved) - return True + # An existing user may have been invited to a domain after they got verified. + # We need to check for this condition. + if verification_type == User.VerificationTypeChoices.INVITED: + invitation = ( + DomainInvitation.objects.filter(email=email_or_username, status=retrieved) + .order_by("created_at") + .first() + ) + + # If you joined BEFORE the oldest invitation was created, then you were verified normally. + # (See logic in get_verification_type_from_email) + if not invitation and self.date_joined < invitation.created_at: + verification_type = User.VerificationTypeChoices.REGULAR + + self.verification_type = verification_type + + @classmethod + def get_verification_type_from_email(cls, email, invitation_status=DomainInvitation.DomainInvitationStatus.INVITED): + """Retrieves the verification type based off of a provided email address""" + + verification_type = None + if TransitionDomain.objects.filter(Q(username=email) | Q(email=email)).exists(): + # A new incoming user who is a domain manager for one of the domains + # that we inputted from Verisign (that is, their email address appears + # in the username field of a TransitionDomain) + verification_type = cls.VerificationTypeChoices.GRANDFATHERED + elif VerifiedByStaff.objects.filter(email=email).exists(): + # New users flagged by Staff to bypass ial2 + verification_type = cls.VerificationTypeChoices.VERIFIED_BY_STAFF + elif DomainInvitation.objects.filter(email=email, status=invitation_status).exists(): + # A new incoming user who is being invited to be a domain manager (that is, + # their email address is in DomainInvitation for an invitation that is not yet "retrieved"). + verification_type = cls.VerificationTypeChoices.INVITED + else: + verification_type = cls.VerificationTypeChoices.REGULAR + + return verification_type def check_domain_invitations_on_login(self): """When a user first arrives on the site, we need to retrieve any domain diff --git a/src/registrar/models/utility/generic_helper.py b/src/registrar/models/utility/generic_helper.py index 12ec85e57..d1d890da4 100644 --- a/src/registrar/models/utility/generic_helper.py +++ b/src/registrar/models/utility/generic_helper.py @@ -167,7 +167,7 @@ class CreateOrUpdateOrganizationTypeHelper: # There is no avenue for this to occur in the UI, # as such - this can only occur if the object is initialized in this way. # Or if there are pre-existing data. - logger.warning( + logger.debug( "create_or_update_organization_type() -> is_election_board " f"cannot exist for {generic_org_type}. Setting to None." ) diff --git a/src/registrar/models/verified_by_staff.py b/src/registrar/models/verified_by_staff.py index a6d861504..c09dce822 100644 --- a/src/registrar/models/verified_by_staff.py +++ b/src/registrar/models/verified_by_staff.py @@ -9,7 +9,6 @@ class VerifiedByStaff(TimeStampedModel): email = models.EmailField( null=False, blank=False, - help_text="Email", db_index=True, ) @@ -19,12 +18,12 @@ class VerifiedByStaff(TimeStampedModel): blank=True, on_delete=models.SET_NULL, related_name="verifiedby_user", + help_text="Person who verified this user", ) notes = models.TextField( null=False, blank=False, - help_text="Notes", ) class Meta: diff --git a/src/registrar/models/website.py b/src/registrar/models/website.py index 29739b8ee..a062fe248 100644 --- a/src/registrar/models/website.py +++ b/src/registrar/models/website.py @@ -12,7 +12,7 @@ class Website(TimeStampedModel): website = models.CharField( max_length=255, null=False, - help_text="", + help_text="An alternative domain or current website listed on a domain request", ) def __str__(self) -> str: diff --git a/src/registrar/templates/admin/base_site.html b/src/registrar/templates/admin/base_site.html index 58843421a..dd680cec5 100644 --- a/src/registrar/templates/admin/base_site.html +++ b/src/registrar/templates/admin/base_site.html @@ -23,6 +23,7 @@ + {% endblock %} {% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} diff --git a/src/registrar/templates/admin/fieldset.html b/src/registrar/templates/admin/fieldset.html index 8b8972e08..19c5db294 100644 --- a/src/registrar/templates/admin/fieldset.html +++ b/src/registrar/templates/admin/fieldset.html @@ -6,9 +6,23 @@ It is not inherently customizable on its own, so we can modify this instead. https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/includes/fieldset.html {% endcomment %}
- {% if fieldset.name %}

{{ fieldset.name }}

{% endif %} + {% if fieldset.name %} + {# Customize the markup for the collapse toggle #} + {% if 'collapse--dotgov' in fieldset.classes %} + + {{ fieldset.description }} + {% else %} +

{{ fieldset.name }}

+ {% endif %} + {% endif %} - {% if fieldset.description %} + {# Customize the markup for the collapse toggle: Do not show a description for the collapse fieldsets, instead we're using the description as a screen reader only legend #} + {% if fieldset.description and 'collapse--dotgov' not in fieldset.classes %}
{{ fieldset.description|safe }}
{% endif %} diff --git a/src/registrar/templates/django/admin/domain_change_form.html b/src/registrar/templates/django/admin/domain_change_form.html index 44fe6851b..80888423d 100644 --- a/src/registrar/templates/django/admin/domain_change_form.html +++ b/src/registrar/templates/django/admin/domain_change_form.html @@ -33,7 +33,10 @@ {% endif %} - {{ block.super }} + + {% for fieldset in adminform %} + {% include "django/admin/includes/domain_fieldset.html" with state_help_message=state_help_message %} + {% endfor %} {% endblock %} {% block submit_buttons_bottom %} diff --git a/src/registrar/templates/django/admin/includes/contact_detail_list.html b/src/registrar/templates/django/admin/includes/contact_detail_list.html index 0ac9c4c49..3b49e62a4 100644 --- a/src/registrar/templates/django/admin/includes/contact_detail_list.html +++ b/src/registrar/templates/django/admin/includes/contact_detail_list.html @@ -3,7 +3,7 @@
{% if show_formatted_name %} - {% if contact.get_formatted_name %} + {% if user.get_formatted_name %} {{ user.get_formatted_name }}
{% else %} None
@@ -47,7 +47,12 @@ {% else %} None
{% endif %} + {% else %} - No additional contact information found. + No additional contact information found.
+ {% endif %} + + {% if user_verification_type %} + {{ user_verification_type }} {% endif %}
diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html index fb7303352..c7bb6325e 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -4,6 +4,7 @@ {% comment %} This is using a custom implementation fieldset.html (see admin/fieldset.html) {% endcomment %} + {% block field_readonly %} {% with all_contacts=original_object.other_contacts.all %} {% if field.field.name == "other_contacts" %} @@ -66,14 +67,42 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% endblock field_readonly %} {% block after_help_text %} - {% if field.field.name == "creator" %} + {% if field.field.name == "status" and original_object.history.count > 0 %}
+ +
+ + + + + + + + + + {% for log_entry in original_object.history.all %} + {% for key, value in log_entry.changes_display_dict.items %} + {% if key == "status" %} + + + + + + {% endif %} + {% endfor %} + {% endfor %} + +
StatusUserChanged at
{{ value.1|default:"None" }}{{ log_entry.actor|default:"None" }}{{ log_entry.timestamp|default:"None" }}
+
+
+ {% elif field.field.name == "creator" %} +
- {% include "django/admin/includes/contact_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly %} + {% include "django/admin/includes/contact_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly user_verification_type=original_object.creator.get_verification_type_display%}
{% include "django/admin/includes/user_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly %} {% elif field.field.name == "submitter" %} -
+
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.submitter no_title_top_padding=field.is_readonly %}
diff --git a/src/registrar/templates/django/admin/includes/domain_fieldset.html b/src/registrar/templates/django/admin/includes/domain_fieldset.html new file mode 100644 index 000000000..d5f5bc1af --- /dev/null +++ b/src/registrar/templates/django/admin/includes/domain_fieldset.html @@ -0,0 +1,21 @@ +{% extends "admin/fieldset.html" %} +{% load static url_helpers %} + + +{% block field_readonly %} + {% if field.field.name == "state" %} +
{{ domain_state }}
+ {% else %} +
{{ field.contents }}
+ {% endif %} +{% endblock %} + +{% block help_text %} +
+ {% if field.field.name == "state" %} +
{{ state_help_message }}
+ {% else %} +
{{ field.field.help_text|safe }}
+ {% endif %} +
+{% endblock help_text %} diff --git a/src/registrar/templates/domain_request_additional_details.html b/src/registrar/templates/domain_request_additional_details.html new file mode 100644 index 000000000..e13d3c7ee --- /dev/null +++ b/src/registrar/templates/domain_request_additional_details.html @@ -0,0 +1,55 @@ +{% extends 'domain_request_form.html' %} +{% load static field_helpers %} + +{% block form_instructions %} + These questions are required (*). +{% endblock %} + +{% block form_required_fields_help_text %} +{# commented out so it does not appear at this point on this page #} +{% endblock %} + + +{% block form_fields %} +
+ +

Are you working with a CISA regional representative on your domain request?

+

.gov is managed by the Cybersecurity and Infrastructure Security Agency. CISA has 10 regions that some organizations choose to work with. Regional representatives use titles like protective security advisors, cyber security advisors, or election security advisors.

+
+ + + {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} + {% input_with_errors forms.0.has_cisa_representative %} + {% endwith %} + {# forms.0 is a small yes/no form that toggles the visibility of "cisa representative" formset #} + +
+ +
+ {% input_with_errors forms.1.cisa_representative_email %} + {# forms.1 is a form for inputting the e-mail of a cisa representative #} + +
+ + +
+ +

Is there anything else you’d like us to know about your domain request?

+
+ + + {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} + {% input_with_errors forms.2.has_anything_else_text %} + {% endwith %} + {# forms.2 is a small yes/no form that toggles the visibility of "cisa representative" formset #} + +
+ +
+ {% with attr_maxlength=2000 add_label_class="usa-sr-only" %} + {% input_with_errors forms.3.anything_else %} + {% endwith %} + {# forms.3 is a form for inputting the e-mail of a cisa representative #} + +
+{% endblock %} diff --git a/src/registrar/templates/domain_request_anything_else.html b/src/registrar/templates/domain_request_anything_else.html deleted file mode 100644 index dbeb10cac..000000000 --- a/src/registrar/templates/domain_request_anything_else.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends 'domain_request_form.html' %} -{% load field_helpers %} - -{% block form_instructions %} -

Is there anything else you’d like us to know about your domain request?

- -

This question is optional.

-{% endblock %} - -{% block form_required_fields_help_text %} -{# commented out so it does not appear on this page #} -{% endblock %} - - -{% block form_fields %} - {% with attr_maxlength=2000 add_label_class="usa-sr-only" %} - {% input_with_errors forms.0.anything_else %} - {% endwith %} -{% endblock %} diff --git a/src/registrar/templates/domain_request_review.html b/src/registrar/templates/domain_request_review.html index 227fc0cea..5f359e95f 100644 --- a/src/registrar/templates/domain_request_review.html +++ b/src/registrar/templates/domain_request_review.html @@ -155,11 +155,20 @@ {% endif %} - {% if step == Step.ANYTHING_ELSE %} + {% if step == Step.ADDITIONAL_DETAILS %} {% namespaced_url 'domain-request' step as domain_request_url %} - {% with title=form_titles|get_item:step value=domain_request.anything_else|default:"No" %} - {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} + {% with title=form_titles|get_item:step value=domain_request.requested_domain.name|default:"Incomplete" %} + {% include "includes/summary_item.html" with title=title sub_header_text='CISA regional representative' value=domain_request.cisa_representative_email heading_level=heading_level editable=True edit_link=domain_request_url custom_text_for_value_none='No' %} {% endwith %} + +

Anything else

+
    + {% if domain_request.anything_else %} + {{domain_request.anything_else}} + {% else %} + No + {% endif %} +
{% endif %} diff --git a/src/registrar/templates/domain_request_status.html b/src/registrar/templates/domain_request_status.html index d3c1eab6d..0ea16e3a3 100644 --- a/src/registrar/templates/domain_request_status.html +++ b/src/registrar/templates/domain_request_status.html @@ -115,8 +115,19 @@ {% else %} {% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.no_other_contacts_rationale heading_level=heading_level %} {% endif %} - - {% include "includes/summary_item.html" with title='Anything else?' value=DomainRequest.anything_else|default:"No" heading_level=heading_level %} + + {# We always show this field even if None #} + {% if DomainRequest %} + {% include "includes/summary_item.html" with title='Additional details' sub_header_text='CISA regional representative' value=DomainRequest.cisa_representative_email custom_text_for_value_none='No' heading_level=heading_level %} +

Anything else

+
    + {% if DomainRequest.anything_else %} + {{DomainRequest.anything_else}} + {% else %} + No + {% endif %} +
+ {% endif %} {% endwith %}
diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html index ea9276b9f..6cc17817a 100644 --- a/src/registrar/templates/home.html +++ b/src/registrar/templates/home.html @@ -26,7 +26,7 @@

Domains

{% if domains %} - +
@@ -98,13 +98,21 @@ > {% else %}

You don't have any registered domains.

+

+ + + Why don't I see my domain when I sign in to the registrar? + +

{% endif %}

Domain requests

{% if domain_requests %} -
Your registered domains
+
diff --git a/src/registrar/templates/includes/summary_item.html b/src/registrar/templates/includes/summary_item.html index 7c0d801ad..a2f328e1f 100644 --- a/src/registrar/templates/includes/summary_item.html +++ b/src/registrar/templates/includes/summary_item.html @@ -21,6 +21,9 @@ {% else %} {% endif %} + {% if sub_header_text %} +

{{ sub_header_text }}

+ {% endif %} {% if address %} {% include "includes/organization_address.html" with organization=value %} {% elif contact %} @@ -39,6 +42,10 @@ {% endfor %} + {% elif custom_text_for_value_none %} +

+ {{ custom_text_for_value_none }} +

{% else %}

None @@ -92,6 +99,8 @@

{% if value %} {{ value }} + {% elif custom_text_for_value_none %} + {{ custom_text_for_value_none }} {% else %} None {% endif %} diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 6dd88c1c1..c2f11b85d 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -789,6 +789,7 @@ def create_ready_domain(): return domain +# TODO in 1793: Remove the federal agency/updated federal agency fields def completed_domain_request( has_other_contacts=True, has_current_website=True, @@ -803,6 +804,8 @@ def completed_domain_request( generic_org_type="federal", is_election_board=False, organization_type=None, + federal_agency=None, + updated_federal_agency=None, ): """A completed domain request.""" if not user: @@ -839,6 +842,7 @@ def completed_domain_request( last_name="Bob", is_staff=True, ) + domain_request_kwargs = dict( generic_org_type=generic_org_type, is_election_board=is_election_board, @@ -856,6 +860,8 @@ def completed_domain_request( creator=user, status=status, investigator=investigator, + federal_agency=federal_agency, + updated_federal_agency=updated_federal_agency, ) if has_about_your_organization: domain_request_kwargs["about_your_organization"] = "e-Government" @@ -864,7 +870,6 @@ def completed_domain_request( if organization_type: domain_request_kwargs["organization_type"] = organization_type - domain_request, _ = DomainRequest.objects.get_or_create(**domain_request_kwargs) if has_other_contacts: diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index bca1a94cb..cf00994be 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -18,6 +18,7 @@ from registrar.admin import ( AuditedAdmin, ContactAdmin, DomainInformationAdmin, + MyHostAdmin, UserDomainRoleAdmin, VerifiedByStaffAdmin, ) @@ -76,6 +77,13 @@ class TestDomainAdmin(MockEppLib, WebTest): self.app.set_user(self.superuser.username) self.client.force_login(self.superuser) + # Add domain data + self.ready_domain, _ = Domain.objects.get_or_create(name="fakeready.gov", state=Domain.State.READY) + self.unknown_domain, _ = Domain.objects.get_or_create(name="fakeunknown.gov", state=Domain.State.UNKNOWN) + self.dns_domain, _ = Domain.objects.get_or_create(name="fakedns.gov", state=Domain.State.DNS_NEEDED) + self.hold_domain, _ = Domain.objects.get_or_create(name="fakehold.gov", state=Domain.State.ON_HOLD) + self.deleted_domain, _ = Domain.objects.get_or_create(name="fakedeleted.gov", state=Domain.State.DELETED) + # Contains some test tools self.test_helper = GenericTestHelper( factory=self.factory, @@ -159,6 +167,68 @@ class TestDomainAdmin(MockEppLib, WebTest): # Test for the copy link self.assertContains(response, "usa-button__clipboard") + @less_console_noise_decorator + def test_helper_text(self): + """ + Tests for the correct helper text on this page + """ + + # Create a ready domain with a preset expiration date + domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) + + 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) + + # These should exist in the response + expected_values = [ + ("expiration_date", "Date the domain expires in the registry"), + ("first_ready_at", 'Date when this domain first moved into "ready" state; date will never change'), + ("deleted_at", 'Will appear blank unless the domain is in "deleted" state'), + ] + self.test_helper.assert_response_contains_distinct_values(response, expected_values) + + @less_console_noise_decorator + def test_helper_text_state(self): + """ + Tests for the correct state helper text on this page + """ + + # We don't need to check for all text content, just a portion of it + expected_unknown_domain_message = "The creator of the associated domain request has not logged in to" + expected_dns_message = "Before this domain can be used, name server addresses need" + expected_hold_message = "While on hold, this domain" + expected_deleted_message = "This domain was permanently removed from the registry." + expected_messages = [ + (self.ready_domain, "This domain has name servers and is ready for use."), + (self.unknown_domain, expected_unknown_domain_message), + (self.dns_domain, expected_dns_message), + (self.hold_domain, expected_hold_message), + (self.deleted_domain, expected_deleted_message), + ] + + p = "adminpass" + self.client.login(username="superuser", password=p) + for domain, message in expected_messages: + with self.subTest(domain_state=domain.state): + response = self.client.get( + "/admin/registrar/domain/{}/change/".format(domain.id), + ) + + # Make sure the page loaded, and that we're on the right page + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + + # Check that the right help text exists + self.assertContains(response, message) + @patch("registrar.admin.DomainAdmin._get_current_date", return_value=date(2024, 1, 1)) def test_extend_expiration_date_button(self, mock_date_today): """ @@ -782,6 +852,152 @@ class TestDomainRequestAdmin(MockEppLib): ) self.mock_client = MockSESClient() + @less_console_noise_decorator + def test_helper_text(self): + """ + Tests for the correct helper text on this page + """ + + # Create a fake domain request and domain + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + p = "adminpass" + self.client.login(username="superuser", password=p) + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.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_request.requested_domain.name) + + # These should exist in the response + expected_values = [ + ("creator", "Person who submitted the domain request; will not receive email updates"), + ( + "submitter", + 'Person listed under "your contact information" in the request form; will receive email updates', + ), + ("approved_domain", "Domain associated with this request; will be blank until request is approved"), + ("no_other_contacts_rationale", "Required if creator does not list other employees"), + ("alternative_domains", "Other domain names the creator provided for consideration"), + ("no_other_contacts_rationale", "Required if creator does not list other employees"), + ("Urbanization", "Required for Puerto Rico only"), + ] + self.test_helper.assert_response_contains_distinct_values(response, expected_values) + + @less_console_noise_decorator + def test_status_logs(self): + """ + Tests that the status changes are shown in a table on the domain request change form, + accurately and in chronological order. + """ + + # Create a fake domain request and domain + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.STARTED) + + p = "adminpass" + self.client.login(username="superuser", password=p) + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.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_request.requested_domain.name) + + # Table will contain one row for Started + self.assertContains(response, "

", count=1) + self.assertNotContains(response, "") + + domain_request.submit() + domain_request.save() + + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), + follow=True, + ) + + # Table will contain and extra row for Submitted + self.assertContains(response, "", count=1) + self.assertContains(response, "", count=1) + + domain_request.in_review() + domain_request.save() + + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), + follow=True, + ) + + # Table will contain and extra row for In review + self.assertContains(response, "", count=1) + self.assertContains(response, "", count=1) + self.assertContains(response, "", count=1) + + domain_request.action_needed() + domain_request.save() + + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), + follow=True, + ) + + # Table will contain and extra row for Action needed + self.assertContains(response, "", count=1) + self.assertContains(response, "", count=1) + self.assertContains(response, "", count=1) + self.assertContains(response, "", count=1) + + domain_request.in_review() + domain_request.save() + + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), + follow=True, + ) + + # Define the expected sequence of status changes + expected_status_changes = [ + "", + "", + "", + "", + "", + ] + + # Test for the order of status changes + for status_change in expected_status_changes: + self.assertContains(response, status_change, html=True) + + # Table now contains 2 rows for Approved + self.assertContains(response, "", count=1) + self.assertContains(response, "", count=1) + self.assertContains(response, "", count=2) + self.assertContains(response, "", count=1) + + def test_collaspe_toggle_button_markup(self): + """ + Tests for the correct collapse toggle button markup + """ + + # Create a fake domain request and domain + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + p = "adminpass" + self.client.login(username="superuser", password=p) + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.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_request.requested_domain.name) + self.test_helper.assertContains(response, "Show details") + @less_console_noise_decorator def test_analyst_can_see_and_edit_alternative_domain(self): """Tests if an analyst can still see and edit the alternative domain field""" @@ -1889,6 +2105,9 @@ class TestDomainRequestAdmin(MockEppLib): "purpose", "no_other_contacts_rationale", "anything_else", + "has_anything_else_text", + "cisa_representative_email", + "has_cisa_representative", "is_policy_acknowledged", "submission_date", "notes", @@ -1920,6 +2139,7 @@ class TestDomainRequestAdmin(MockEppLib): "no_other_contacts_rationale", "anything_else", "is_policy_acknowledged", + "cisa_representative_email", ] self.assertEqual(readonly_fields, expected_fields) @@ -2315,6 +2535,54 @@ class DomainInvitationAdminTest(TestCase): self.assertContains(response, retrieved_html, count=1) +class TestHostAdmin(TestCase): + def setUp(self): + """Setup environment for a mock admin user""" + super().setUp() + self.site = AdminSite() + self.factory = RequestFactory() + self.admin = MyHostAdmin(model=Host, admin_site=self.site) + self.client = Client(HTTP_HOST="localhost:8080") + self.superuser = create_superuser() + self.test_helper = GenericTestHelper( + factory=self.factory, + user=self.superuser, + admin=self.admin, + url="/admin/registrar/Host/", + model=Host, + ) + + def tearDown(self): + super().tearDown() + Host.objects.all().delete() + Domain.objects.all().delete() + + @less_console_noise_decorator + def test_helper_text(self): + """ + Tests for the correct helper text on this page + """ + domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) + # Create a fake host + host, _ = Host.objects.get_or_create(name="ns1.test.gov", domain=domain) + + p = "adminpass" + self.client.login(username="superuser", password=p) + response = self.client.get( + "/admin/registrar/host/{}/change/".format(host.pk), + follow=True, + ) + + # Make sure the page loaded + self.assertEqual(response.status_code, 200) + + # These should exist in the response + expected_values = [ + ("domain", "Domain associated with this host"), + ] + self.test_helper.assert_response_contains_distinct_values(response, expected_values) + + class TestDomainInformationAdmin(TestCase): def setUp(self): """Setup environment for a mock admin user""" @@ -2367,6 +2635,38 @@ class TestDomainInformationAdmin(TestCase): Contact.objects.all().delete() User.objects.all().delete() + @less_console_noise_decorator + def test_helper_text(self): + """ + Tests for the correct helper text on this page + """ + + # Create a fake domain request and domain + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + domain_request.approve() + domain_info = DomainInformation.objects.filter(domain=domain_request.approved_domain).get() + + 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) + + # These should exist in the response + expected_values = [ + ("creator", "Person who submitted the domain request"), + ("submitter", 'Person listed under "your contact information" in the request form'), + ("domain_request", "Request associated with this domain"), + ("no_other_contacts_rationale", "Required if creator does not list other employees"), + ("urbanization", "Required for Puerto Rico only"), + ] + self.test_helper.assert_response_contains_distinct_values(response, expected_values) + @less_console_noise_decorator def test_other_contacts_has_readonly_link(self): """Tests if the readonly other_contacts field has links""" @@ -2711,7 +3011,7 @@ class UserDomainRoleAdminTest(TestCase): self.assertContains(response, "Joe Jones AntarcticPolarBears@example.com", count=1) -class ListHeaderAdminTest(TestCase): +class TestListHeaderAdmin(TestCase): def setUp(self): self.site = AdminSite() self.factory = RequestFactory() @@ -2784,10 +3084,43 @@ class ListHeaderAdminTest(TestCase): User.objects.all().delete() -class MyUserAdminTest(TestCase): +class TestMyUserAdmin(TestCase): def setUp(self): admin_site = AdminSite() self.admin = MyUserAdmin(model=get_user_model(), admin_site=admin_site) + self.client = Client(HTTP_HOST="localhost:8080") + self.superuser = create_superuser() + self.test_helper = GenericTestHelper(admin=self.admin) + + def tearDown(self): + super().tearDown() + User.objects.all().delete() + + @less_console_noise_decorator + def test_helper_text(self): + """ + Tests for the correct helper text on this page + """ + user = create_user() + + p = "adminpass" + self.client.login(username="superuser", password=p) + response = self.client.get( + "/admin/registrar/user/{}/change/".format(user.pk), + follow=True, + ) + + # Make sure the page loaded + self.assertEqual(response.status_code, 200) + + # These should exist in the response + expected_values = [ + ("password", "Raw passwords are not stored, so they will not display here."), + ("status", 'Users in "restricted" status cannot make updates in the registrar or start a new request.'), + ("is_staff", "Designates whether the user can log in to this admin site"), + ("is_superuser", "For development purposes only; provides superuser access on the database level"), + ] + self.test_helper.assert_response_contains_distinct_values(response, expected_values) def test_list_display_without_username(self): with less_console_noise(): @@ -2809,8 +3142,9 @@ class MyUserAdminTest(TestCase): def test_get_fieldsets_superuser(self): with less_console_noise(): request = self.client.request().wsgi_request - request.user = create_superuser() + request.user = self.superuser fieldsets = self.admin.get_fieldsets(request) + expected_fieldsets = super(MyUserAdmin, self.admin).get_fieldsets(request) self.assertEqual(fieldsets, expected_fieldsets) @@ -2820,16 +3154,21 @@ class MyUserAdminTest(TestCase): request.user = create_user() fieldsets = self.admin.get_fieldsets(request) expected_fieldsets = ( - (None, {"fields": ("password", "status")}), + ( + None, + { + "fields": ( + "status", + "verification_type", + ) + }, + ), ("Personal Info", {"fields": ("first_name", "last_name", "email")}), ("Permissions", {"fields": ("is_active", "groups")}), ("Important dates", {"fields": ("last_login", "date_joined")}), ) self.assertEqual(fieldsets, expected_fieldsets) - def tearDown(self): - User.objects.all().delete() - class AuditedAdminTest(TestCase): def setUp(self): @@ -3303,10 +3642,43 @@ class ContactAdminTest(TestCase): User.objects.all().delete() -class VerifiedByStaffAdminTestCase(TestCase): +class TestVerifiedByStaffAdmin(TestCase): def setUp(self): + super().setUp() + self.site = AdminSite() self.superuser = create_superuser() + self.admin = VerifiedByStaffAdmin(model=VerifiedByStaff, admin_site=self.site) self.factory = RequestFactory() + self.client = Client(HTTP_HOST="localhost:8080") + self.test_helper = GenericTestHelper(admin=self.admin) + + def tearDown(self): + super().tearDown() + VerifiedByStaff.objects.all().delete() + User.objects.all().delete() + + @less_console_noise_decorator + def test_helper_text(self): + """ + Tests for the correct helper text on this page + """ + vip_instance, _ = VerifiedByStaff.objects.get_or_create(email="test@example.com", notes="Test Notes") + + p = "adminpass" + self.client.login(username="superuser", password=p) + response = self.client.get( + "/admin/registrar/verifiedbystaff/{}/change/".format(vip_instance.pk), + follow=True, + ) + + # Make sure the page loaded + self.assertEqual(response.status_code, 200) + + # These should exist in the response + expected_values = [ + ("requestor", "Person who verified this user"), + ] + self.test_helper.assert_response_contains_distinct_values(response, expected_values) def test_save_model_sets_user_field(self): with less_console_noise(): diff --git a/src/registrar/tests/test_forms.py b/src/registrar/tests/test_forms.py index 4b8904f0c..c72c70e98 100644 --- a/src/registrar/tests/test_forms.py +++ b/src/registrar/tests/test_forms.py @@ -15,7 +15,7 @@ from registrar.forms.domain_request_wizard import ( RequirementsForm, TribalGovernmentForm, PurposeForm, - AnythingElseForm, + AdditionalDetailsForm, AboutYourOrganizationForm, ) from registrar.forms.domain import ContactForm @@ -274,7 +274,7 @@ class TestFormValidation(MockEppLib): def test_anything_else_form_about_your_organization_character_count_invalid(self): """Response must be less than 2000 characters.""" - form = AnythingElseForm( + form = AdditionalDetailsForm( data={ "anything_else": "Bacon ipsum dolor amet fatback strip steak pastrami" "shankle, drumstick doner chicken landjaeger turkey andouille." diff --git a/src/registrar/tests/test_management_scripts.py b/src/registrar/tests/test_management_scripts.py index 26161b272..86ea1847f 100644 --- a/src/registrar/tests/test_management_scripts.py +++ b/src/registrar/tests/test_management_scripts.py @@ -14,8 +14,9 @@ from registrar.models import ( TransitionDomain, DomainInformation, UserDomainRole, + VerifiedByStaff, + PublicContact, ) -from registrar.models.public_contact import PublicContact from django.core.management import call_command from unittest.mock import patch, call @@ -25,6 +26,103 @@ from .common import MockEppLib, less_console_noise, completed_domain_request from api.tests.common import less_console_noise_decorator +class TestPopulateVerificationType(MockEppLib): + """Tests for the populate_organization_type script""" + + def setUp(self): + """Creates a fake domain object""" + super().setUp() + + # Get the domain requests + self.domain_request_1 = completed_domain_request( + name="lasers.gov", + generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + is_election_board=True, + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + ) + + # Approve the request + self.domain_request_1.approve() + + # Get the domains + self.domain_1 = Domain.objects.get(name="lasers.gov") + + # Get users + self.regular_user, _ = User.objects.get_or_create(username="testuser@igormail.gov") + + vip, _ = VerifiedByStaff.objects.get_or_create(email="vipuser@igormail.gov") + self.verified_by_staff_user, _ = User.objects.get_or_create(username="vipuser@igormail.gov") + + grandfathered, _ = TransitionDomain.objects.get_or_create( + username="grandpa@igormail.gov", domain_name=self.domain_1.name + ) + self.grandfathered_user, _ = User.objects.get_or_create(username="grandpa@igormail.gov") + + invited, _ = DomainInvitation.objects.get_or_create( + email="invited@igormail.gov", domain=self.domain_1, status=DomainInvitation.DomainInvitationStatus.RETRIEVED + ) + self.invited_user, _ = User.objects.get_or_create(username="invited@igormail.gov") + + self.untouched_user, _ = User.objects.get_or_create( + username="iaminvincible@igormail.gov", verification_type=User.VerificationTypeChoices.GRANDFATHERED + ) + + # Fixture users should be untouched by the script. These will auto update once the + # user logs in / creates an account. + self.fixture_user, _ = User.objects.get_or_create( + username="fixture@igormail.gov", verification_type=User.VerificationTypeChoices.FIXTURE_USER + ) + + def tearDown(self): + """Deletes all DB objects related to migrations""" + super().tearDown() + + # Delete domains and related information + Domain.objects.all().delete() + DomainInformation.objects.all().delete() + DomainRequest.objects.all().delete() + User.objects.all().delete() + Contact.objects.all().delete() + Website.objects.all().delete() + + @less_console_noise_decorator + def run_populate_verification_type(self): + """ + This method executes the populate_organization_type command. + + The 'call_command' function from Django's management framework is then used to + execute the populate_organization_type command with the specified arguments. + """ + with patch( + "registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", # noqa + return_value=True, + ): + call_command("populate_verification_type") + + @less_console_noise_decorator + def test_verification_type_script_populates_data(self): + """Ensures that the verification type script actually populates data""" + + # Run the script + self.run_populate_verification_type() + + # Scripts don't work as we'd expect in our test environment, we need to manually + # trigger the refresh event + self.regular_user.refresh_from_db() + self.grandfathered_user.refresh_from_db() + self.invited_user.refresh_from_db() + self.verified_by_staff_user.refresh_from_db() + self.untouched_user.refresh_from_db() + + # Test all users + self.assertEqual(self.regular_user.verification_type, User.VerificationTypeChoices.REGULAR) + self.assertEqual(self.grandfathered_user.verification_type, User.VerificationTypeChoices.GRANDFATHERED) + self.assertEqual(self.invited_user.verification_type, User.VerificationTypeChoices.INVITED) + self.assertEqual(self.verified_by_staff_user.verification_type, User.VerificationTypeChoices.VERIFIED_BY_STAFF) + self.assertEqual(self.untouched_user.verification_type, User.VerificationTypeChoices.GRANDFATHERED) + self.assertEqual(self.fixture_user.verification_type, User.VerificationTypeChoices.FIXTURE_USER) + + class TestPopulateOrganizationType(MockEppLib): """Tests for the populate_organization_type script""" @@ -743,3 +841,120 @@ class TestDiscloseEmails(MockEppLib): ) ] ) + + +# TODO in #1793: Remove this whole test class +class TestPopulateDomainUpdatedFederalAgency(TestCase): + def setUp(self): + super().setUp() + + # Get the domain requests + self.domain_request_1 = completed_domain_request( + name="stitches.gov", + generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + is_election_board=True, + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + federal_agency="U.S. Peace Corps", + ) + self.domain_request_2 = completed_domain_request( + name="fadoesntexist.gov", + generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + is_election_board=True, + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + federal_agency="MEOWARDRULES", + ) + self.domain_request_3 = completed_domain_request( + name="nullfederalagency.gov", + generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + is_election_board=True, + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + federal_agency=None, + ) + + # Approve all three requests + self.domain_request_1.approve() + self.domain_request_2.approve() + self.domain_request_3.approve() + + # Get the domains + self.domain_1 = Domain.objects.get(name="stitches.gov") + self.domain_2 = Domain.objects.get(name="fadoesntexist.gov") + self.domain_3 = Domain.objects.get(name="nullfederalagency.gov") + + # Get the domain infos + self.domain_info_1 = DomainInformation.objects.get(domain=self.domain_1) + self.domain_info_2 = DomainInformation.objects.get(domain=self.domain_2) + self.domain_info_3 = DomainInformation.objects.get(domain=self.domain_3) + + def tearDown(self): + super().tearDown() + DomainInformation.objects.all().delete() + DomainRequest.objects.all().delete() + Domain.objects.all().delete() + + def run_populate_domain_updated_federal_agency(self): + """ + This method executes the populate_domain_updated_federal_agency command. + + The 'call_command' function from Django's management framework is then used to + execute the populate_domain_updated_federal_agency command. + """ + with less_console_noise(): + call_command("populate_domain_updated_federal_agency") + + def test_domain_information_renaming_federal_agency_success(self): + """ + Domain Information updates successfully for an "outdated" Federal Agency + """ + + self.run_populate_domain_updated_federal_agency() + + self.domain_info_1.refresh_from_db() + + previous_federal_agency_name = self.domain_info_1.federal_agency + + updated_federal_agency_name = self.domain_info_1.updated_federal_agency.agency + + self.assertEqual(previous_federal_agency_name, "U.S. Peace Corps") + self.assertEqual(updated_federal_agency_name, "Peace Corps") + + def test_domain_information_does_not_exist(self): + """ + Update a Federal Agency that doesn't exist + (should return None bc the Federal Agency didn't exist before) + """ + + self.run_populate_domain_updated_federal_agency() + + self.domain_info_2.refresh_from_db() + + self.assertEqual(self.domain_info_2.updated_federal_agency, None) + + def test_domain_request_is_skipped(self): + """ + Update a Domain Request that doesn't exist + (should return None bc the Federal Agency didn't exist before) + """ + + # Test case #2 + self.run_populate_domain_updated_federal_agency() + + self.domain_request_2.refresh_from_db() + + self.assertEqual(self.domain_request_2.updated_federal_agency, None) + + def test_domain_information_updating_null_federal_agency_to_non_federal_agency(self): + """ + Updating a Domain Information that was previously None + to Non-Federal Agency + """ + + self.run_populate_domain_updated_federal_agency() + + self.domain_info_3.refresh_from_db() + + previous_federal_agency_name = self.domain_info_3.federal_agency + updated_federal_agency_name = self.domain_info_3.updated_federal_agency.agency + + self.assertEqual(previous_federal_agency_name, None) + self.assertEqual(updated_federal_agency_name, "Non-Federal Agency") diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index b8055f288..217e24b9a 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -1,12 +1,16 @@ +from datetime import date from django.test import Client, TestCase, override_settings from django.contrib.auth import get_user_model from api.tests.common import less_console_noise_decorator +from registrar.models.contact import Contact from registrar.models.domain import Domain +from registrar.models.draft_domain import DraftDomain +from registrar.models.user import User from registrar.models.user_domain_role import UserDomainRole from registrar.views.domain import DomainNameserversView -from .common import MockEppLib # type: ignore +from .common import MockEppLib, less_console_noise # type: ignore from unittest.mock import patch from django.urls import reverse @@ -135,3 +139,369 @@ class TestEnvironmentVariablesEffects(TestCase): self.assertEqual(contact_page_500.status_code, 500) self.assertNotContains(contact_page_500, "You are on a test site.") + + +class HomeTests(TestWithUser): + """A series of tests that target the two tables on home.html""" + + def setUp(self): + super().setUp() + self.client.force_login(self.user) + + def tearDown(self): + super().tearDown() + Contact.objects.all().delete() + + def test_empty_domain_table(self): + response = self.client.get("/") + self.assertContains(response, "You don't have any registered domains.") + self.assertContains(response, "Why don't I see my domain when I sign in to the registrar?") + + def test_home_lists_domain_requests(self): + response = self.client.get("/") + self.assertNotContains(response, "igorville.gov") + site = DraftDomain.objects.create(name="igorville.gov") + domain_request = DomainRequest.objects.create(creator=self.user, requested_domain=site) + response = self.client.get("/") + + # count = 7 because of screenreader content + self.assertContains(response, "igorville.gov", count=7) + + # clean up + domain_request.delete() + + def test_state_help_text(self): + """Tests if each domain state has help text""" + + # Get the expected text content of each state + deleted_text = "This domain has been removed and " "is no longer registered to your organization." + dns_needed_text = "Before this domain can be used, " "you’ll need to add name server addresses." + ready_text = "This domain has name servers and is ready for use." + on_hold_text = ( + "This domain is administratively paused, " + "so it can’t be edited and won’t resolve in DNS. " + "Contact help@get.gov for details." + ) + deleted_text = "This domain has been removed and " "is no longer registered to your organization." + # Generate a mapping of domain names, the state, and expected messages for the subtest + test_cases = [ + ("deleted.gov", Domain.State.DELETED, deleted_text), + ("dnsneeded.gov", Domain.State.DNS_NEEDED, dns_needed_text), + ("unknown.gov", Domain.State.UNKNOWN, dns_needed_text), + ("onhold.gov", Domain.State.ON_HOLD, on_hold_text), + ("ready.gov", Domain.State.READY, ready_text), + ] + for domain_name, state, expected_message in test_cases: + with self.subTest(domain_name=domain_name, state=state, expected_message=expected_message): + # Create a domain and a UserRole with the given params + test_domain, _ = Domain.objects.get_or_create(name=domain_name, state=state) + test_domain.expiration_date = date.today() + test_domain.save() + + user_role, _ = UserDomainRole.objects.get_or_create( + user=self.user, domain=test_domain, role=UserDomainRole.Roles.MANAGER + ) + + # Grab the home page + response = self.client.get("/") + + # Make sure the user can actually see the domain. + # We expect two instances because of SR content. + self.assertContains(response, domain_name, count=2) + + # Check that we have the right text content. + self.assertContains(response, expected_message, count=1) + + # Delete the role and domain to ensure we're testing in isolation + user_role.delete() + test_domain.delete() + + def test_state_help_text_expired(self): + """Tests if each domain state has help text when expired""" + expired_text = "This domain has expired, but it is still online. " "To renew this domain, contact help@get.gov." + test_domain, _ = Domain.objects.get_or_create(name="expired.gov", state=Domain.State.READY) + test_domain.expiration_date = date(2011, 10, 10) + test_domain.save() + + UserDomainRole.objects.get_or_create(user=self.user, domain=test_domain, role=UserDomainRole.Roles.MANAGER) + + # Grab the home page + response = self.client.get("/") + + # Make sure the user can actually see the domain. + # We expect two instances because of SR content. + self.assertContains(response, "expired.gov", count=2) + + # Check that we have the right text content. + self.assertContains(response, expired_text, count=1) + + def test_state_help_text_no_expiration_date(self): + """Tests if each domain state has help text when expiration date is None""" + + # == Test a expiration of None for state ready. This should be expired. == # + expired_text = "This domain has expired, but it is still online. " "To renew this domain, contact help@get.gov." + test_domain, _ = Domain.objects.get_or_create(name="imexpired.gov", state=Domain.State.READY) + test_domain.expiration_date = None + test_domain.save() + + UserDomainRole.objects.get_or_create(user=self.user, domain=test_domain, role=UserDomainRole.Roles.MANAGER) + + # Grab the home page + response = self.client.get("/") + + # Make sure the user can actually see the domain. + # We expect two instances because of SR content. + self.assertContains(response, "imexpired.gov", count=2) + + # Make sure the expiration date is None + self.assertEqual(test_domain.expiration_date, None) + + # Check that we have the right text content. + self.assertContains(response, expired_text, count=1) + + # == Test a expiration of None for state unknown. This should not display expired text. == # + unknown_text = "Before this domain can be used, " "you’ll need to add name server addresses." + test_domain_2, _ = Domain.objects.get_or_create(name="notexpired.gov", state=Domain.State.UNKNOWN) + test_domain_2.expiration_date = None + test_domain_2.save() + + UserDomainRole.objects.get_or_create(user=self.user, domain=test_domain_2, role=UserDomainRole.Roles.MANAGER) + + # Grab the home page + response = self.client.get("/") + + # Make sure the user can actually see the domain. + # We expect two instances because of SR content. + self.assertContains(response, "notexpired.gov", count=2) + + # Make sure the expiration date is None + self.assertEqual(test_domain_2.expiration_date, None) + + # Check that we have the right text content. + self.assertContains(response, unknown_text, count=1) + + def test_home_deletes_withdrawn_domain_request(self): + """Tests if the user can delete a DomainRequest in the 'withdrawn' status""" + + site = DraftDomain.objects.create(name="igorville.gov") + domain_request = DomainRequest.objects.create( + creator=self.user, requested_domain=site, status=DomainRequest.DomainRequestStatus.WITHDRAWN + ) + + # Ensure that igorville.gov exists on the page + home_page = self.client.get("/") + self.assertContains(home_page, "igorville.gov") + + # Check if the delete button exists. We can do this by checking for its id and text content. + self.assertContains(home_page, "Delete") + self.assertContains(home_page, "button-toggle-delete-domain-alert-1") + + # Trigger the delete logic + response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True) + + self.assertNotContains(response, "igorville.gov") + + # clean up + domain_request.delete() + + def test_home_deletes_started_domain_request(self): + """Tests if the user can delete a DomainRequest in the 'started' status""" + + site = DraftDomain.objects.create(name="igorville.gov") + domain_request = DomainRequest.objects.create( + creator=self.user, requested_domain=site, status=DomainRequest.DomainRequestStatus.STARTED + ) + + # Ensure that igorville.gov exists on the page + home_page = self.client.get("/") + self.assertContains(home_page, "igorville.gov") + + # Check if the delete button exists. We can do this by checking for its id and text content. + self.assertContains(home_page, "Delete") + self.assertContains(home_page, "button-toggle-delete-domain-alert-1") + + # Trigger the delete logic + response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True) + + self.assertNotContains(response, "igorville.gov") + + # clean up + domain_request.delete() + + def test_home_doesnt_delete_other_domain_requests(self): + """Tests to ensure the user can't delete domain requests not in the status of STARTED or WITHDRAWN""" + + # Given that we are including a subset of items that can be deleted while excluding the rest, + # subTest is appropriate here as otherwise we would need many duplicate tests for the same reason. + with less_console_noise(): + draft_domain = DraftDomain.objects.create(name="igorville.gov") + for status in DomainRequest.DomainRequestStatus: + if status not in [ + DomainRequest.DomainRequestStatus.STARTED, + DomainRequest.DomainRequestStatus.WITHDRAWN, + ]: + with self.subTest(status=status): + domain_request = DomainRequest.objects.create( + creator=self.user, requested_domain=draft_domain, status=status + ) + + # Trigger the delete logic + response = self.client.post( + reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True + ) + + # Check for a 403 error - the end user should not be allowed to do this + self.assertEqual(response.status_code, 403) + + desired_domain_request = DomainRequest.objects.filter(requested_domain=draft_domain) + + # Make sure the DomainRequest wasn't deleted + self.assertEqual(desired_domain_request.count(), 1) + + # clean up + domain_request.delete() + + def test_home_deletes_domain_request_and_orphans(self): + """Tests if delete for DomainRequest deletes orphaned Contact objects""" + + # Create the site and contacts to delete (orphaned) + contact = Contact.objects.create( + first_name="Henry", + last_name="Mcfakerson", + ) + contact_shared = Contact.objects.create( + first_name="Relative", + last_name="Aether", + ) + + # Create two non-orphaned contacts + contact_2 = Contact.objects.create( + first_name="Saturn", + last_name="Mars", + ) + + # Attach a user object to a contact (should not be deleted) + contact_user, _ = Contact.objects.get_or_create(user=self.user) + + site = DraftDomain.objects.create(name="igorville.gov") + domain_request = DomainRequest.objects.create( + creator=self.user, + requested_domain=site, + status=DomainRequest.DomainRequestStatus.WITHDRAWN, + authorizing_official=contact, + submitter=contact_user, + ) + domain_request.other_contacts.set([contact_2]) + + # Create a second domain request to attach contacts to + site_2 = DraftDomain.objects.create(name="teaville.gov") + domain_request_2 = DomainRequest.objects.create( + creator=self.user, + requested_domain=site_2, + status=DomainRequest.DomainRequestStatus.STARTED, + authorizing_official=contact_2, + submitter=contact_shared, + ) + domain_request_2.other_contacts.set([contact_shared]) + + # Ensure that igorville.gov exists on the page + home_page = self.client.get("/") + self.assertContains(home_page, "igorville.gov") + + # Trigger the delete logic + response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True) + + # igorville is now deleted + self.assertNotContains(response, "igorville.gov") + + # Check if the orphaned contact was deleted + orphan = Contact.objects.filter(id=contact.id) + self.assertFalse(orphan.exists()) + + # All non-orphan contacts should still exist and are unaltered + try: + current_user = Contact.objects.filter(id=contact_user.id).get() + except Contact.DoesNotExist: + self.fail("contact_user (a non-orphaned contact) was deleted") + + self.assertEqual(current_user, contact_user) + try: + edge_case = Contact.objects.filter(id=contact_2.id).get() + except Contact.DoesNotExist: + self.fail("contact_2 (a non-orphaned contact) was deleted") + + self.assertEqual(edge_case, contact_2) + + def test_home_deletes_domain_request_and_shared_orphans(self): + """Test the edge case for an object that will become orphaned after a delete + (but is not an orphan at the time of deletion)""" + + # Create the site and contacts to delete (orphaned) + contact = Contact.objects.create( + first_name="Henry", + last_name="Mcfakerson", + ) + contact_shared = Contact.objects.create( + first_name="Relative", + last_name="Aether", + ) + + # Create two non-orphaned contacts + contact_2 = Contact.objects.create( + first_name="Saturn", + last_name="Mars", + ) + + # Attach a user object to a contact (should not be deleted) + contact_user, _ = Contact.objects.get_or_create(user=self.user) + + site = DraftDomain.objects.create(name="igorville.gov") + domain_request = DomainRequest.objects.create( + creator=self.user, + requested_domain=site, + status=DomainRequest.DomainRequestStatus.WITHDRAWN, + authorizing_official=contact, + submitter=contact_user, + ) + domain_request.other_contacts.set([contact_2]) + + # Create a second domain request to attach contacts to + site_2 = DraftDomain.objects.create(name="teaville.gov") + domain_request_2 = DomainRequest.objects.create( + creator=self.user, + requested_domain=site_2, + status=DomainRequest.DomainRequestStatus.STARTED, + authorizing_official=contact_2, + submitter=contact_shared, + ) + domain_request_2.other_contacts.set([contact_shared]) + + home_page = self.client.get("/") + self.assertContains(home_page, "teaville.gov") + + # Trigger the delete logic + response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request_2.pk}), follow=True) + + self.assertNotContains(response, "teaville.gov") + + # Check if the orphaned contact was deleted + orphan = Contact.objects.filter(id=contact_shared.id) + self.assertFalse(orphan.exists()) + + def test_domain_request_form_view(self): + response = self.client.get("/request/", follow=True) + self.assertContains( + response, + "You’re about to start your .gov domain request.", + ) + + def test_domain_request_form_with_ineligible_user(self): + """Domain request form not accessible for an ineligible user. + This test should be solid enough since all domain request wizard + views share the same permissions class""" + self.user.status = User.RESTRICTED + self.user.save() + + with less_console_noise(): + response = self.client.get("/request/", follow=True) + self.assertEqual(response.status_code, 403) diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index fc391f8b5..6448e91e1 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -344,8 +344,6 @@ class TestDomainManagers(TestDomainOverview): def tearDown(self): """Ensure that the user has its original permissions""" super().tearDown() - self.user.is_staff = False - self.user.save() def test_domain_managers(self): response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id})) diff --git a/src/registrar/tests/test_views_request.py b/src/registrar/tests/test_views_request.py index a4cb210bc..22ad56646 100644 --- a/src/registrar/tests/test_views_request.py +++ b/src/registrar/tests/test_views_request.py @@ -3,7 +3,6 @@ from unittest.mock import Mock from django.conf import settings from django.urls import reverse -from datetime import date from .common import MockSESClient, completed_domain_request # type: ignore from django_webtest import WebTest # type: ignore @@ -17,7 +16,6 @@ from registrar.models import ( Contact, User, Website, - UserDomainRole, ) from registrar.views.domain_request import DomainRequestWizard, Step @@ -356,33 +354,39 @@ class DomainRequestTests(TestWithUser, WebTest): # the post request should return a redirect to the next form in # the domain request page self.assertEqual(other_contacts_result.status_code, 302) - self.assertEqual(other_contacts_result["Location"], "/request/anything_else/") + self.assertEqual(other_contacts_result["Location"], "/request/additional_details/") num_pages_tested += 1 - # ---- ANYTHING ELSE PAGE ---- + # ---- ADDITIONAL DETAILS PAGE ---- # Follow the redirect to the next form page self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - anything_else_page = other_contacts_result.follow() - anything_else_form = anything_else_page.forms[0] + additional_details_page = other_contacts_result.follow() + additional_details_form = additional_details_page.forms[0] - anything_else_form["anything_else-anything_else"] = "Nothing else." + # load inputs with test data + + additional_details_form["additional_details-has_cisa_representative"] = "True" + additional_details_form["additional_details-has_anything_else_text"] = "True" + additional_details_form["additional_details-cisa_representative_email"] = "FakeEmail@gmail.com" + additional_details_form["additional_details-anything_else"] = "Nothing else." # test next button self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - anything_else_result = anything_else_form.submit() + additional_details_result = additional_details_form.submit() # validate that data from this step are being saved domain_request = DomainRequest.objects.get() # there's only one + self.assertEqual(domain_request.cisa_representative_email, "FakeEmail@gmail.com") self.assertEqual(domain_request.anything_else, "Nothing else.") # the post request should return a redirect to the next form in # the domain request page - self.assertEqual(anything_else_result.status_code, 302) - self.assertEqual(anything_else_result["Location"], "/request/requirements/") + self.assertEqual(additional_details_result.status_code, 302) + self.assertEqual(additional_details_result["Location"], "/request/requirements/") num_pages_tested += 1 # ---- REQUIREMENTS PAGE ---- # Follow the redirect to the next form page self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - requirements_page = anything_else_result.follow() + requirements_page = additional_details_result.follow() requirements_form = requirements_page.forms[0] requirements_form["requirements-is_policy_acknowledged"] = True @@ -434,6 +438,7 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertContains(review_page, "Another Tester") self.assertContains(review_page, "testy2@town.com") self.assertContains(review_page, "(201) 555-5557") + self.assertContains(review_page, "FakeEmail@gmail.com") self.assertContains(review_page, "Nothing else.") # We can't test the modal itself as it relies on JS for init and triggering, @@ -717,13 +722,25 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertContains(contact_page, self.TITLES[Step.ABOUT_YOUR_ORGANIZATION]) - def test_yes_no_form_inits_blank_for_new_domain_request(self): + def test_yes_no_contact_form_inits_blank_for_new_domain_request(self): """On the Other Contacts page, the yes/no form gets initialized with nothing selected for new domain requests""" other_contacts_page = self.app.get(reverse("domain-request:other_contacts")) other_contacts_form = other_contacts_page.forms[0] self.assertEquals(other_contacts_form["other_contacts-has_other_contacts"].value, None) + def test_yes_no_additional_form_inits_blank_for_new_domain_request(self): + """On the Additional Details page, the yes/no form gets initialized with nothing selected for + new domain requests""" + additional_details_page = self.app.get(reverse("domain-request:additional_details")) + additional_form = additional_details_page.forms[0] + + # Check the cisa representative yes/no field + self.assertEquals(additional_form["additional_details-has_cisa_representative"].value, None) + + # Check the anything else yes/no field + self.assertEquals(additional_form["additional_details-has_anything_else_text"].value, None) + def test_yes_no_form_inits_yes_for_domain_request_with_other_contacts(self): """On the Other Contacts page, the yes/no form gets initialized with YES selected if the domain request has other contacts""" @@ -744,6 +761,38 @@ class DomainRequestTests(TestWithUser, WebTest): other_contacts_form = other_contacts_page.forms[0] self.assertEquals(other_contacts_form["other_contacts-has_other_contacts"].value, "True") + def test_yes_no_form_inits_yes_for_cisa_representative_and_anything_else(self): + """On the Additional Details page, the yes/no form gets initialized with YES selected + for both yes/no radios if the domain request has a value for cisa_representative and + anything_else""" + + domain_request = completed_domain_request(user=self.user, has_anything_else=True) + domain_request.cisa_representative_email = "test@igorville.gov" + domain_request.anything_else = "1234" + domain_request.save() + + # prime the form by visiting /edit + self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})) + # django-webtest does not handle cookie-based sessions well because it keeps + # resetting the session key on each new request, thus destroying the concept + # of a "session". We are going to do it manually, saving the session ID here + # and then setting the cookie on each request. + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_page = self.app.get(reverse("domain-request:additional_details")) + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_form = additional_details_page.forms[0] + + # Check the cisa representative yes/no field + yes_no_cisa = additional_details_form["additional_details-has_cisa_representative"].value + self.assertEquals(yes_no_cisa, "True") + + # Check the anything else yes/no field + yes_no_anything_else = additional_details_form["additional_details-has_anything_else_text"].value + self.assertEquals(yes_no_anything_else, "True") + def test_yes_no_form_inits_no_for_domain_request_with_no_other_contacts_rationale(self): """On the Other Contacts page, the yes/no form gets initialized with NO selected if the domain request has no other contacts""" @@ -766,6 +815,230 @@ class DomainRequestTests(TestWithUser, WebTest): other_contacts_form = other_contacts_page.forms[0] self.assertEquals(other_contacts_form["other_contacts-has_other_contacts"].value, "False") + def test_yes_no_form_for_domain_request_with_no_cisa_representative_and_anything_else(self): + """On the Additional details page, the form preselects "no" when has_cisa_representative + and anything_else is no""" + + domain_request = completed_domain_request(user=self.user, has_anything_else=False) + + # Unlike the other contacts form, the no button is tracked with these boolean fields. + # This means that we should expect this to correlate with the no button. + domain_request.has_anything_else_text = False + domain_request.has_cisa_representative = False + domain_request.save() + + # prime the form by visiting /edit + self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})) + # django-webtest does not handle cookie-based sessions well because it keeps + # resetting the session key on each new request, thus destroying the concept + # of a "session". We are going to do it manually, saving the session ID here + # and then setting the cookie on each request. + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_page = self.app.get(reverse("domain-request:additional_details")) + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_form = additional_details_page.forms[0] + + # Check the cisa representative yes/no field + yes_no_cisa = additional_details_form["additional_details-has_cisa_representative"].value + self.assertEquals(yes_no_cisa, "False") + + # Check the anything else yes/no field + yes_no_anything_else = additional_details_form["additional_details-has_anything_else_text"].value + self.assertEquals(yes_no_anything_else, "False") + + def test_submitting_additional_details_deletes_cisa_representative_and_anything_else(self): + """When a user submits the Additional Details form with no selected for all fields, + the domain request's data gets wiped when submitted""" + domain_request = completed_domain_request(name="nocisareps.gov", user=self.user) + domain_request.cisa_representative_email = "fake@faketown.gov" + domain_request.save() + + # Make sure we have the data we need for the test + self.assertEqual(domain_request.anything_else, "There is more") + self.assertEqual(domain_request.cisa_representative_email, "fake@faketown.gov") + + # prime the form by visiting /edit + self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})) + # django-webtest does not handle cookie-based sessions well because it keeps + # resetting the session key on each new request, thus destroying the concept + # of a "session". We are going to do it manually, saving the session ID here + # and then setting the cookie on each request. + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_page = self.app.get(reverse("domain-request:additional_details")) + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_form = additional_details_page.forms[0] + + # Check the cisa representative yes/no field + yes_no_cisa = additional_details_form["additional_details-has_cisa_representative"].value + self.assertEquals(yes_no_cisa, "True") + + # Check the anything else yes/no field + yes_no_anything_else = additional_details_form["additional_details-has_anything_else_text"].value + self.assertEquals(yes_no_anything_else, "True") + + # Set fields to false + additional_details_form["additional_details-has_cisa_representative"] = "False" + additional_details_form["additional_details-has_anything_else_text"] = "False" + + # Submit the form + additional_details_form.submit() + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + # Verify that the anything_else and cisa_representative have been deleted from the DB + domain_request = DomainRequest.objects.get(requested_domain__name="nocisareps.gov") + + # Check that our data has been cleared + self.assertEqual(domain_request.anything_else, None) + self.assertEqual(domain_request.cisa_representative_email, None) + + # Double check the yes/no fields + self.assertEqual(domain_request.has_anything_else_text, False) + self.assertEqual(domain_request.has_cisa_representative, False) + + def test_submitting_additional_details_populates_cisa_representative_and_anything_else(self): + """When a user submits the Additional Details form, + the domain request's data gets submitted""" + domain_request = completed_domain_request(name="cisareps.gov", user=self.user, has_anything_else=False) + + # Make sure we have the data we need for the test + self.assertEqual(domain_request.anything_else, None) + self.assertEqual(domain_request.cisa_representative_email, None) + + # These fields should not be selected at all, since we haven't initialized the form yet + self.assertEqual(domain_request.has_anything_else_text, None) + self.assertEqual(domain_request.has_cisa_representative, None) + + # prime the form by visiting /edit + self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})) + # django-webtest does not handle cookie-based sessions well because it keeps + # resetting the session key on each new request, thus destroying the concept + # of a "session". We are going to do it manually, saving the session ID here + # and then setting the cookie on each request. + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_page = self.app.get(reverse("domain-request:additional_details")) + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_form = additional_details_page.forms[0] + + # Set fields to true, and set data on those fields + additional_details_form["additional_details-has_cisa_representative"] = "True" + additional_details_form["additional_details-has_anything_else_text"] = "True" + additional_details_form["additional_details-cisa_representative_email"] = "test@faketest.gov" + additional_details_form["additional_details-anything_else"] = "redandblue" + + # Submit the form + additional_details_form.submit() + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + # Verify that the anything_else and cisa_representative exist in the db + domain_request = DomainRequest.objects.get(requested_domain__name="cisareps.gov") + + self.assertEqual(domain_request.anything_else, "redandblue") + self.assertEqual(domain_request.cisa_representative_email, "test@faketest.gov") + + self.assertEqual(domain_request.has_cisa_representative, True) + self.assertEqual(domain_request.has_anything_else_text, True) + + def test_if_cisa_representative_yes_no_form_is_yes_then_field_is_required(self): + """Applicants with a cisa representative must provide a value""" + domain_request = completed_domain_request(name="cisareps.gov", user=self.user, has_anything_else=False) + + # prime the form by visiting /edit + self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})) + # django-webtest does not handle cookie-based sessions well because it keeps + # resetting the session key on each new request, thus destroying the concept + # of a "session". We are going to do it manually, saving the session ID here + # and then setting the cookie on each request. + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_page = self.app.get(reverse("domain-request:additional_details")) + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_form = additional_details_page.forms[0] + + # Set fields to true, and set data on those fields + additional_details_form["additional_details-has_cisa_representative"] = "True" + additional_details_form["additional_details-has_anything_else_text"] = "False" + + # Submit the form + response = additional_details_form.submit() + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + self.assertContains(response, "Enter the email address of your CISA regional representative.") + + def test_if_anything_else_yes_no_form_is_yes_then_field_is_required(self): + """Applicants with a anything else must provide a value""" + domain_request = completed_domain_request(name="cisareps.gov", user=self.user, has_anything_else=False) + + # prime the form by visiting /edit + self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})) + # django-webtest does not handle cookie-based sessions well because it keeps + # resetting the session key on each new request, thus destroying the concept + # of a "session". We are going to do it manually, saving the session ID here + # and then setting the cookie on each request. + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_page = self.app.get(reverse("domain-request:additional_details")) + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_form = additional_details_page.forms[0] + + # Set fields to true, and set data on those fields + additional_details_form["additional_details-has_cisa_representative"] = "False" + additional_details_form["additional_details-has_anything_else_text"] = "True" + + # Submit the form + response = additional_details_form.submit() + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + expected_message = "Provide additional details you’d like us to know. If you have nothing to add, select “No.”" + self.assertContains(response, expected_message) + + def test_additional_details_form_fields_required(self): + """When a user submits the Additional Details form without checking the + has_cisa_representative and has_anything_else_text fields, the form should deny this action""" + domain_request = completed_domain_request(name="cisareps.gov", user=self.user, has_anything_else=False) + + self.assertEqual(domain_request.has_anything_else_text, None) + self.assertEqual(domain_request.has_cisa_representative, None) + + # prime the form by visiting /edit + self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})) + # django-webtest does not handle cookie-based sessions well because it keeps + # resetting the session key on each new request, thus destroying the concept + # of a "session". We are going to do it manually, saving the session ID here + # and then setting the cookie on each request. + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_page = self.app.get(reverse("domain-request:additional_details")) + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_form = additional_details_page.forms[0] + + # Submit the form + response = additional_details_form.submit() + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + # We expect to see this twice for both fields. This results in a count of 4 + # due to screen reader information / html. + self.assertContains(response, "This question is required.", count=4) + def test_submitting_other_contacts_deletes_no_other_contacts_rationale(self): """When a user submits the Other Contacts form with other contacts selected, the domain request's no other contacts rationale gets deleted""" @@ -2328,364 +2601,3 @@ class TestWizardUnlockingSteps(TestWithUser, WebTest): else: self.fail(f"Expected a redirect, but got a different response: {response}") - - -class HomeTests(TestWithUser): - """A series of tests that target the two tables on home.html""" - - def setUp(self): - super().setUp() - self.client.force_login(self.user) - - def tearDown(self): - super().tearDown() - Contact.objects.all().delete() - - def test_home_lists_domain_requests(self): - response = self.client.get("/") - self.assertNotContains(response, "igorville.gov") - site = DraftDomain.objects.create(name="igorville.gov") - domain_request = DomainRequest.objects.create(creator=self.user, requested_domain=site) - response = self.client.get("/") - - # count = 7 because of screenreader content - self.assertContains(response, "igorville.gov", count=7) - - # clean up - domain_request.delete() - - def test_state_help_text(self): - """Tests if each domain state has help text""" - - # Get the expected text content of each state - deleted_text = "This domain has been removed and " "is no longer registered to your organization." - dns_needed_text = "Before this domain can be used, " "you’ll need to add name server addresses." - ready_text = "This domain has name servers and is ready for use." - on_hold_text = ( - "This domain is administratively paused, " - "so it can’t be edited and won’t resolve in DNS. " - "Contact help@get.gov for details." - ) - deleted_text = "This domain has been removed and " "is no longer registered to your organization." - # Generate a mapping of domain names, the state, and expected messages for the subtest - test_cases = [ - ("deleted.gov", Domain.State.DELETED, deleted_text), - ("dnsneeded.gov", Domain.State.DNS_NEEDED, dns_needed_text), - ("unknown.gov", Domain.State.UNKNOWN, dns_needed_text), - ("onhold.gov", Domain.State.ON_HOLD, on_hold_text), - ("ready.gov", Domain.State.READY, ready_text), - ] - for domain_name, state, expected_message in test_cases: - with self.subTest(domain_name=domain_name, state=state, expected_message=expected_message): - # Create a domain and a UserRole with the given params - test_domain, _ = Domain.objects.get_or_create(name=domain_name, state=state) - test_domain.expiration_date = date.today() - test_domain.save() - - user_role, _ = UserDomainRole.objects.get_or_create( - user=self.user, domain=test_domain, role=UserDomainRole.Roles.MANAGER - ) - - # Grab the home page - response = self.client.get("/") - - # Make sure the user can actually see the domain. - # We expect two instances because of SR content. - self.assertContains(response, domain_name, count=2) - - # Check that we have the right text content. - self.assertContains(response, expected_message, count=1) - - # Delete the role and domain to ensure we're testing in isolation - user_role.delete() - test_domain.delete() - - def test_state_help_text_expired(self): - """Tests if each domain state has help text when expired""" - expired_text = "This domain has expired, but it is still online. " "To renew this domain, contact help@get.gov." - test_domain, _ = Domain.objects.get_or_create(name="expired.gov", state=Domain.State.READY) - test_domain.expiration_date = date(2011, 10, 10) - test_domain.save() - - UserDomainRole.objects.get_or_create(user=self.user, domain=test_domain, role=UserDomainRole.Roles.MANAGER) - - # Grab the home page - response = self.client.get("/") - - # Make sure the user can actually see the domain. - # We expect two instances because of SR content. - self.assertContains(response, "expired.gov", count=2) - - # Check that we have the right text content. - self.assertContains(response, expired_text, count=1) - - def test_state_help_text_no_expiration_date(self): - """Tests if each domain state has help text when expiration date is None""" - - # == Test a expiration of None for state ready. This should be expired. == # - expired_text = "This domain has expired, but it is still online. " "To renew this domain, contact help@get.gov." - test_domain, _ = Domain.objects.get_or_create(name="imexpired.gov", state=Domain.State.READY) - test_domain.expiration_date = None - test_domain.save() - - UserDomainRole.objects.get_or_create(user=self.user, domain=test_domain, role=UserDomainRole.Roles.MANAGER) - - # Grab the home page - response = self.client.get("/") - - # Make sure the user can actually see the domain. - # We expect two instances because of SR content. - self.assertContains(response, "imexpired.gov", count=2) - - # Make sure the expiration date is None - self.assertEqual(test_domain.expiration_date, None) - - # Check that we have the right text content. - self.assertContains(response, expired_text, count=1) - - # == Test a expiration of None for state unknown. This should not display expired text. == # - unknown_text = "Before this domain can be used, " "you’ll need to add name server addresses." - test_domain_2, _ = Domain.objects.get_or_create(name="notexpired.gov", state=Domain.State.UNKNOWN) - test_domain_2.expiration_date = None - test_domain_2.save() - - UserDomainRole.objects.get_or_create(user=self.user, domain=test_domain_2, role=UserDomainRole.Roles.MANAGER) - - # Grab the home page - response = self.client.get("/") - - # Make sure the user can actually see the domain. - # We expect two instances because of SR content. - self.assertContains(response, "notexpired.gov", count=2) - - # Make sure the expiration date is None - self.assertEqual(test_domain_2.expiration_date, None) - - # Check that we have the right text content. - self.assertContains(response, unknown_text, count=1) - - def test_home_deletes_withdrawn_domain_request(self): - """Tests if the user can delete a DomainRequest in the 'withdrawn' status""" - - site = DraftDomain.objects.create(name="igorville.gov") - domain_request = DomainRequest.objects.create( - creator=self.user, requested_domain=site, status=DomainRequest.DomainRequestStatus.WITHDRAWN - ) - - # Ensure that igorville.gov exists on the page - home_page = self.client.get("/") - self.assertContains(home_page, "igorville.gov") - - # Check if the delete button exists. We can do this by checking for its id and text content. - self.assertContains(home_page, "Delete") - self.assertContains(home_page, "button-toggle-delete-domain-alert-1") - - # Trigger the delete logic - response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True) - - self.assertNotContains(response, "igorville.gov") - - # clean up - domain_request.delete() - - def test_home_deletes_started_domain_request(self): - """Tests if the user can delete a DomainRequest in the 'started' status""" - - site = DraftDomain.objects.create(name="igorville.gov") - domain_request = DomainRequest.objects.create( - creator=self.user, requested_domain=site, status=DomainRequest.DomainRequestStatus.STARTED - ) - - # Ensure that igorville.gov exists on the page - home_page = self.client.get("/") - self.assertContains(home_page, "igorville.gov") - - # Check if the delete button exists. We can do this by checking for its id and text content. - self.assertContains(home_page, "Delete") - self.assertContains(home_page, "button-toggle-delete-domain-alert-1") - - # Trigger the delete logic - response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True) - - self.assertNotContains(response, "igorville.gov") - - # clean up - domain_request.delete() - - def test_home_doesnt_delete_other_domain_requests(self): - """Tests to ensure the user can't delete domain requests not in the status of STARTED or WITHDRAWN""" - - # Given that we are including a subset of items that can be deleted while excluding the rest, - # subTest is appropriate here as otherwise we would need many duplicate tests for the same reason. - with less_console_noise(): - draft_domain = DraftDomain.objects.create(name="igorville.gov") - for status in DomainRequest.DomainRequestStatus: - if status not in [ - DomainRequest.DomainRequestStatus.STARTED, - DomainRequest.DomainRequestStatus.WITHDRAWN, - ]: - with self.subTest(status=status): - domain_request = DomainRequest.objects.create( - creator=self.user, requested_domain=draft_domain, status=status - ) - - # Trigger the delete logic - response = self.client.post( - reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True - ) - - # Check for a 403 error - the end user should not be allowed to do this - self.assertEqual(response.status_code, 403) - - desired_domain_request = DomainRequest.objects.filter(requested_domain=draft_domain) - - # Make sure the DomainRequest wasn't deleted - self.assertEqual(desired_domain_request.count(), 1) - - # clean up - domain_request.delete() - - def test_home_deletes_domain_request_and_orphans(self): - """Tests if delete for DomainRequest deletes orphaned Contact objects""" - - # Create the site and contacts to delete (orphaned) - contact = Contact.objects.create( - first_name="Henry", - last_name="Mcfakerson", - ) - contact_shared = Contact.objects.create( - first_name="Relative", - last_name="Aether", - ) - - # Create two non-orphaned contacts - contact_2 = Contact.objects.create( - first_name="Saturn", - last_name="Mars", - ) - - # Attach a user object to a contact (should not be deleted) - contact_user, _ = Contact.objects.get_or_create(user=self.user) - - site = DraftDomain.objects.create(name="igorville.gov") - domain_request = DomainRequest.objects.create( - creator=self.user, - requested_domain=site, - status=DomainRequest.DomainRequestStatus.WITHDRAWN, - authorizing_official=contact, - submitter=contact_user, - ) - domain_request.other_contacts.set([contact_2]) - - # Create a second domain request to attach contacts to - site_2 = DraftDomain.objects.create(name="teaville.gov") - domain_request_2 = DomainRequest.objects.create( - creator=self.user, - requested_domain=site_2, - status=DomainRequest.DomainRequestStatus.STARTED, - authorizing_official=contact_2, - submitter=contact_shared, - ) - domain_request_2.other_contacts.set([contact_shared]) - - # Ensure that igorville.gov exists on the page - home_page = self.client.get("/") - self.assertContains(home_page, "igorville.gov") - - # Trigger the delete logic - response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True) - - # igorville is now deleted - self.assertNotContains(response, "igorville.gov") - - # Check if the orphaned contact was deleted - orphan = Contact.objects.filter(id=contact.id) - self.assertFalse(orphan.exists()) - - # All non-orphan contacts should still exist and are unaltered - try: - current_user = Contact.objects.filter(id=contact_user.id).get() - except Contact.DoesNotExist: - self.fail("contact_user (a non-orphaned contact) was deleted") - - self.assertEqual(current_user, contact_user) - try: - edge_case = Contact.objects.filter(id=contact_2.id).get() - except Contact.DoesNotExist: - self.fail("contact_2 (a non-orphaned contact) was deleted") - - self.assertEqual(edge_case, contact_2) - - def test_home_deletes_domain_request_and_shared_orphans(self): - """Test the edge case for an object that will become orphaned after a delete - (but is not an orphan at the time of deletion)""" - - # Create the site and contacts to delete (orphaned) - contact = Contact.objects.create( - first_name="Henry", - last_name="Mcfakerson", - ) - contact_shared = Contact.objects.create( - first_name="Relative", - last_name="Aether", - ) - - # Create two non-orphaned contacts - contact_2 = Contact.objects.create( - first_name="Saturn", - last_name="Mars", - ) - - # Attach a user object to a contact (should not be deleted) - contact_user, _ = Contact.objects.get_or_create(user=self.user) - - site = DraftDomain.objects.create(name="igorville.gov") - domain_request = DomainRequest.objects.create( - creator=self.user, - requested_domain=site, - status=DomainRequest.DomainRequestStatus.WITHDRAWN, - authorizing_official=contact, - submitter=contact_user, - ) - domain_request.other_contacts.set([contact_2]) - - # Create a second domain request to attach contacts to - site_2 = DraftDomain.objects.create(name="teaville.gov") - domain_request_2 = DomainRequest.objects.create( - creator=self.user, - requested_domain=site_2, - status=DomainRequest.DomainRequestStatus.STARTED, - authorizing_official=contact_2, - submitter=contact_shared, - ) - domain_request_2.other_contacts.set([contact_shared]) - - home_page = self.client.get("/") - self.assertContains(home_page, "teaville.gov") - - # Trigger the delete logic - response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request_2.pk}), follow=True) - - self.assertNotContains(response, "teaville.gov") - - # Check if the orphaned contact was deleted - orphan = Contact.objects.filter(id=contact_shared.id) - self.assertFalse(orphan.exists()) - - def test_domain_request_form_view(self): - response = self.client.get("/request/", follow=True) - self.assertContains( - response, - "You’re about to start your .gov domain request.", - ) - - def test_domain_request_form_with_ineligible_user(self): - """Domain request form not accessible for an ineligible user. - This test should be solid enough since all domain request wizard - views share the same permissions class""" - self.user.status = User.RESTRICTED - self.user.save() - - with less_console_noise(): - response = self.client.get("/request/", follow=True) - self.assertEqual(response.status_code, 403) diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index 244b47602..f93976138 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -45,7 +45,7 @@ class Step(StrEnum): PURPOSE = "purpose" YOUR_CONTACT = "your_contact" OTHER_CONTACTS = "other_contacts" - ANYTHING_ELSE = "anything_else" + ADDITIONAL_DETAILS = "additional_details" REQUIREMENTS = "requirements" REVIEW = "review" @@ -91,7 +91,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): Step.PURPOSE: _("Purpose of your domain"), Step.YOUR_CONTACT: _("Your contact information"), Step.OTHER_CONTACTS: _("Other employees from your organization"), - Step.ANYTHING_ELSE: _("Anything else?"), + Step.ADDITIONAL_DETAILS: _("Additional details"), Step.REQUIREMENTS: _("Requirements for operating a .gov domain"), Step.REVIEW: _("Review and submit your domain request"), } @@ -365,8 +365,9 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): self.domain_request.other_contacts.exists() or self.domain_request.no_other_contacts_rationale is not None ), - "anything_else": ( - self.domain_request.anything_else is not None or self.domain_request.is_policy_acknowledged is not None + "additional_details": ( + (self.domain_request.anything_else is not None and self.domain_request.cisa_representative_email) + or self.domain_request.is_policy_acknowledged is not None ), "requirements": self.domain_request.is_policy_acknowledged is not None, "review": self.domain_request.is_policy_acknowledged is not None, @@ -581,9 +582,64 @@ class OtherContacts(DomainRequestWizard): return all_forms_valid -class AnythingElse(DomainRequestWizard): - template_name = "domain_request_anything_else.html" - forms = [forms.AnythingElseForm] +class AdditionalDetails(DomainRequestWizard): + + template_name = "domain_request_additional_details.html" + + forms = [ + forms.CisaRepresentativeYesNoForm, + forms.CisaRepresentativeForm, + forms.AdditionalDetailsYesNoForm, + forms.AdditionalDetailsForm, + ] + + def is_valid(self, forms: list) -> bool: + + # Validate Cisa Representative + """Overrides default behavior defined in DomainRequestWizard. + Depending on value in yes_no forms, marks corresponding data + for deletion. Then validates all forms. + """ + cisa_representative_email_yes_no_form = forms[0] + cisa_representative_email_form = forms[1] + anything_else_yes_no_form = forms[2] + anything_else_form = forms[3] + + # ------- Validate cisa representative ------- + cisa_rep_portion_is_valid = True + # test first for yes_no_form validity + if cisa_representative_email_yes_no_form.is_valid(): + # test for existing data + if not cisa_representative_email_yes_no_form.cleaned_data.get("has_cisa_representative"): + # mark the cisa_representative_email_form for deletion + cisa_representative_email_form.mark_form_for_deletion() + else: + cisa_rep_portion_is_valid = cisa_representative_email_form.is_valid() + else: + # if yes no form is invalid, no choice has been made + # mark the cisa_representative_email_form for deletion + cisa_representative_email_form.mark_form_for_deletion() + cisa_rep_portion_is_valid = False + + # ------- Validate anything else ------- + anything_else_portion_is_valid = True + # test first for yes_no_form validity + if anything_else_yes_no_form.is_valid(): + # test for existing data + if not anything_else_yes_no_form.cleaned_data.get("has_anything_else_text"): + # mark the anything_else_form for deletion + anything_else_form.mark_form_for_deletion() + else: + anything_else_portion_is_valid = anything_else_form.is_valid() + else: + # if yes no form is invalid, no choice has been made + # mark the anything_else_form for deletion + anything_else_form.mark_form_for_deletion() + anything_else_portion_is_valid = False + + # ------- Return combined validation result ------- + all_forms_valid = cisa_rep_portion_is_valid and anything_else_portion_is_valid + return all_forms_valid class Requirements(DomainRequestWizard): diff --git a/src/requirements.txt b/src/requirements.txt index 067a174e9..d4878748a 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,8 +1,8 @@ -i https://pypi.python.org/simple annotated-types==0.6.0; python_version >= '3.8' asgiref==3.8.1; python_version >= '3.8' -boto3==1.34.88; python_version >= '3.8' -botocore==1.34.88; python_version >= '3.8' +boto3==1.34.90; python_version >= '3.8' +botocore==1.34.90; python_version >= '3.8' cachetools==5.3.3; python_version >= '3.7' certifi==2024.2.2; python_version >= '3.6' cfenv==0.5.3 @@ -16,7 +16,7 @@ dj-email-url==1.0.6 django==4.2.10; python_version >= '3.8' django-admin-multiple-choice-list-filter==0.1.1 django-allow-cidr==0.7.1 -django-auditlog==2.3.0; python_version >= '3.7' +django-auditlog==3.0.0; python_version >= '3.8' django-cache-url==3.4.5 django-cors-headers==4.3.1; python_version >= '3.8' django-csp==3.8 @@ -50,8 +50,8 @@ phonenumberslite==8.13.35 psycopg2-binary==2.9.9; python_version >= '3.7' pycparser==2.22; python_version >= '3.8' 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.7.0; python_version >= '3.8' -pydantic-core==2.18.1; python_version >= '3.8' +pydantic==2.7.1; python_version >= '3.8' +pydantic-core==2.18.2; python_version >= '3.8' pydantic-settings==2.2.1; python_version >= '3.8' pyjwkest==1.4.2 python-dateutil==2.9.0.post0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
Your domain requests
StartedSubmittedStartedSubmittedStartedSubmittedIn reviewStartedSubmittedIn reviewAction neededIn reviewAction neededIn reviewSubmittedStartedStartedSubmittedIn reviewAction needed