merge main

This commit is contained in:
David Kennedy 2024-05-03 07:35:14 -04:00
commit b97cd399e0
No known key found for this signature in database
GPG key ID: 6528A5386E66B96B
62 changed files with 4335 additions and 1155 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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```

View file

@ -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 = "*"

218
src/Pipfile.lock generated
View file

@ -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": [

View file

@ -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 /"""

View file

@ -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", "/"))

View file

@ -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/

View file

@ -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 -g npm@10.5.0
RUN npm install

4
src/package-lock.json generated
View file

@ -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": {

View file

@ -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"

View file

@ -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 <a href="{link}">this form</a>.'
)
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,8 +761,11 @@ 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 readonly_fields
else:
# Return restrictive Read-only fields for analysts and
# users who might not belong to groups
return self.analyst_readonly_fields
@ -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,20 +1879,26 @@ 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
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
return super().changeform_view(request, object_id, form_url, extra_context)
else:
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
return super().changeform_view(request, object_id, form_url, extra_context)
def response_change(self, request, obj):
# Create dictionary of action functions

View file

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

View file

@ -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 <h3> 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&amp;_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 <h2> element to the <h3> element
h3Element.textContent = h2Element.textContent;
// Find the nested <span> element inside the <h2>
const nestedSpan = h2Element.querySelector('span[class][title]');
// If the nested <span> element exists
if (nestedSpan) {
// Create a new <span> element
const newSpan = document.createElement('span');
// Copy the class and title attributes from the nested <span> element
newSpan.className = nestedSpan.className;
newSpan.title = nestedSpan.title;
// Append the new <span> element to the <h3> element
h3Element.appendChild(newSpan);
}
// 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
);
// identify the fromList element in the DOM
let fromList = toList.closest('.selector').querySelector(".selector-available select");
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"));
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
// Replace the <h2> element with the new <h3> element
parentElement.replaceChild(h3Element, h2Element);
}
}
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;
}
relatedWidgetWrapper.insertBefore(link, previousSibling.nextSibling);
}
}
// 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')

View file

@ -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,57 +771,40 @@ 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);
}
}
if (radioButtons.length) {
// Add event listener to each radio button
radioButtons.forEach(function (radioButton) {
radioButton.addEventListener('change', handleRadioButtonChange);
/**
* 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");
});
// 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)
})();

View file

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

View file

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

View file

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

View file

@ -10,6 +10,7 @@
--- Custom Styles ---------------------------------*/
@forward "base";
@forward "typography";
@forward "links";
@forward "lists";
@forward "buttons";
@forward "forms";

View file

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

View file

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

View file

@ -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()

View file

@ -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. (Well 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. (Well 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 representatives 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,7 +679,20 @@ class AnythingElseForm(RegistrarForm):
message="Response must be less than 2000 characters.",
)
],
error_messages={
"required": (
"Provide additional details youd 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):

View file

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

View file

@ -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}"
)

View file

@ -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}"
)

View file

@ -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):

View file

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

View file

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

View file

@ -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):

View file

@ -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):

View file

@ -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?

View file

@ -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):

View file

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

View file

@ -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
# 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)
# 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
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)
# 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)
if TransitionDomain.objects.filter(username=email).exists():
return False
verification_type = cls.VerificationTypeChoices.GRANDFATHERED
elif VerifiedByStaff.objects.filter(email=email).exists():
# New users flagged by Staff to bypass ial2
if VerifiedByStaff.objects.filter(email=email).exists():
return False
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").
invited = DomainInvitation.DomainInvitationStatus.INVITED
if DomainInvitation.objects.filter(email=email, status=invited).exists():
return False
verification_type = cls.VerificationTypeChoices.INVITED
else:
verification_type = cls.VerificationTypeChoices.REGULAR
return True
return verification_type
def check_domain_invitations_on_login(self):
"""When a user first arrives on the site, we need to retrieve any domain

View file

@ -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."
)

View file

@ -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:

View file

@ -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:

View file

@ -23,6 +23,7 @@
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script type="application/javascript" src="{% static 'js/get-gov-admin.js' %}" defer></script>
<script type="application/javascript" src="{% static 'js/get-gov-reports.js' %}" defer></script>
<script type="application/javascript" src="{% static 'js/dja-collapse.js' %}" defer></script>
{% endblock %}
{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}

View file

@ -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 %}
<fieldset class="module aligned {{ fieldset.classes }}">
{% if fieldset.name %}<h2>{{ fieldset.name }}</h2>{% endif %}
{% if fieldset.name %}
{# Customize the markup for the collapse toggle #}
{% if 'collapse--dotgov' in fieldset.classes %}
<button type="button">
<span>{{ fieldset.name }}</span>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#expand_more"></use>
</svg>
</button>
<legend class="sr-only">{{ fieldset.description }}</legend>
{% else %}
<h2>{{ fieldset.name }}</h2>
{% 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 %}
<div class="description">{{ fieldset.description|safe }}</div>
{% endif %}

View file

@ -33,7 +33,10 @@
{% endif %}
</div>
</div>
{{ 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 %}

View file

@ -3,7 +3,7 @@
<address class="{% if no_title_top_padding %}margin-top-neg-1__detail-list{% endif %} {% if user.has_contact_info %}margin-bottom-1{% endif %} dja-address-contact-list">
{% if show_formatted_name %}
{% if contact.get_formatted_name %}
{% if user.get_formatted_name %}
<a href="{% url 'admin:registrar_contact_change' user.id %}">{{ user.get_formatted_name }}</a><br />
{% else %}
None<br />
@ -47,7 +47,12 @@
{% else %}
None<br>
{% endif %}
{% else %}
No additional contact information found.
No additional contact information found.<br>
{% endif %}
{% if user_verification_type %}
{{ user_verification_type }}
{% endif %}
</address>

View file

@ -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 %}
<div class="flex-container">
<label aria-label="Submitter contact details"></label>
<div class="usa-table-container--scrollable" tabindex="0">
<table class="usa-table usa-table--borderless">
<thead>
<tr>
<th>Status</th>
<th>User</th>
<th>Changed at</th>
</tr>
</thead>
<tbody>
{% for log_entry in original_object.history.all %}
{% for key, value in log_entry.changes_display_dict.items %}
{% if key == "status" %}
<tr>
<td>{{ value.1|default:"None" }}</td>
<td>{{ log_entry.actor|default:"None" }}</td>
<td>{{ log_entry.timestamp|default:"None" }}</td>
</tr>
{% endif %}
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
</div>
{% elif field.field.name == "creator" %}
<div class="flex-container tablet:margin-top-2">
<label aria-label="Creator contact details"></label>
{% 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%}
</div>
{% 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" %}
<div class="flex-container">
<div class="flex-container tablet:margin-top-2">
<label aria-label="Submitter contact details"></label>
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.submitter no_title_top_padding=field.is_readonly %}
</div>

View file

@ -0,0 +1,21 @@
{% extends "admin/fieldset.html" %}
{% load static url_helpers %}
{% block field_readonly %}
{% if field.field.name == "state" %}
<div class="readonly">{{ domain_state }}</div>
{% else %}
<div class="readonly">{{ field.contents }}</div>
{% endif %}
{% endblock %}
{% block help_text %}
<div class="help margin-bottom-1" {% if field.field.id_for_label %} id="{{ field.field.id_for_label }}_helptext"{% endif %}>
{% if field.field.name == "state" %}
<div>{{ state_help_message }}</div>
{% else %}
<div>{{ field.field.help_text|safe }}</div>
{% endif %}
</div>
{% endblock help_text %}

View file

@ -0,0 +1,55 @@
{% extends 'domain_request_form.html' %}
{% load static field_helpers %}
{% block form_instructions %}
<em>These questions are required (<abbr class="usa-hint usa-hint--required" title="required">*</abbr>).</em>
{% endblock %}
{% block form_required_fields_help_text %}
{# commented out so it does not appear at this point on this page #}
{% endblock %}
<!-- TODO-NL: (refactor) Breakup into two separate components-->
{% block form_fields %}
<fieldset class="usa-fieldset margin-top-2">
<legend>
<h2>Are you working with a CISA regional representative on your domain request?</h2>
<p>.gov is managed by the Cybersecurity and Infrastructure Security Agency. CISA has <a href="https://www.cisa.gov/about/regions" target="_blank">10 regions</a> that some organizations choose to work with. Regional representatives use titles like protective security advisors, cyber security advisors, or election security advisors.</p>
</legend>
<!-- Toggle -->
{% 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 #}
<!-- TODO-NL: Hookup forms.0 to yes/no form for cisa representative (backend def)-->
</fieldset>
<div id="cisa-representative" class="cisa-representative-form">
{% input_with_errors forms.1.cisa_representative_email %}
{# forms.1 is a form for inputting the e-mail of a cisa representative #}
<!-- TODO-NL: Hookup forms.1 to cisa representative form (backend def) -->
</div>
<fieldset class="usa-fieldset margin-top-2">
<legend>
<h2>Is there anything else youd like us to know about your domain request?</h2>
</legend>
<!-- Toggle -->
{% 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 #}
<!-- TODO-NL: Hookup forms.2 to yes/no form for anything else form (backend def)-->
</fieldset>
<div id="anything-else">
{% 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 #}
<!-- TODO-NL: Hookup forms.3 to anything else form (backend def) -->
</div>
{% endblock %}

View file

@ -1,19 +0,0 @@
{% extends 'domain_request_form.html' %}
{% load field_helpers %}
{% block form_instructions %}
<h2>Is there anything else youd like us to know about your domain request?</h2>
<p>This question is optional.</p>
{% 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 %}

View file

@ -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 %}
<h3 class="register-form-review-header">Anything else</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% if domain_request.anything_else %}
{{domain_request.anything_else}}
{% else %}
No
{% endif %}
</ul>
{% endif %}

View file

@ -116,7 +116,18 @@
{% 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 %}
<h3 class="register-form-review-header">Anything else</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% if DomainRequest.anything_else %}
{{DomainRequest.anything_else}}
{% else %}
No
{% endif %}
</ul>
{% endif %}
{% endwith %}
</div>

View file

@ -26,7 +26,7 @@
<section class="section--outlined">
<h2>Domains</h2>
{% if domains %}
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked dotgov-table__registered-domains">
<caption class="sr-only">Your registered domains</caption>
<thead>
<tr>
@ -98,13 +98,21 @@
></div>
{% else %}
<p>You don't have any registered domains.</p>
<p class="maxw-none clearfix">
<a href="https://get.gov/help/faq/#do-not-see-my-domain" class="float-right-tablet display-flex flex-align-start usa-link" target="_blank">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#help_outline"></use>
</svg>
Why don't I see my domain when I sign in to the registrar?
</a>
</p>
{% endif %}
</section>
<section class="section--outlined">
<h2>Domain requests</h2>
{% if domain_requests %}
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked dotgov-table__domain-requests">
<caption class="sr-only">Your domain requests</caption>
<thead>
<tr>

View file

@ -20,6 +20,9 @@
</{{ heading_level }}>
{% else %}
</h2>
{% endif %}
{% if sub_header_text %}
<h3 class="register-form-review-header">{{ sub_header_text }}</h3>
{% endif %}
{% if address %}
{% include "includes/organization_address.html" with organization=value %}
@ -39,6 +42,10 @@
</dd>
{% endfor %}
</dl>
{% elif custom_text_for_value_none %}
<p>
{{ custom_text_for_value_none }}
</p>
{% else %}
<p>
None
@ -92,6 +99,8 @@
<p class="margin-top-0 margin-bottom-0">
{% if value %}
{{ value }}
{% elif custom_text_for_value_none %}
{{ custom_text_for_value_none }}
{% else %}
None
{% endif %}

View file

@ -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:

View file

@ -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, "<td>Started</td>", count=1)
self.assertNotContains(response, "<td>Submitted</td>")
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, "<td>Started</td>", count=1)
self.assertContains(response, "<td>Submitted</td>", 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, "<td>Started</td>", count=1)
self.assertContains(response, "<td>Submitted</td>", count=1)
self.assertContains(response, "<td>In review</td>", 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, "<td>Started</td>", count=1)
self.assertContains(response, "<td>Submitted</td>", count=1)
self.assertContains(response, "<td>In review</td>", count=1)
self.assertContains(response, "<td>Action needed</td>", 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 = [
"<td>In review</td>",
"<td>Action needed</td>",
"<td>In review</td>",
"<td>Submitted</td>",
"<td>Started</td>",
]
# 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, "<td>Started</td>", count=1)
self.assertContains(response, "<td>Submitted</td>", count=1)
self.assertContains(response, "<td>In review</td>", count=2)
self.assertContains(response, "<td>Action needed</td>", 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, "<span>Show details</span>")
@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</a></th>", 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():

View file

@ -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."

View file

@ -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")

View file

@ -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, " "youll 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 cant be edited and wont 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, " "youll 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,
"Youre 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)

View file

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

View file

@ -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 youd 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, " "youll 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 cant be edited and wont 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, " "youll 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,
"Youre 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)

View file

@ -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):

View file

@ -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'