diff --git a/.github/ISSUE_TEMPLATE/developer-onboarding.md b/.github/ISSUE_TEMPLATE/developer-onboarding.md index d63cf2f94..a0825ab52 100644 --- a/.github/ISSUE_TEMPLATE/developer-onboarding.md +++ b/.github/ISSUE_TEMPLATE/developer-onboarding.md @@ -14,13 +14,29 @@ assignees: abroddrick ## Installation -There are several tools we use locally that you will need to have. -- [ ] [Install the cf CLI v7](https://docs.cloudfoundry.org/cf-cli/install-go-cli.html#pkg-mac) for the ability to deploy +There are several tools we use locally that you will need to have. + +- [ ] [Cloudfoundry CLI](https://docs.cloudfoundry.org/cf-cli/install-go-cli.html#pkg-mac) Note: If you are on Windows the cli will be under `cf8` or `cf7` depending on which version you install. - If you are using Windows, installation information can be found [here](https://github.com/cloudfoundry/cli/wiki/V8-CLI-Installation-Guide#installers-and-compressed-binaries) - Alternatively, for Windows, [consider using chocolately](https://community.chocolatey.org/packages/cloudfoundry-cli/7.2.0) -- [ ] Make sure you have `gpg` >2.1.7. Run `gpg --version` to check. If not, [install gnupg](https://formulae.brew.sh/formula/gnupg) - - Alternatively, you can skip this step and [use ssh keys](#setting-up-commit-signing-with-ssh) instead -- [ ] Install the [Github CLI](https://cli.github.com/) +- [ ] [GPG](https://gnupg.org/download/) + - Make sure you have `gpg` >2.1.7. Run `gpg --version` to check. If not, [install gnupg](https://formulae.brew.sh/formula/gnupg) + - This may not work on DHS devices. Alternatively, you can [use ssh keys](#setting-up-commit-signing-with-ssh) instead. +- [ ] Docker Community Edition* +- [ ] Git* +- [ ] VSCode (our preferred editor)* +- [ ] Github Desktop* or the Github CLI* + +The following tools are optional but recommended. For DHS devices, these can be requested through the DHS IT portal: +- [ ] Slack Desktop App** +- [ ] Python 3.10* +- [ ] NodeJS (latest version available)* +- [ ] Putty* +- [ ] Windows Subsystem for Linux* + +* Must be requested through DHS IT portal on DHS devices + +** Downloadable via DHS Software Center ## Access @@ -37,7 +53,12 @@ cf login -a api.fr.cloud.gov --sso **Note:** As mentioned in the [Login documentation](https://developers.login.gov/testing/), the sandbox Login account is different account from your regular, production Login account. If you have not created a Login account for the sandbox before, you will need to create a new account first. -- [ ] Optional- add yourself as a codeowner if desired. See the [Developer readme](https://github.com/cisagov/getgov/blob/main/docs/developer/README.md) for how to do this and what it does. +Follow the [.gov onboarding dev setup instructions](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit#heading=h.94jwfwkpkhdx). Confirm you successfully set up the following accounts: +- [ ] Identity sandbox accounts - 1 superuser access account and 1 analyst access account. +- [ ] Login.gov account to access stable + +**Optional** +- [ ] Add yourself as a codeowner if desired. See the [Developer readme](https://github.com/cisagov/getgov/blob/main/docs/developer/README.md) for how to do this and what it does. ### Steps for the onboarder - [ ] Add the onboardee to cloud.gov org (cisa-dotgov) @@ -124,3 +145,19 @@ Additionally, consider a gpg key manager like Kleopatra if you run into issues w We have three types of environments: stable, staging, and sandbox. Stable (production)and staging (pre-prod) get deployed via tagged release, and developer sandboxes are given to get.gov developers to mess around in a production-like environment without disrupting stable or staging. Each sandbox is namespaced and will automatically be deployed too when the appropriate branch syntax is used for that space in an open pull request. There are several things you need to setup to make the sandbox work for a developer. All automation for setting up a developer sandbox is documented in the scripts for [creating a developer sandbox](../../ops/scripts/create_dev_sandbox.sh) and [removing a developer sandbox](../../ops/scripts/destroy_dev_sandbox.sh). A Cloud.gov organization administrator will have to perform the script in order to create the sandbox. + +## Known Issues + +### SSL Verification Failure +Some developers using Government Furnished Equipment (GFE) have problems using tools such as git and pip due to SSL verification failurse. This happens because GFE has a custom certificate chain installed, but these tools use their own certificate bundles. As a result, when they try to verify an ssl connection, they cannot and so the connection fails. To resolve this in pip you can use --use-feature=truststore to direct pip to use the local certificate store. If you are running into this issue when using git on windows, run ```git config --global http.sslbackend schannel```. + +If you are running into these issues in a docker container you will need to export the root certificate and pull it into the container. Ask another developer how to do this properly. + +### Puppeteer Download Error +When building the node image either individually or with docker compose, there may be an error caused by a node package call puppeteer. This can be resolved by adding `ENV PUPPETEER_SKIP_DOWNLOAD=true` to [node.Dockerfile](../../src/node.Dockerfile) after the COPY command. + +### Checksum Error +There is an unresolved issue with python package installation that occurs after the above SSL Verification failure has been resolved. It often manifests as a checksum error, where the hash of a download .whl file (python package) does not match the expected value. This appears to be because pythonhosted.org is cutting off download connections to some devices for some packages (the behavior is somewhat inconsistent). We have outstanding issues with PyPA and DHS IT to fix this. In the meantime we have a [workaround](#developing-using-docker). + +## Developing Using Docker +While we have unresolved issues with certain devices, you can pull a pre-built docker image from matthewswspence/getgov-base that comes with all the needed packages installed. To do this, you will need to change the very first line in the main [Dockerfile](../../src/Dockerfile) to `FROM matthewswspence/getgov-base:latest`. Note: this change will need to be reverted before any branch can be merged. Additionally, this will only resolve the [checksum error](#checksum-error), you will still need to resolve any other issues through the listed instructions. We are actively working to resolve this inconvenience. diff --git a/.github/workflows/deploy-branch-to-sandbox.yaml b/.github/workflows/deploy-manual.yaml similarity index 79% rename from .github/workflows/deploy-branch-to-sandbox.yaml rename to .github/workflows/deploy-manual.yaml index f57961fa8..e0bbee436 100644 --- a/.github/workflows/deploy-branch-to-sandbox.yaml +++ b/.github/workflows/deploy-manual.yaml @@ -29,6 +29,7 @@ on: - hotgov - litterbox - ms + - ad # GitHub Actions has no "good" way yet to dynamically input branches branch: description: 'Branch to deploy' @@ -73,20 +74,4 @@ jobs: cf_org: cisa-dotgov cf_space: ${{ env.ENVIRONMENT }} cf_manifest: ops/manifests/manifest-${{ env.ENVIRONMENT }}.yaml - comment: - runs-on: ubuntu-latest - needs: [deploy] - steps: - - uses: actions/github-script@v6 - env: - ENVIRONMENT: ${{ github.event.inputs.environment }} - with: - github-token: ${{secrets.GITHUB_TOKEN}} - script: | - github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - body: '🥳 Successfully deployed to developer sandbox **[${{ env.ENVIRONMENT }}](https://getgov-${{ env.ENVIRONMENT }}.app.cloud.gov/)**.' - }) - diff --git a/.github/workflows/deploy-sandbox.yaml b/.github/workflows/deploy-sandbox.yaml index 57561919c..fe0a19089 100644 --- a/.github/workflows/deploy-sandbox.yaml +++ b/.github/workflows/deploy-sandbox.yaml @@ -29,6 +29,7 @@ jobs: || startsWith(github.head_ref, 'litterbox/') || startsWith(github.head_ref, 'ag/') || startsWith(github.head_ref, 'ms/') + || startsWith(github.head_ref, 'ad/') outputs: environment: ${{ steps.var.outputs.environment}} runs-on: "ubuntu-latest" diff --git a/.github/workflows/migrate.yaml b/.github/workflows/migrate.yaml index 3ebee59f9..70ff8ee95 100644 --- a/.github/workflows/migrate.yaml +++ b/.github/workflows/migrate.yaml @@ -16,6 +16,7 @@ on: - stable - staging - development + - ad - ms - ag - litterbox diff --git a/.github/workflows/reset-db.yaml b/.github/workflows/reset-db.yaml index 49e4b5e5f..b6fa0fec5 100644 --- a/.github/workflows/reset-db.yaml +++ b/.github/workflows/reset-db.yaml @@ -16,6 +16,7 @@ on: options: - staging - development + - ad - ms - ag - litterbox diff --git a/.gitignore b/.gitignore index ddd75475d..f2d82f599 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,10 @@ docs/research/data/** public/ credentials* +src/certs/ *.pem *.crt +*.cer *.bk diff --git a/docs/developer/README.md b/docs/developer/README.md index f63f01938..358df649c 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -357,4 +357,8 @@ Then, copy the variables under the section labled `s3`. 1. On the app, navigate to `\admin`. 2. Under models, click `Waffle flags`. 3. Click the `disable_email_sending` record. This should exist by default, if not - create one with that name. -4. (Important) Set the field `everyone` to `Yes`. This field overrides all other settings \ No newline at end of file +4. (Important) Set the field `everyone` to `Yes`. This field overrides all other settings + +## Request Flow FSM Diagram + +The [.gov Domain Request & Domain Status Digram](https://miro.com/app/board/uXjVMuqbLOk=/?moveToWidget=3458764594819017396&cot=14) visualizes the domain request flow and resulting domain objects. diff --git a/docs/operations/README.md b/docs/operations/README.md index 9aaee4c86..cc73d82cb 100644 --- a/docs/operations/README.md +++ b/docs/operations/README.md @@ -45,6 +45,8 @@ When deploying to your personal sandbox, you should make sure all of the USWDS a For ease of use, you can run the `deploy.sh ` script in the `/src` directory to build the assets and deploy to your sandbox. Similarly, you could run `build.sh ` script to just compile and collect the assets without deploying. +You may also manually deploy to a sandbox using our [manual deploy workflow](https://github.com/cisagov/manage.get.gov/actions/workflows/deploy-manual.yaml) on GitHub Actions. Select Run workflow and enter the branch you want to deploy to your sandbox of choice. + Your sandbox space should've been setup as part of the onboarding process. If this was not the case, please have an admin follow the instructions below. ## Creating a sandbox or new environment diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md index 75f2f27a0..cd748b22d 100644 --- a/docs/operations/data_migration.md +++ b/docs/operations/data_migration.md @@ -751,4 +751,68 @@ Example: `cf ssh getgov-za` ##### Parameters | | Parameter | Description | |:-:|:-------------------------- |:-----------------------------------------------------------------------------------| -| 1 | **emailTo** | Specifies where the email will be emailed. Defaults to help@get.gov on production. | \ No newline at end of file +| 1 | **emailTo** | Specifies where the email will be emailed. Defaults to help@get.gov on production. | + +## Populate federal agency initials and FCEB +This script adds to the "is_fceb" and "initials" fields on the FederalAgency model. This script expects a CSV of federal CIOs to pull from, which can be sourced from [here](https://docs.google.com/spreadsheets/d/14oXHFpKyUXS5_mDWARPusghGdHCrP67jCleOknaSx38/edit?gid=479328070#gid=479328070). + +### 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: Upload your csv to the desired sandbox +[Follow these steps](#use-scp-to-transfer-data-to-sandboxes) to upload the federal_cio csv to a sandbox of your choice. + +#### Step 5: Running the script +```./manage.py populate_federal_agency_initials_and_fceb {path_to_CIO_csv}``` + +### Running locally + +#### Step 1: Running the script +```docker-compose exec app ./manage.py populate_federal_agency_initials_and_fceb {path_to_CIO_csv}``` + +##### Parameters +| | Parameter | Description | +|:-:|:-------------------------- |:-----------------------------------------------------------------------------------| +| 1 | **federal_cio_csv_path** | Specifies where the federal CIO csv is | + +## Load senior official table +This script adds SeniorOfficial records to the related table based off of a CSV. This script expects a CSV of federal CIOs to pull from, which can be sourced from [here](https://docs.google.com/spreadsheets/d/14oXHFpKyUXS5_mDWARPusghGdHCrP67jCleOknaSx38/edit?gid=479328070#gid=479328070). + +### 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: Upload your csv to the desired sandbox +[Follow these steps](#use-scp-to-transfer-data-to-sandboxes) to upload the federal_cio csv to a sandbox of your choice. + +#### Step 5: Running the script +```./manage.py load_senior_official_table {path_to_CIO_csv}``` + +### Running locally + +#### Step 1: Running the script +```docker-compose exec app ./manage.py load_senior_official_table {path_to_CIO_csv}``` + +##### Parameters +| | Parameter | Description | +|:-:|:-------------------------- |:-----------------------------------------------------------------------------------| +| 1 | **federal_cio_csv_path** | Specifies where the federal CIO csv is | diff --git a/ops/manifests/manifest-ad.yaml b/ops/manifests/manifest-ad.yaml new file mode 100644 index 000000000..73d6f96ff --- /dev/null +++ b/ops/manifests/manifest-ad.yaml @@ -0,0 +1,32 @@ +--- +applications: +- name: getgov-ad + buildpacks: + - python_buildpack + path: ../../src + instances: 1 + memory: 512M + stack: cflinuxfs4 + timeout: 180 + command: ./run.sh + health-check-type: http + health-check-http-endpoint: /health + health-check-invocation-timeout: 40 + env: + # Send stdout and stderr straight to the terminal without buffering + PYTHONUNBUFFERED: yup + # Tell Django where to find its configuration + DJANGO_SETTINGS_MODULE: registrar.config.settings + # Tell Django where it is being hosted + DJANGO_BASE_URL: https://getgov-ad.app.cloud.gov + # Tell Django how much stuff to log + DJANGO_LOG_LEVEL: INFO + # default public site location + GETGOV_PUBLIC_SITE_URL: https://get.gov + # Flag to disable/enable features in prod environments + IS_PRODUCTION: False + routes: + - route: getgov-ad.app.cloud.gov + services: + - getgov-credentials + - getgov-ad-database diff --git a/ops/scripts/create_dev_sandbox.sh b/ops/scripts/create_dev_sandbox.sh index 676fcf7ae..6cbad9c4f 100755 --- a/ops/scripts/create_dev_sandbox.sh +++ b/ops/scripts/create_dev_sandbox.sh @@ -116,6 +116,10 @@ sed -i '' '/ - development/ {a\ - '"$1"' }' .github/workflows/migrate.yaml +sed -i '' '/ - backup/ {a\ + - '"$1"' +}' .github/workflows/deploy-manual.yaml + sed -i '' '/${{startsWith(github.head_ref, / {a\ || startsWith(github.head_ref, '"'$1'"') }' .github/workflows/deploy-sandbox.yaml diff --git a/ops/scripts/destroy_dev_sandbox.sh b/ops/scripts/destroy_dev_sandbox.sh index 9e233b2f1..c8a00937f 100755 --- a/ops/scripts/destroy_dev_sandbox.sh +++ b/ops/scripts/destroy_dev_sandbox.sh @@ -49,6 +49,7 @@ rm ops/manifests/manifest-$1.yaml sed -i '' "/getgov-$1.app.cloud.gov/d" src/registrar/config/settings.py sed -i '' "/- $1/d" .github/workflows/reset-db.yaml sed -i '' "/- $1/d" .github/workflows/migrate.yaml +sed -i '' "/- $1/d" .github/workflows/deploy-manual.yaml echo "Cleaning up services, applications, and the Cloud.gov space for $1..." cf delete getgov-$1 diff --git a/src/epplibwrapper/tests/test_client.py b/src/epplibwrapper/tests/test_client.py index d30ec4865..57c99a05f 100644 --- a/src/epplibwrapper/tests/test_client.py +++ b/src/epplibwrapper/tests/test_client.py @@ -3,10 +3,10 @@ from dateutil.tz import tzlocal # type: ignore from unittest.mock import MagicMock, patch from pathlib import Path from django.test import TestCase +from api.tests.common import less_console_noise_decorator from gevent.exceptions import ConcurrentObjectUseError from epplibwrapper.client import EPPLibWrapper from epplibwrapper.errors import RegistryError, LoginError -from .common import less_console_noise import logging try: @@ -24,99 +24,101 @@ logger = logging.getLogger(__name__) class TestClient(TestCase): """Test the EPPlibwrapper client""" + @less_console_noise_decorator def fake_result(self, code, msg): """Helper function to create a fake Result object""" return Result(code=code, msg=msg, res_data=[], cl_tr_id="cl_tr_id", sv_tr_id="sv_tr_id") + @less_console_noise_decorator @patch("epplibwrapper.client.Client") def test_initialize_client_success(self, mock_client): """Test when the initialize_client is successful""" - with less_console_noise(): - # Mock the Client instance and its methods - mock_connect = MagicMock() - # Create a mock Result instance - mock_result = MagicMock(spec=Result) - mock_result.code = 200 - mock_result.msg = "Success" - mock_result.res_data = ["data1", "data2"] - mock_result.cl_tr_id = "client_id" - mock_result.sv_tr_id = "server_id" - mock_send = MagicMock(return_value=mock_result) - mock_client.return_value.connect = mock_connect - mock_client.return_value.send = mock_send + # Mock the Client instance and its methods + mock_connect = MagicMock() + # Create a mock Result instance + mock_result = MagicMock(spec=Result) + mock_result.code = 200 + mock_result.msg = "Success" + mock_result.res_data = ["data1", "data2"] + mock_result.cl_tr_id = "client_id" + mock_result.sv_tr_id = "server_id" + mock_send = MagicMock(return_value=mock_result) + mock_client.return_value.connect = mock_connect + mock_client.return_value.send = mock_send - # Create EPPLibWrapper instance and initialize client - wrapper = EPPLibWrapper() + # Create EPPLibWrapper instance and initialize client + wrapper = EPPLibWrapper() - # Assert that connect method is called once - mock_connect.assert_called_once() - # Assert that _client is not None after initialization - self.assertIsNotNone(wrapper._client) + # Assert that connect method is called once + mock_connect.assert_called_once() + # Assert that _client is not None after initialization + self.assertIsNotNone(wrapper._client) + @less_console_noise_decorator @patch("epplibwrapper.client.Client") def test_initialize_client_transport_error(self, mock_client): """Test when the send(login) step of initialize_client raises a TransportError.""" - with less_console_noise(): - # Mock the Client instance and its methods - mock_connect = MagicMock() - mock_send = MagicMock(side_effect=TransportError("Transport error")) - mock_client.return_value.connect = mock_connect - mock_client.return_value.send = mock_send + # Mock the Client instance and its methods + mock_connect = MagicMock() + mock_send = MagicMock(side_effect=TransportError("Transport error")) + mock_client.return_value.connect = mock_connect + mock_client.return_value.send = mock_send - with self.assertRaises(RegistryError): - # Create EPPLibWrapper instance and initialize client - # if functioning as expected, initial __init__ should except - # and log any Exception raised - wrapper = EPPLibWrapper() - # so call _initialize_client a second time directly to test - # the raised exception - wrapper._initialize_client() + with self.assertRaises(RegistryError): + # Create EPPLibWrapper instance and initialize client + # if functioning as expected, initial __init__ should except + # and log any Exception raised + wrapper = EPPLibWrapper() + # so call _initialize_client a second time directly to test + # the raised exception + wrapper._initialize_client() + @less_console_noise_decorator @patch("epplibwrapper.client.Client") def test_initialize_client_login_error(self, mock_client): """Test when the send(login) step of initialize_client returns (2400) comamnd failed code.""" - with less_console_noise(): - # Mock the Client instance and its methods - mock_connect = MagicMock() - # Create a mock Result instance - mock_result = MagicMock(spec=Result) - mock_result.code = 2400 - mock_result.msg = "Login failed" - mock_result.res_data = ["data1", "data2"] - mock_result.cl_tr_id = "client_id" - mock_result.sv_tr_id = "server_id" - mock_send = MagicMock(return_value=mock_result) - mock_client.return_value.connect = mock_connect - mock_client.return_value.send = mock_send + # Mock the Client instance and its methods + mock_connect = MagicMock() + # Create a mock Result instance + mock_result = MagicMock(spec=Result) + mock_result.code = 2400 + mock_result.msg = "Login failed" + mock_result.res_data = ["data1", "data2"] + mock_result.cl_tr_id = "client_id" + mock_result.sv_tr_id = "server_id" + mock_send = MagicMock(return_value=mock_result) + mock_client.return_value.connect = mock_connect + mock_client.return_value.send = mock_send - with self.assertRaises(LoginError): - # Create EPPLibWrapper instance and initialize client - # if functioning as expected, initial __init__ should except - # and log any Exception raised - wrapper = EPPLibWrapper() - # so call _initialize_client a second time directly to test - # the raised exception - wrapper._initialize_client() + with self.assertRaises(LoginError): + # Create EPPLibWrapper instance and initialize client + # if functioning as expected, initial __init__ should except + # and log any Exception raised + wrapper = EPPLibWrapper() + # so call _initialize_client a second time directly to test + # the raised exception + wrapper._initialize_client() + @less_console_noise_decorator @patch("epplibwrapper.client.Client") def test_initialize_client_unknown_exception(self, mock_client): """Test when the send(login) step of initialize_client raises an unexpected Exception.""" - with less_console_noise(): - # Mock the Client instance and its methods - mock_connect = MagicMock() - mock_send = MagicMock(side_effect=Exception("Unknown exception")) - mock_client.return_value.connect = mock_connect - mock_client.return_value.send = mock_send + # Mock the Client instance and its methods + mock_connect = MagicMock() + mock_send = MagicMock(side_effect=Exception("Unknown exception")) + mock_client.return_value.connect = mock_connect + mock_client.return_value.send = mock_send - with self.assertRaises(RegistryError): - # Create EPPLibWrapper instance and initialize client - # if functioning as expected, initial __init__ should except - # and log any Exception raised - wrapper = EPPLibWrapper() - # so call _initialize_client a second time directly to test - # the raised exception - wrapper._initialize_client() + with self.assertRaises(RegistryError): + # Create EPPLibWrapper instance and initialize client + # if functioning as expected, initial __init__ should except + # and log any Exception raised + wrapper = EPPLibWrapper() + # so call _initialize_client a second time directly to test + # the raised exception + wrapper._initialize_client() + @less_console_noise_decorator @patch("epplibwrapper.client.Client") def test_initialize_client_fails_recovers_with_send_command(self, mock_client): """Test when the initialize_client fails on the connect() step. And then a subsequent @@ -126,56 +128,56 @@ class TestClient(TestCase): Initialization step fails at app init Send command fails (with 2400 code) prompting retry Client closes and re-initializes, and command is sent successfully""" - with less_console_noise(): - # Mock the Client instance and its methods - # close() should return successfully - mock_close = MagicMock() - mock_client.return_value.close = mock_close - # Create success and failure results - command_success_result = self.fake_result(1000, "Command completed successfully") - command_failure_result = self.fake_result(2400, "Command failed") - # side_effect for the connect() calls - # first connect() should raise an Exception - # subsequent connect() calls should return success - connect_call_count = 0 + # Mock the Client instance and its methods + # close() should return successfully + mock_close = MagicMock() + mock_client.return_value.close = mock_close + # Create success and failure results + command_success_result = self.fake_result(1000, "Command completed successfully") + command_failure_result = self.fake_result(2400, "Command failed") + # side_effect for the connect() calls + # first connect() should raise an Exception + # subsequent connect() calls should return success + connect_call_count = 0 - def connect_side_effect(*args, **kwargs): - nonlocal connect_call_count - connect_call_count += 1 - if connect_call_count == 1: - raise Exception("Connection failed") - else: - return command_success_result + def connect_side_effect(*args, **kwargs): + nonlocal connect_call_count + connect_call_count += 1 + if connect_call_count == 1: + raise Exception("Connection failed") + else: + return command_success_result - mock_connect = MagicMock(side_effect=connect_side_effect) - mock_client.return_value.connect = mock_connect - # side_effect for the send() calls - # first send will be the send("InfoDomainCommand") and should fail - # subsequend send() calls should return success - send_call_count = 0 + mock_connect = MagicMock(side_effect=connect_side_effect) + mock_client.return_value.connect = mock_connect + # side_effect for the send() calls + # first send will be the send("InfoDomainCommand") and should fail + # subsequend send() calls should return success + send_call_count = 0 - def send_side_effect(*args, **kwargs): - nonlocal send_call_count - send_call_count += 1 - if send_call_count == 1: - return command_failure_result - else: - return command_success_result + def send_side_effect(*args, **kwargs): + nonlocal send_call_count + send_call_count += 1 + if send_call_count == 1: + return command_failure_result + else: + return command_success_result - mock_send = MagicMock(side_effect=send_side_effect) - mock_client.return_value.send = mock_send - # Create EPPLibWrapper instance and call send command - wrapper = EPPLibWrapper() - wrapper.send("InfoDomainCommand", cleaned=True) - # two connect() calls should be made, the initial failed connect() - # and the successful connect() during retry() - self.assertEquals(mock_connect.call_count, 2) - # close() should only be called once, during retry() - mock_close.assert_called_once() - # send called 4 times: failed send("InfoDomainCommand"), passed send(logout), - # passed send(login), passed send("InfoDomainCommand") - self.assertEquals(mock_send.call_count, 4) + mock_send = MagicMock(side_effect=send_side_effect) + mock_client.return_value.send = mock_send + # Create EPPLibWrapper instance and call send command + wrapper = EPPLibWrapper() + wrapper.send("InfoDomainCommand", cleaned=True) + # two connect() calls should be made, the initial failed connect() + # and the successful connect() during retry() + self.assertEquals(mock_connect.call_count, 2) + # close() should only be called once, during retry() + mock_close.assert_called_once() + # send called 4 times: failed send("InfoDomainCommand"), passed send(logout), + # passed send(login), passed send("InfoDomainCommand") + self.assertEquals(mock_send.call_count, 4) + @less_console_noise_decorator @patch("epplibwrapper.client.Client") def test_send_command_failed_retries_and_fails_again(self, mock_client): """Test when the send("InfoDomainCommand) call fails with a 2400, prompting a retry @@ -185,42 +187,42 @@ class TestClient(TestCase): Initialization succeeds Send command fails (with 2400 code) prompting retry Client closes and re-initializes, and command fails again with 2400""" - with less_console_noise(): - # Mock the Client instance and its methods - # connect() and close() should succeed throughout - mock_connect = MagicMock() - mock_close = MagicMock() - # Create a mock Result instance - send_command_success_result = self.fake_result(1000, "Command completed successfully") - send_command_failure_result = self.fake_result(2400, "Command failed") + # Mock the Client instance and its methods + # connect() and close() should succeed throughout + mock_connect = MagicMock() + mock_close = MagicMock() + # Create a mock Result instance + send_command_success_result = self.fake_result(1000, "Command completed successfully") + send_command_failure_result = self.fake_result(2400, "Command failed") - # side_effect for send command, passes for all other sends (login, logout), but - # fails for send("InfoDomainCommand") - def side_effect(*args, **kwargs): - if args[0] == "InfoDomainCommand": - return send_command_failure_result - else: - return send_command_success_result + # side_effect for send command, passes for all other sends (login, logout), but + # fails for send("InfoDomainCommand") + def side_effect(*args, **kwargs): + if args[0] == "InfoDomainCommand": + return send_command_failure_result + else: + return send_command_success_result - mock_send = MagicMock(side_effect=side_effect) - mock_client.return_value.connect = mock_connect - mock_client.return_value.close = mock_close - mock_client.return_value.send = mock_send + mock_send = MagicMock(side_effect=side_effect) + mock_client.return_value.connect = mock_connect + mock_client.return_value.close = mock_close + mock_client.return_value.send = mock_send - with self.assertRaises(RegistryError): - # Create EPPLibWrapper instance and initialize client - wrapper = EPPLibWrapper() - # call send, which should throw a RegistryError (after retry) - wrapper.send("InfoDomainCommand", cleaned=True) - # connect() should be called twice, once during initialization, second time - # during retry - self.assertEquals(mock_connect.call_count, 2) - # close() is called once during retry - mock_close.assert_called_once() - # send() is called 5 times: send(login), send(command) fails, send(logout) - # send(login), send(command) - self.assertEquals(mock_send.call_count, 5) + with self.assertRaises(RegistryError): + # Create EPPLibWrapper instance and initialize client + wrapper = EPPLibWrapper() + # call send, which should throw a RegistryError (after retry) + wrapper.send("InfoDomainCommand", cleaned=True) + # connect() should be called twice, once during initialization, second time + # during retry + self.assertEquals(mock_connect.call_count, 2) + # close() is called once during retry + mock_close.assert_called_once() + # send() is called 5 times: send(login), send(command) fails, send(logout) + # send(login), send(command) + self.assertEquals(mock_send.call_count, 5) + @less_console_noise_decorator @patch("epplibwrapper.client.Client") def test_send_command_failure_prompts_successful_retry(self, mock_client): """Test when the send("InfoDomainCommand) call fails with a 2400, prompting a retry @@ -229,40 +231,40 @@ class TestClient(TestCase): Initialization succeeds Send command fails (with 2400 code) prompting retry Client closes and re-initializes, and command succeeds""" - with less_console_noise(): - # Mock the Client instance and its methods - # connect() and close() should succeed throughout - mock_connect = MagicMock() - mock_close = MagicMock() - # create success and failure result messages - send_command_success_result = self.fake_result(1000, "Command completed successfully") - send_command_failure_result = self.fake_result(2400, "Command failed") - # side_effect for send call, initial send(login) succeeds during initialization, next send(command) - # fails, subsequent sends (logout, login, command) all succeed - send_call_count = 0 + # Mock the Client instance and its methods + # connect() and close() should succeed throughout + mock_connect = MagicMock() + mock_close = MagicMock() + # create success and failure result messages + send_command_success_result = self.fake_result(1000, "Command completed successfully") + send_command_failure_result = self.fake_result(2400, "Command failed") + # side_effect for send call, initial send(login) succeeds during initialization, next send(command) + # fails, subsequent sends (logout, login, command) all succeed + send_call_count = 0 - def side_effect(*args, **kwargs): - nonlocal send_call_count - send_call_count += 1 - if send_call_count == 2: - return send_command_failure_result - else: - return send_command_success_result + def side_effect(*args, **kwargs): + nonlocal send_call_count + send_call_count += 1 + if send_call_count == 2: + return send_command_failure_result + else: + return send_command_success_result - mock_send = MagicMock(side_effect=side_effect) - mock_client.return_value.connect = mock_connect - mock_client.return_value.close = mock_close - mock_client.return_value.send = mock_send - # Create EPPLibWrapper instance and initialize client - wrapper = EPPLibWrapper() - wrapper.send("InfoDomainCommand", cleaned=True) - # connect() is called twice, once during initialization of app, once during retry - self.assertEquals(mock_connect.call_count, 2) - # close() is called once, during retry - mock_close.assert_called_once() - # send() is called 5 times: send(login), send(command) fail, send(logout), send(login), send(command) - self.assertEquals(mock_send.call_count, 5) + mock_send = MagicMock(side_effect=side_effect) + mock_client.return_value.connect = mock_connect + mock_client.return_value.close = mock_close + mock_client.return_value.send = mock_send + # Create EPPLibWrapper instance and initialize client + wrapper = EPPLibWrapper() + wrapper.send("InfoDomainCommand", cleaned=True) + # connect() is called twice, once during initialization of app, once during retry + self.assertEquals(mock_connect.call_count, 2) + # close() is called once, during retry + mock_close.assert_called_once() + # send() is called 5 times: send(login), send(command) fail, send(logout), send(login), send(command) + self.assertEquals(mock_send.call_count, 5) + @less_console_noise_decorator def fake_failure_send_concurrent_threads(self, command=None, cleaned=None): """ Raises a ConcurrentObjectUseError, which gevent throws when accessing @@ -277,6 +279,7 @@ class TestClient(TestCase): """ pass # noqa + @less_console_noise_decorator def fake_success_send(self, command=None, cleaned=None): """ Simulates receiving a success response from EPP. @@ -292,6 +295,7 @@ class TestClient(TestCase): ) return mock + @less_console_noise_decorator def fake_info_domain_received(self, command=None, cleaned=None): """ Simulates receiving a response by reading from a predefined XML file. @@ -300,6 +304,7 @@ class TestClient(TestCase): xml = (location).read_bytes() return xml + @less_console_noise_decorator def get_fake_epp_result(self): """Mimics a return from EPP by returning a dictionary in the same format""" result = { @@ -338,6 +343,7 @@ class TestClient(TestCase): } return result + @less_console_noise_decorator def test_send_command_close_failure_recovers(self): """ Validates the resilience of the connection handling mechanism @@ -350,7 +356,6 @@ class TestClient(TestCase): - Subsequently, the client re-initializes the connection. - A retry of the command execution post-reinitialization succeeds. """ - expected_result = self.get_fake_epp_result() wrapper = None # Trigger a retry diff --git a/src/package-lock.json b/src/package-lock.json index 2ff464d5e..08e70dd51 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@uswds/uswds": "^3.8.0", + "@uswds/uswds": "^3.8.1", "pa11y-ci": "^3.0.1", "sass": "^1.54.8" }, @@ -187,9 +187,10 @@ } }, "node_modules/@uswds/uswds": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@uswds/uswds/-/uswds-3.8.0.tgz", - "integrity": "sha512-rMwCXe/u4HGkfskvS1Iuabapi/EXku3ChaIFW7y/dUhc7R1TXQhbbfp8YXEjmXPF0yqJnv9T08WPgS0fQqWZ8w==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@uswds/uswds/-/uswds-3.8.1.tgz", + "integrity": "sha512-bKG/B9mJF1v0yoqth48wQDzST5Xyu3OxxpePIPDyhKWS84oDrCehnu3Z88JhSjdIAJMl8dtjtH8YvdO9kZUpAg==", + "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "classlist-polyfill": "1.2.0", "object-assign": "4.1.1", diff --git a/src/package.json b/src/package.json index 58ae3a4ed..e16bc8198 100644 --- a/src/package.json +++ b/src/package.json @@ -10,11 +10,11 @@ "author": "", "license": "ISC", "dependencies": { - "@uswds/uswds": "^3.8.0", + "@uswds/uswds": "^3.8.1", "pa11y-ci": "^3.0.1", "sass": "^1.54.8" }, "devDependencies": { "@uswds/compile": "^1.0.0-beta.3" } -} \ No newline at end of file +} diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 0bfd70a47..d2b9ec4c7 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -9,6 +9,9 @@ from django.db.models.functions import Concat, Coalesce from django.http import HttpResponseRedirect from django.shortcuts import redirect from django_fsm import get_available_FIELD_transitions, FSMField +from registrar.models.domain_group import DomainGroup +from registrar.models.suborganization import Suborganization +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from waffle.decorators import flag_is_active from django.contrib import admin, messages from django.contrib.auth.admin import UserAdmin as BaseUserAdmin @@ -129,12 +132,12 @@ class MyUserAdminForm(UserChangeForm): "groups": NoAutocompleteFilteredSelectMultiple("groups", False), "user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False), "portfolio_roles": FilteredSelectMultipleArrayWidget( - "portfolio_roles", is_stacked=False, choices=User.UserPortfolioRoleChoices.choices + "portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices ), "portfolio_additional_permissions": FilteredSelectMultipleArrayWidget( "portfolio_additional_permissions", is_stacked=False, - choices=User.UserPortfolioPermissionChoices.choices, + choices=UserPortfolioPermissionChoices.choices, ), } @@ -167,6 +170,24 @@ class MyUserAdminForm(UserChangeForm): ) +class PortfolioInvitationAdminForm(UserChangeForm): + """This form utilizes the custom widget for its class's ManyToMany UIs.""" + + class Meta: + model = models.PortfolioInvitation + fields = "__all__" + widgets = { + "portfolio_roles": FilteredSelectMultipleArrayWidget( + "portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices + ), + "portfolio_additional_permissions": FilteredSelectMultipleArrayWidget( + "portfolio_additional_permissions", + is_stacked=False, + choices=UserPortfolioPermissionChoices.choices, + ), + } + + class DomainInformationAdminForm(forms.ModelForm): """This form utilizes the custom widget for its class's ManyToMany UIs.""" @@ -1109,10 +1130,9 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin): class SeniorOfficialAdmin(ListHeaderAdmin): """Custom Senior Official Admin class.""" - # NOTE: these are just placeholders. Not part of ACs (haven't been defined yet). Update in future tickets. search_fields = ["first_name", "last_name", "email"] search_help_text = "Search by first name, last name or email." - list_display = ["first_name", "last_name", "email"] + list_display = ["first_name", "last_name", "email", "federal_agency"] # this ordering effects the ordering of results # in autocomplete_fields for Senior Official @@ -1297,6 +1317,56 @@ class DomainInvitationAdmin(ListHeaderAdmin): return super().changelist_view(request, extra_context=extra_context) +class PortfolioInvitationAdmin(ListHeaderAdmin): + """Custom portfolio invitation admin class.""" + + form = PortfolioInvitationAdminForm + + class Meta: + model = models.PortfolioInvitation + fields = "__all__" + + _meta = Meta() + + # Columns + list_display = [ + "email", + "portfolio", + "portfolio_roles", + "portfolio_additional_permissions", + "status", + ] + + # Search + search_fields = [ + "email", + "portfolio__name", + ] + + # Filters + list_filter = ("status",) + + search_help_text = "Search by email or portfolio." + + # Mark the FSM field 'status' as readonly + # to allow admin users to create Domain Invitations + # without triggering the FSM Transition Not Allowed + # error. + readonly_fields = ["status"] + + autocomplete_fields = ["portfolio"] + + change_form_template = "django/admin/email_clipboard_change_form.html" + + # Select portfolio invitations to change -> Portfolio invitations + def changelist_view(self, request, extra_context=None): + if extra_context is None: + extra_context = {} + extra_context["tabtitle"] = "Portfolio invitations" + # Get the filtered values + return super().changelist_view(request, extra_context=extra_context) + + class DomainInformationResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file""" @@ -2765,6 +2835,8 @@ class VerifiedByStaffAdmin(ListHeaderAdmin): class PortfolioAdmin(ListHeaderAdmin): + change_form_template = "django/admin/portfolio_change_form.html" + list_display = ("organization_name", "federal_agency", "creator") search_fields = ["organization_name"] search_help_text = "Search by organization name." @@ -2775,13 +2847,25 @@ class PortfolioAdmin(ListHeaderAdmin): "federal_agency", ] + def change_view(self, request, object_id, form_url="", extra_context=None): + """Add related suborganizations and domain groups""" + obj = self.get_object(request, object_id) + + # ---- Domain Groups + domain_groups = DomainGroup.objects.filter(portfolio=obj) + + # ---- Suborganizations + suborganizations = Suborganization.objects.filter(portfolio=obj) + + extra_context = {"domain_groups": domain_groups, "suborganizations": suborganizations} + return super().change_view(request, object_id, form_url, extra_context) + def save_model(self, request, obj, form, change): if obj.creator is not None: # ---- update creator ---- # Set the creator field to the current admin user obj.creator = request.user if request.user.is_authenticated else None - # ---- update organization name ---- # org name will be the same as federal agency, if it is federal, # otherwise it will be the actual org name. If nothing is entered for @@ -2790,7 +2874,6 @@ class PortfolioAdmin(ListHeaderAdmin): is_federal = obj.organization_type == DomainRequest.OrganizationChoices.FEDERAL if is_federal and obj.organization_name is None: obj.organization_name = obj.federal_agency.agency - super().save_model(request, obj, form, change) @@ -2885,6 +2968,7 @@ admin.site.register(models.PublicContact, PublicContactAdmin) admin.site.register(models.DomainRequest, DomainRequestAdmin) admin.site.register(models.TransitionDomain, TransitionDomainAdmin) admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin) +admin.site.register(models.PortfolioInvitation, PortfolioInvitationAdmin) admin.site.register(models.Portfolio, PortfolioAdmin) admin.site.register(models.DomainGroup, DomainGroupAdmin) admin.site.register(models.Suborganization, SuborganizationAdmin) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index d8bc21899..04f5417b0 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -207,15 +207,11 @@ function addOrRemoveSessionBoolean(name, add){ })(); + /** An IIFE for pages in DjangoAdmin that use a clipboard button */ (function (){ - function copyInnerTextToClipboard(elem) { - let text = elem.innerText - navigator.clipboard.writeText(text) - } - function copyToClipboardAndChangeIcon(button) { // Assuming the input is the previous sibling of the button let input = button.previousElementSibling; @@ -224,7 +220,7 @@ function addOrRemoveSessionBoolean(name, add){ if (input) { navigator.clipboard.writeText(input.value).then(function() { // Change the icon to a checkmark on successful copy - let buttonIcon = button.querySelector('.usa-button__clipboard use'); + let buttonIcon = button.querySelector('.copy-to-clipboard use'); if (buttonIcon) { let currentHref = buttonIcon.getAttribute('xlink:href'); let baseHref = currentHref.split('#')[0]; @@ -233,21 +229,17 @@ function addOrRemoveSessionBoolean(name, add){ buttonIcon.setAttribute('xlink:href', baseHref + '#check'); // Change the button text - nearestSpan = button.querySelector("span") + let nearestSpan = button.querySelector("span") + let original_text = nearestSpan.innerText nearestSpan.innerText = "Copied to clipboard" setTimeout(function() { // Change back to the copy icon buttonIcon.setAttribute('xlink:href', currentHref); - if (button.classList.contains('usa-button__small-text')) { - nearestSpan.innerText = "Copy email"; - } else { - nearestSpan.innerText = "Copy"; - } + nearestSpan.innerText = original_text; }, 2000); } - }).catch(function(error) { console.error('Clipboard copy failed', error); }); @@ -255,7 +247,7 @@ function addOrRemoveSessionBoolean(name, add){ } function handleClipboardButtons() { - clipboardButtons = document.querySelectorAll(".usa-button__clipboard") + clipboardButtons = document.querySelectorAll(".copy-to-clipboard") clipboardButtons.forEach((button) => { // Handle copying the text to your clipboard, @@ -278,20 +270,7 @@ function addOrRemoveSessionBoolean(name, add){ }); } - function handleClipboardLinks() { - let emailButtons = document.querySelectorAll(".usa-button__clipboard-link"); - if (emailButtons){ - emailButtons.forEach((button) => { - button.addEventListener("click", ()=>{ - copyInnerTextToClipboard(button); - }) - }); - } - } - handleClipboardButtons(); - handleClipboardLinks(); - })(); @@ -605,3 +584,169 @@ function initializeWidgetOnList(list, parentId) { } } })(); + + +/** An IIFE for copy summary button (appears in DomainRegistry models) +*/ +(function (){ + const copyButton = document.getElementById('id-copy-to-clipboard-summary'); + + if (copyButton) { + copyButton.addEventListener('click', function() { + /// Generate a rich HTML summary text and copy to clipboard + + //------ Organization Type + const organizationTypeElement = document.getElementById('id_organization_type'); + const organizationType = organizationTypeElement.options[organizationTypeElement.selectedIndex].text; + + //------ Alternative Domains + const alternativeDomainsDiv = document.querySelector('.form-row.field-alternative_domains .readonly'); + const alternativeDomainslinks = alternativeDomainsDiv.querySelectorAll('a'); + const alternativeDomains = Array.from(alternativeDomainslinks).map(link => link.textContent); + + //------ Existing Websites + const existingWebsitesDiv = document.querySelector('.form-row.field-current_websites .readonly'); + const existingWebsiteslinks = existingWebsitesDiv.querySelectorAll('a'); + const existingWebsites = Array.from(existingWebsiteslinks).map(link => link.textContent); + + //------ Additional Contacts + // 1 - Create a hyperlinks map so we can display contact details and also link to the contact + const otherContactsDiv = document.querySelector('.form-row.field-other_contacts .readonly'); + let otherContactLinks = []; + const nameToUrlMap = {}; + if (otherContactsDiv) { + otherContactLinks = otherContactsDiv.querySelectorAll('a'); + otherContactLinks.forEach(link => { + const name = link.textContent.trim(); + const url = link.href; + nameToUrlMap[name] = url; + }); + } + + // 2 - Iterate through contact details and assemble html for summary + let otherContactsSummary = "" + const bulletList = document.createElement('ul'); + + // CASE 1 - Contacts are not in a table (this happens if there is only one or two other contacts) + const contacts = document.querySelectorAll('.field-other_contacts .dja-detail-list dd'); + if (contacts) { + contacts.forEach(contact => { + // Check if the
element is not empty + const name = contact.querySelector('a#contact_info_name')?.innerText; + const title = contact.querySelector('span#contact_info_title')?.innerText; + const email = contact.querySelector('span#contact_info_email')?.innerText; + const phone = contact.querySelector('span#contact_info_phone')?.innerText; + const url = nameToUrlMap[name] || '#'; + // Format the contact information + const listItem = document.createElement('li'); + listItem.innerHTML = `${name}, ${title}, ${email}, ${phone}`; + bulletList.appendChild(listItem); + }); + + } + + // CASE 2 - Contacts are in a table (this happens if there is more than 2 contacts) + const otherContactsTable = document.querySelector('.form-row.field-other_contacts table tbody'); + if (otherContactsTable) { + const otherContactsRows = otherContactsTable.querySelectorAll('tr'); + otherContactsRows.forEach(contactRow => { + // Extract the contact details + const name = contactRow.querySelector('th').textContent.trim(); + const title = contactRow.querySelectorAll('td')[0].textContent.trim(); + const email = contactRow.querySelectorAll('td')[1].textContent.trim(); + const phone = contactRow.querySelectorAll('td')[2].textContent.trim(); + const url = nameToUrlMap[name] || '#'; + // Format the contact information + const listItem = document.createElement('li'); + listItem.innerHTML = `${name}, ${title}, ${email}, ${phone}`; + bulletList.appendChild(listItem); + }); + } + otherContactsSummary += bulletList.outerHTML + + + //------ Requested Domains + const requestedDomainElement = document.getElementById('id_requested_domain'); + const requestedDomain = requestedDomainElement.options[requestedDomainElement.selectedIndex].text; + + //------ Submitter + // Function to extract text by ID and handle missing elements + function extractTextById(id, divElement) { + if (divElement) { + const element = divElement.querySelector(`#${id}`); + return element ? ", " + element.textContent.trim() : ''; + } + return ''; + } + // Extract the submitter name, title, email, and phone number + const submitterDiv = document.querySelector('.form-row.field-submitter'); + const submitterNameElement = document.getElementById('id_submitter'); + const submitterName = submitterNameElement.options[submitterNameElement.selectedIndex].text; + const submitterTitle = extractTextById('contact_info_title', submitterDiv); + const submitterEmail = extractTextById('contact_info_email', submitterDiv); + const submitterPhone = extractTextById('contact_info_phone', submitterDiv); + let submitterInfo = `${submitterName}${submitterTitle}${submitterEmail}${submitterPhone}`; + + + //------ Senior Official + const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official'); + const seniorOfficialElement = document.getElementById('id_senior_official'); + const seniorOfficialName = seniorOfficialElement.options[seniorOfficialElement.selectedIndex].text; + const seniorOfficialTitle = extractTextById('contact_info_title', seniorOfficialDiv); + const seniorOfficialEmail = extractTextById('contact_info_email', seniorOfficialDiv); + const seniorOfficialPhone = extractTextById('contact_info_phone', seniorOfficialDiv); + let seniorOfficialInfo = `${seniorOfficialName}${seniorOfficialTitle}${seniorOfficialEmail}${seniorOfficialPhone}`; + + const html_summary = `Recommendation:
` + + `Organization Type: ${organizationType}
` + + `Requested Domain: ${requestedDomain}
` + + `Current Websites: ${existingWebsites.join(', ')}
` + + `Rationale:
` + + `Alternative Domains: ${alternativeDomains.join(', ')}
` + + `Submitter: ${submitterInfo}
` + + `Senior Official: ${seniorOfficialInfo}
` + + `Other Employees: ${otherContactsSummary}
`; + + //Replace
with \n, then strip out all remaining html tags (replace <...> with '') + const plain_summary = html_summary.replace(/<\/br>|
/g, '\n').replace(/<\/?[^>]+(>|$)/g, ''); + + // Create Blobs with the summary content + const html_blob = new Blob([html_summary], { type: 'text/html' }); + const plain_blob = new Blob([plain_summary], { type: 'text/plain' }); + + // Create a ClipboardItem with the Blobs + const clipboardItem = new ClipboardItem({ + 'text/html': html_blob, + 'text/plain': plain_blob + }); + + // Write the ClipboardItem to the clipboard + navigator.clipboard.write([clipboardItem]).then(() => { + // Change the icon to a checkmark on successful copy + let buttonIcon = copyButton.querySelector('use'); + if (buttonIcon) { + let currentHref = buttonIcon.getAttribute('xlink:href'); + let baseHref = currentHref.split('#')[0]; + + // Append the new icon reference + buttonIcon.setAttribute('xlink:href', baseHref + '#check'); + + // Change the button text + nearestSpan = copyButton.querySelector("span") + original_text = nearestSpan.innerText + nearestSpan.innerText = "Copied to clipboard" + + setTimeout(function() { + // Change back to the copy icon + buttonIcon.setAttribute('xlink:href', currentHref); + nearestSpan.innerText = original_text + }, 2000); + + } + console.log('Summary copied to clipboard successfully!'); + }).catch(err => { + console.error('Failed to copy text: ', err); + }); + }); + } +})(); diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index f83966756..0712da0f7 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -657,6 +657,34 @@ function hideDeletedForms() { }); } +// Checks for if we want to display Urbanization or not +document.addEventListener('DOMContentLoaded', function() { + var stateTerritoryField = document.querySelector('select[name="organization_contact-state_territory"]'); + + if (!stateTerritoryField) { + return; // Exit if the field not found + } + + setupUrbanizationToggle(stateTerritoryField); +}); + +function setupUrbanizationToggle(stateTerritoryField) { + var urbanizationField = document.getElementById('urbanization-field'); + + function toggleUrbanizationField() { + // Checking specifically for Puerto Rico only + if (stateTerritoryField.value === 'PR') { + urbanizationField.style.display = 'block'; + } else { + urbanizationField.style.display = 'none'; + } + } + + toggleUrbanizationField(); + + stateTerritoryField.addEventListener('change', toggleUrbanizationField); +} + /** * An IIFE that attaches a click handler for our dynamic formsets * @@ -1141,6 +1169,8 @@ document.addEventListener('DOMContentLoaded', function() { const statusIndicator = document.querySelector('.domain__filter-indicator'); const statusToggle = document.querySelector('.usa-button--filter'); const noPortfolioFlag = document.getElementById('no-portfolio-js-flag'); + const portfolioElement = document.getElementById('portfolio-js-value'); + const portfolioValue = portfolioElement ? portfolioElement.getAttribute('data-portfolio') : null; /** * Loads rows in the domains list, as well as updates pagination around the domains list @@ -1150,10 +1180,15 @@ document.addEventListener('DOMContentLoaded', function() { * @param {*} order - the sort order {asc, desc} * @param {*} scroll - control for the scrollToElement functionality * @param {*} searchTerm - the search term + * @param {*} portfolio - the portfolio id */ - function loadDomains(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, status = currentStatus, searchTerm = currentSearchTerm) { + function loadDomains(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, status = currentStatus, searchTerm = currentSearchTerm, portfolio = portfolioValue) { // fetch json of page of domains, given params - fetch(`/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}&status=${status}&search_term=${searchTerm}`) + let url = `/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}&status=${status}&search_term=${searchTerm}` + if (portfolio) + url += `&portfolio=${portfolio}` + + fetch(url) .then(response => response.json()) .then(data => { if (data.error) { diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index d7d116046..711bfe960 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -369,9 +369,6 @@ input.admin-confirm-button { padding: 10px 8px; line-height: normal; } - .usa-icon { - top: 2px; - } a.button:active, a.button:focus { text-decoration: none; } @@ -447,15 +444,12 @@ address.margin-top-neg-1__detail-list { } } -td button.usa-button__clipboard-link, address.dja-address-contact-list { +address.dja-address-contact-list { font-size: unset; } address.dja-address-contact-list { color: var(--body-quiet-color); - button.usa-button__clipboard-link { - font-size: unset; - } } // Mimic the normal label size @@ -464,11 +458,18 @@ address.dja-address-contact-list { font-size: 0.875rem; color: var(--body-quiet-color); } +} - address button.usa-button__clipboard-link, td button.usa-button__clipboard-link { - font-size: 0.875rem !important; - } +// Targets the unstyled buttons in the form +.button--clipboard { + color: var(--link-fg); +} +// Targets the DJA buttom with a nested icon +button .usa-icon, +.button .usa-icon, +.button--clipboard .usa-icon { + vertical-align: middle; } .errors span.select2-selection { @@ -663,7 +664,7 @@ address.dja-address-contact-list { align-items: center; - .usa-button__icon { + .usa-button--icon { position: absolute; right: auto; left: 4px; @@ -681,10 +682,6 @@ address.dja-address-contact-list { } } -button.usa-button__clipboard { - color: var(--link-fg); -} - .no-outline-on-click:focus { outline: none !important; } diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index 7a50c3da0..9f8a0cbb6 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -72,6 +72,29 @@ body { } } +.section--outlined__header--no-portfolio { + .section--outlined__search, + .section--outlined__utility-button { + margin-top: units(2); + } + + @include at-media(tablet) { + display: flex; + column-gap: units(3); + + .section--outlined__search, + .section--outlined__utility-button { + margin-top: 0; + } + .section--outlined__search { + flex-grow: 4; + // Align right + max-width: 383px; + margin-left: auto; + } + } +} + .break-word { word-break: break-word; } diff --git a/src/registrar/assets/sass/_theme/_buttons.scss b/src/registrar/assets/sass/_theme/_buttons.scss index 8ec43705f..d246366d8 100644 --- a/src/registrar/assets/sass/_theme/_buttons.scss +++ b/src/registrar/assets/sass/_theme/_buttons.scss @@ -204,4 +204,13 @@ a.usa-button--unstyled:visited { box-shadow: none; } } - \ No newline at end of file + +.usa-icon.usa-icon--big { + margin: 0; + height: 1.5em; + width: 1.5em; +} + +.margin-right-neg-4px { + margin-right: -4px; +} \ No newline at end of file diff --git a/src/registrar/assets/sass/_theme/_forms.scss b/src/registrar/assets/sass/_theme/_forms.scss index c025bdb29..0aedfcdba 100644 --- a/src/registrar/assets/sass/_theme/_forms.scss +++ b/src/registrar/assets/sass/_theme/_forms.scss @@ -82,3 +82,13 @@ legend.float-left-tablet + button.float-right-tablet { color: var(--close-button-hover-bg); } } + +.read-only-label { + font-size: size('body', 'sm'); + color: color('primary'); + margin-bottom: units(0.5); +} + +.read-only-value { + margin-top: units(0); +} diff --git a/src/registrar/assets/sass/_theme/_links.scss b/src/registrar/assets/sass/_theme/_links.scss index e9b71733a..fd1c3dee9 100644 --- a/src/registrar/assets/sass/_theme/_links.scss +++ b/src/registrar/assets/sass/_theme/_links.scss @@ -15,3 +15,4 @@ margin-right: units(0.5); } } + diff --git a/src/registrar/assets/sass/_theme/styles.scss b/src/registrar/assets/sass/_theme/styles.scss index 4775b60c9..f9df015b4 100644 --- a/src/registrar/assets/sass/_theme/styles.scss +++ b/src/registrar/assets/sass/_theme/styles.scss @@ -27,4 +27,4 @@ /*-------------------------------------------------- --- Admin ---------------------------------*/ -@forward "admin"; +@forward "admin"; \ No newline at end of file diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 3da0a104a..9d707a533 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -241,7 +241,6 @@ TEMPLATES = [ "registrar.context_processors.is_demo_site", "registrar.context_processors.is_production", "registrar.context_processors.org_user_status", - "registrar.context_processors.add_portfolio_to_context", "registrar.context_processors.add_path_to_context", "registrar.context_processors.add_has_profile_feature_flag_to_context", "registrar.context_processors.portfolio_permissions", @@ -665,6 +664,7 @@ ALLOWED_HOSTS = [ "getgov-stable.app.cloud.gov", "getgov-staging.app.cloud.gov", "getgov-development.app.cloud.gov", + "getgov-ad.app.cloud.gov", "getgov-ms.app.cloud.gov", "getgov-ag.app.cloud.gov", "getgov-litterbox.app.cloud.gov", diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index b91e0980c..90137c4af 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -9,7 +9,7 @@ from django.urls import include, path from django.views.generic import RedirectView from registrar import views -from registrar.views.admin_views import ( +from registrar.views.report_views import ( ExportDataDomainsGrowth, ExportDataFederal, ExportDataFull, @@ -19,13 +19,13 @@ from registrar.views.admin_views import ( ExportDataUnmanagedDomains, AnalyticsView, ExportDomainRequestDataFull, + ExportDataTypeUser, ) from registrar.views.domain_request import Step from registrar.views.domain_requests_json import get_domain_requests_json from registrar.views.domains_json import get_domains_json from registrar.views.utility import always_404 -from registrar.views.portfolios import PortfolioDomainsView, PortfolioDomainRequestsView, PortfolioOrganizationView from api.views import available, get_current_federal, get_current_full @@ -60,19 +60,19 @@ for step, view in [ urlpatterns = [ path("", views.index, name="home"), path( - "portfolio//domains/", - PortfolioDomainsView.as_view(), - name="portfolio-domains", + "domains/", + views.PortfolioDomainsView.as_view(), + name="domains", ), path( - "portfolio//domain_requests/", - PortfolioDomainRequestsView.as_view(), - name="portfolio-domain-requests", + "requests/", + views.PortfolioDomainRequestsView.as_view(), + name="domain-requests", ), path( - "portfolio//organization/", - PortfolioOrganizationView.as_view(), - name="portfolio-organization", + "organization/", + views.PortfolioOrganizationView.as_view(), + name="organization", ), path( "admin/logout/", @@ -124,6 +124,11 @@ urlpatterns = [ name="analytics", ), path("admin/", admin.site.urls), + path( + "reports/export_data_type_user/", + ExportDataTypeUser.as_view(), + name="export_data_type_user", + ), path( "domain-request//edit/", views.DomainRequestWizard.as_view(), diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py index 06ef07050..ee5f8aee1 100644 --- a/src/registrar/context_processors.py +++ b/src/registrar/context_processors.py @@ -50,10 +50,6 @@ def org_user_status(request): } -def add_portfolio_to_context(request): - return {"portfolio": getattr(request, "portfolio", None)} - - def add_path_to_context(request): return {"path": getattr(request, "path", None)} @@ -65,16 +61,20 @@ def add_has_profile_feature_flag_to_context(request): def portfolio_permissions(request): """Make portfolio permissions for the request user available in global context""" try: - if not request.user or not request.user.is_authenticated: + if not request.user or not request.user.is_authenticated or not flag_is_active(request, "organization_feature"): return { "has_base_portfolio_permission": False, "has_domains_portfolio_permission": False, "has_domain_requests_portfolio_permission": False, + "portfolio": None, + "has_organization_feature_flag": False, } return { "has_base_portfolio_permission": request.user.has_base_portfolio_permission(), "has_domains_portfolio_permission": request.user.has_domains_portfolio_permission(), "has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission(), + "portfolio": request.user.portfolio, + "has_organization_feature_flag": True, } except AttributeError: # Handles cases where request.user might not exist @@ -82,4 +82,6 @@ def portfolio_permissions(request): "has_base_portfolio_permission": False, "has_domains_portfolio_permission": False, "has_domain_requests_portfolio_permission": False, + "portfolio": None, + "has_organization_feature_flag": False, } diff --git a/src/registrar/fixtures_users.py b/src/registrar/fixtures_users.py index 74fd4d15d..7ce63d364 100644 --- a/src/registrar/fixtures_users.py +++ b/src/registrar/fixtures_users.py @@ -22,6 +22,11 @@ class UserFixture: """ ADMINS = [ + { + "username": "aad084c3-66cc-4632-80eb-41cdf5c5bcbf", + "first_name": "Aditi", + "last_name": "Green", + }, { "username": "be17c826-e200-4999-9389-2ded48c43691", "first_name": "Matthew", @@ -120,6 +125,11 @@ class UserFixture: ] STAFF = [ + { + "username": "ffec5987-aa84-411b-a05a-a7ee5cbcde54", + "first_name": "Aditi-Analyst", + "last_name": "Green-Analyst", + }, { "username": "d6bf296b-fac5-47ff-9c12-f88ccc5c1b99", "first_name": "Matthew-Analyst", diff --git a/src/registrar/forms/__init__.py b/src/registrar/forms/__init__.py index a0c71d48c..374b3102f 100644 --- a/src/registrar/forms/__init__.py +++ b/src/registrar/forms/__init__.py @@ -10,3 +10,6 @@ from .domain import ( DomainDsdataFormset, DomainDsdataForm, ) +from .portfolio import ( + PortfolioOrgAddressForm, +) diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index 9b8f1b7fc..02a0724d1 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -458,7 +458,7 @@ class DomainOrgNameAddressForm(forms.ModelForm): # the database fields have blank=True so ModelForm doesn't create # required fields by default. Use this list in __init__ to mark each # of these fields as required - required = ["organization_name", "address_line1", "city", "zipcode"] + required = ["organization_name", "address_line1", "city", "state_territory", "zipcode"] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py new file mode 100644 index 000000000..9362c7bbd --- /dev/null +++ b/src/registrar/forms/portfolio.py @@ -0,0 +1,69 @@ +"""Forms for portfolio.""" + +import logging +from django import forms +from django.core.validators import RegexValidator + +from ..models import DomainInformation, Portfolio + +logger = logging.getLogger(__name__) + + +class PortfolioOrgAddressForm(forms.ModelForm): + """Form for updating the portfolio org mailing address.""" + + zipcode = forms.CharField( + label="Zip code", + validators=[ + RegexValidator( + "^[0-9]{5}(?:-[0-9]{4})?$|^$", + message="Enter a zip code in the required format, like 12345 or 12345-6789.", + ) + ], + ) + + class Meta: + model = Portfolio + fields = [ + "address_line1", + "address_line2", + "city", + "state_territory", + "zipcode", + # "urbanization", + ] + error_messages = { + "address_line1": {"required": "Enter the street address of your organization."}, + "city": {"required": "Enter the city where your organization is located."}, + "state_territory": { + "required": "Select the state, territory, or military post where your organization is located." + }, + } + widgets = { + # We need to set the required attributed for State/territory + # because for this fields we are creating an individual + # instance of the Select. For the other fields we use the for loop to set + # the class's required attribute to true. + "address_line1": forms.TextInput, + "address_line2": forms.TextInput, + "city": forms.TextInput, + "state_territory": forms.Select( + attrs={ + "required": True, + }, + choices=DomainInformation.StateTerritoryChoices.choices, + ), + # "urbanization": forms.TextInput, + } + + # the database fields have blank=True so ModelForm doesn't create + # required fields by default. Use this list in __init__ to mark each + # of these fields as required + required = ["address_line1", "city", "state_territory", "zipcode"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field_name in self.required: + self.fields[field_name].required = True + self.fields["state_territory"].widget.attrs.pop("maxlength", None) + self.fields["zipcode"].widget.attrs.pop("maxlength", None) diff --git a/src/registrar/management/commands/clean_tables.py b/src/registrar/management/commands/clean_tables.py index f0c51390b..5d4439d95 100644 --- a/src/registrar/management/commands/clean_tables.py +++ b/src/registrar/management/commands/clean_tables.py @@ -56,14 +56,27 @@ class Command(BaseCommand): self.clean_table(table_name) def clean_table(self, table_name): - """Delete all rows in the given table""" + """Delete all rows in the given table. + + Delete in batches to be able to handle large tables""" try: # Get the model class dynamically model = apps.get_model("registrar", table_name) - # Use a transaction to ensure database integrity - with transaction.atomic(): - model.objects.all().delete() - logger.info(f"Successfully cleaned table {table_name}") + BATCH_SIZE = 1000 + total_deleted = 0 + + # Get initial batch of primary keys + pks = list(model.objects.values_list("pk", flat=True)[:BATCH_SIZE]) + + while pks: + # Use a transaction to ensure database integrity + with transaction.atomic(): + deleted, _ = model.objects.filter(pk__in=pks).delete() + total_deleted += deleted + logger.debug(f"Deleted {deleted} {table_name}s, total deleted: {total_deleted}") + # Get the next batch of primary keys + pks = list(model.objects.values_list("pk", flat=True)[:BATCH_SIZE]) + logger.info(f"Successfully cleaned table {table_name}, deleted {total_deleted} rows") except LookupError: logger.error(f"Model for table {table_name} not found.") except Exception as e: diff --git a/src/registrar/management/commands/load_senior_official_table.py b/src/registrar/management/commands/load_senior_official_table.py new file mode 100644 index 000000000..cbfe479ea --- /dev/null +++ b/src/registrar/management/commands/load_senior_official_table.py @@ -0,0 +1,123 @@ +import argparse +import csv +import logging +import os +from django.core.management import BaseCommand +from registrar.management.commands.utility.terminal_helper import TerminalHelper, TerminalColors +from registrar.models import SeniorOfficial, FederalAgency + + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + + help = """Populates the SeniorOfficial table based off of a given csv""" + + def add_arguments(self, parser): + """Add command line arguments.""" + parser.add_argument("federal_cio_csv_path", help="A csv containing information about federal CIOs") + + def handle(self, federal_cio_csv_path, **kwargs): + """Populates the SeniorOfficial table with data given to it through a CSV""" + + # Check if the provided file path is valid. + if not os.path.isfile(federal_cio_csv_path): + raise argparse.ArgumentTypeError(f"Invalid file path '{federal_cio_csv_path}'") + + TerminalHelper.prompt_for_execution( + system_exit_on_terminate=True, + info_to_inspect=f""" + ==Proposed Changes== + CSV: {federal_cio_csv_path} + + For each item in this CSV, a SeniorOffical record will be added. + + Note: + - If the row is missing SO data - it will not be added. + - Given we can add the row, any blank first_name will be replaced with "-". + """, # noqa: W291 + prompt_title="Do you wish to load records into the SeniorOfficial table?", + ) + logger.info("Updating...") + + # Get all existing data. + self.existing_senior_officials = SeniorOfficial.objects.all().prefetch_related("federal_agency") + self.existing_agencies = FederalAgency.objects.all() + + # Read the CSV + self.added_senior_officials = [] + self.skipped_rows = [] + with open(federal_cio_csv_path, "r") as requested_file: + for row in csv.DictReader(requested_file): + # Note: the csv files we have received do not currently have a phone field. + # However, we will include it in our kwargs because that is the data we are mapping to + # and it seems best to check for the data even if it ends up not being there. + so_kwargs = { + "first_name": row.get("First Name"), + "last_name": row.get("Last Name"), + "title": row.get("Role/Position"), + "email": row.get("Email"), + "phone": row.get("Phone"), + } + + # Clean the returned data + for key, value in so_kwargs.items(): + if isinstance(value, str): + so_kwargs[key] = value.strip() + + # Handle the federal_agency record seperately (db call) + agency_name = row.get("Agency").strip() if row.get("Agency") else None + if agency_name: + so_kwargs["federal_agency"] = self.existing_agencies.filter(agency=agency_name).first() + + # Check if at least one field has a non-empty value + if row and any(so_kwargs.values()): + # Split into a function: C901 'Command.handle' is too complex. + # Doesn't add it to the DB, but just inits a class of SeniorOfficial. + self.create_senior_official(so_kwargs) + else: + self.skipped_rows.append(row) + message = f"Skipping row (no data was found): {row}" + TerminalHelper.colorful_logger(logger.warning, TerminalColors.YELLOW, message) + + # Bulk create the SO fields + if len(self.added_senior_officials) > 0: + SeniorOfficial.objects.bulk_create(self.added_senior_officials) + + added_message = f"Added {len(self.added_senior_officials)} records" + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKBLUE, added_message) + + if len(self.skipped_rows) > 0: + skipped_message = f"Skipped {len(self.skipped_rows)} records" + TerminalHelper.colorful_logger(logger.warning, TerminalColors.MAGENTA, skipped_message) + + def create_senior_official(self, so_kwargs): + """Creates a senior official object from kwargs but does not add it to the DB""" + + # WORKAROUND: Placeholder value for first name, + # as not having these makes it impossible to access through DJA. + old_first_name = so_kwargs["first_name"] + if not so_kwargs["first_name"]: + so_kwargs["first_name"] = "-" + + # Create a new SeniorOfficial object + new_so = SeniorOfficial(**so_kwargs) + + # Store a variable for the console logger + if all([old_first_name, new_so.last_name]): + record_display = new_so + else: + record_display = so_kwargs + + # Before adding this record, check to make sure we aren't adding a duplicate. + duplicate_field = self.existing_senior_officials.filter(**so_kwargs).exists() + if not duplicate_field: + self.added_senior_officials.append(new_so) + message = f"Creating record: {record_display}" + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, message) + else: + # if this field is a duplicate, don't do anything + self.skipped_rows.append(new_so) + message = f"Skipping add on duplicate record: {record_display}" + TerminalHelper.colorful_logger(logger.warning, TerminalColors.YELLOW, message) diff --git a/src/registrar/management/commands/populate_federal_agency_initials_and_fceb.py b/src/registrar/management/commands/populate_federal_agency_initials_and_fceb.py new file mode 100644 index 000000000..506405b78 --- /dev/null +++ b/src/registrar/management/commands/populate_federal_agency_initials_and_fceb.py @@ -0,0 +1,56 @@ +import argparse +import csv +import logging +import os +from django.core.management import BaseCommand +from registrar.management.commands.utility.terminal_helper import TerminalHelper, PopulateScriptTemplate, TerminalColors +from registrar.models import FederalAgency + + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand, PopulateScriptTemplate): + + help = """Populates the initials and fceb fields for FederalAgencies""" + + def add_arguments(self, parser): + """Add command line arguments.""" + parser.add_argument("federal_cio_csv_path", help="A csv containing information about federal CIOs") + + def handle(self, federal_cio_csv_path, **kwargs): + """Loops through each FederalAgency object and attempts to update is_fceb and initials""" + + # Check if the provided file path is valid. + if not os.path.isfile(federal_cio_csv_path): + raise argparse.ArgumentTypeError(f"Invalid file path '{federal_cio_csv_path}'") + + # Returns a dictionary keyed by the agency name containing initials and agency status + self.federal_agency_dict = {} + with open(federal_cio_csv_path, "r") as requested_file: + for row in csv.DictReader(requested_file): + agency_name = row.get("Agency") + if agency_name: + initials = row.get("Initials") + agency_status = row.get("Agency Status") + self.federal_agency_dict[agency_name.strip()] = (initials, agency_status) + + # Update every federal agency record + self.mass_update_records(FederalAgency, {"agency__isnull": False}, ["initials", "is_fceb"]) + + def update_record(self, record: FederalAgency): + """For each record, update the initials and is_fceb field if data exists for it""" + initials, agency_status = self.federal_agency_dict.get(record.agency) + + record.initials = initials + if agency_status and isinstance(agency_status, str) and agency_status.strip().upper() == "FCEB": + record.is_fceb = True + else: + record.is_fceb = False + + message = f"Updating {record} => initials: {initials} | is_fceb: {record.is_fceb}" + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, message) + + def should_skip_record(self, record) -> bool: + """Skip record update if there is no data for that particular agency""" + return record.agency not in self.federal_agency_dict diff --git a/src/registrar/management/commands/utility/terminal_helper.py b/src/registrar/management/commands/utility/terminal_helper.py index 82821bd70..2c69e1080 100644 --- a/src/registrar/management/commands/utility/terminal_helper.py +++ b/src/registrar/management/commands/utility/terminal_helper.py @@ -317,6 +317,7 @@ class TerminalHelper: case _: logger.info(print_statement) + # TODO - "info_to_inspect" should be refactored to "prompt_message" @staticmethod def prompt_for_execution(system_exit_on_terminate: bool, info_to_inspect: str, prompt_title: str) -> bool: """Create to reduce code complexity. @@ -373,3 +374,26 @@ class TerminalHelper: logger.info(f"{TerminalColors.MAGENTA}Writing to file " f" {filepath}..." f"{TerminalColors.ENDC}") with open(f"{filepath}", "w+") as f: f.write(file_contents) + + @staticmethod + def colorful_logger(log_level, color, message): + """Adds some color to your log output. + + Args: + log_level: str | Logger.method -> Desired log level. ex: logger.info or "INFO" + color: str | TerminalColors -> Output color. ex: TerminalColors.YELLOW or "YELLOW" + message: str -> Message to display. + """ + + if isinstance(log_level, str) and hasattr(logger, log_level.lower()): + log_method = getattr(logger, log_level.lower()) + else: + log_method = log_level + + if isinstance(color, str) and hasattr(TerminalColors, color.upper()): + terminal_color = getattr(TerminalColors, color.upper()) + else: + terminal_color = color + + colored_message = f"{terminal_color}{message}{TerminalColors.ENDC}" + log_method(colored_message) diff --git a/src/registrar/migrations/0114_alter_user_portfolio_additional_permissions.py b/src/registrar/migrations/0114_alter_user_portfolio_additional_permissions.py new file mode 100644 index 000000000..55645298f --- /dev/null +++ b/src/registrar/migrations/0114_alter_user_portfolio_additional_permissions.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.10 on 2024-07-25 12:45 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0113_user_portfolio_user_portfolio_additional_permissions_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="portfolio_additional_permissions", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("view_all_domains", "View all domains and domain reports"), + ("view_managed_domains", "View managed domains"), + ("view_member", "View members"), + ("edit_member", "Create and edit members"), + ("view_all_requests", "View all requests"), + ("view_created_requests", "View created requests"), + ("edit_requests", "Create and edit requests"), + ("view_portfolio", "View organization"), + ("edit_portfolio", "Edit organization"), + ], + max_length=50, + ), + blank=True, + help_text="Select one or more additional permissions.", + null=True, + size=None, + ), + ), + ] diff --git a/src/registrar/migrations/0115_portfolioinvitation.py b/src/registrar/migrations/0115_portfolioinvitation.py new file mode 100644 index 000000000..82a171f10 --- /dev/null +++ b/src/registrar/migrations/0115_portfolioinvitation.py @@ -0,0 +1,83 @@ +# Generated by Django 4.2.10 on 2024-08-01 12:28 + +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import django_fsm + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0114_alter_user_portfolio_additional_permissions"), + ] + + operations = [ + migrations.CreateModel( + name="PortfolioInvitation", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("email", models.EmailField(max_length=254)), + ( + "portfolio_roles", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("organization_admin", "Admin"), + ("organization_admin_read_only", "Admin read only"), + ("organization_member", "Member"), + ], + max_length=50, + ), + blank=True, + help_text="Select one or more roles.", + null=True, + size=None, + ), + ), + ( + "portfolio_additional_permissions", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("view_all_domains", "View all domains and domain reports"), + ("view_managed_domains", "View managed domains"), + ("view_member", "View members"), + ("edit_member", "Create and edit members"), + ("view_all_requests", "View all requests"), + ("view_created_requests", "View created requests"), + ("edit_requests", "Create and edit requests"), + ("view_portfolio", "View organization"), + ("edit_portfolio", "Edit organization"), + ], + max_length=50, + ), + blank=True, + help_text="Select one or more additional permissions.", + null=True, + size=None, + ), + ), + ( + "status", + django_fsm.FSMField( + choices=[("invited", "Invited"), ("retrieved", "Retrieved")], + default="invited", + max_length=50, + protected=True, + ), + ), + ( + "portfolio", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="portfolios", to="registrar.portfolio" + ), + ), + ], + options={ + "indexes": [models.Index(fields=["status"], name="registrar_p_status_aa4218_idx")], + }, + ), + ] diff --git a/src/registrar/migrations/0116_federalagency_initials_federalagency_is_fceb_and_more.py b/src/registrar/migrations/0116_federalagency_initials_federalagency_is_fceb_and_more.py new file mode 100644 index 000000000..00ceaea44 --- /dev/null +++ b/src/registrar/migrations/0116_federalagency_initials_federalagency_is_fceb_and_more.py @@ -0,0 +1,53 @@ +# Generated by Django 4.2.10 on 2024-08-06 19:54 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0115_portfolioinvitation"), + ] + + operations = [ + migrations.AddField( + model_name="federalagency", + name="initials", + field=models.CharField(blank=True, help_text="Agency initials", max_length=10, null=True), + ), + migrations.AddField( + model_name="federalagency", + name="is_fceb", + field=models.BooleanField( + blank=True, help_text="Determines if this agency is FCEB", null=True, verbose_name="FCEB" + ), + ), + migrations.AddField( + model_name="seniorofficial", + name="federal_agency", + field=models.ForeignKey( + blank=True, + help_text="The federal agency this user is associated with", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="so_federal_agency", + to="registrar.federalagency", + ), + ), + migrations.AlterField( + model_name="seniorofficial", + name="first_name", + field=models.CharField(blank=True, null=True, verbose_name="first name"), + ), + migrations.AlterField( + model_name="seniorofficial", + name="last_name", + field=models.CharField(blank=True, null=True, verbose_name="last name"), + ), + migrations.AlterField( + model_name="seniorofficial", + name="title", + field=models.CharField(blank=True, null=True, verbose_name="title / role"), + ), + ] diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py index a68633aff..1e0aad0b1 100644 --- a/src/registrar/models/__init__.py +++ b/src/registrar/models/__init__.py @@ -1,4 +1,4 @@ -from auditlog.registry import auditlog # type: ignore +from auditlog.registry import auditlog from .contact import Contact from .domain_request import DomainRequest from .domain_information import DomainInformation @@ -16,6 +16,7 @@ from .website import Website from .transition_domain import TransitionDomain from .verified_by_staff import VerifiedByStaff from .waffle_flag import WaffleFlag +from .portfolio_invitation import PortfolioInvitation from .portfolio import Portfolio from .domain_group import DomainGroup from .suborganization import Suborganization @@ -40,6 +41,7 @@ __all__ = [ "TransitionDomain", "VerifiedByStaff", "WaffleFlag", + "PortfolioInvitation", "Portfolio", "DomainGroup", "Suborganization", @@ -63,6 +65,7 @@ auditlog.register(Website) auditlog.register(TransitionDomain) auditlog.register(VerifiedByStaff) auditlog.register(WaffleFlag) +auditlog.register(PortfolioInvitation) auditlog.register(Portfolio) auditlog.register(DomainGroup) auditlog.register(Suborganization) diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index a7252e16b..363de213b 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -215,6 +215,11 @@ class DomainRequest(TimeStampedModel): } return org_election_map + @classmethod + def get_org_label(cls, org_name: str): + # Translating the key that is given to the direct readable value + return cls(org_name).label if org_name else None + class OrganizationChoicesVerbose(models.TextChoices): """ Tertiary organization choices diff --git a/src/registrar/models/federal_agency.py b/src/registrar/models/federal_agency.py index 8db415bbd..5cc87b38c 100644 --- a/src/registrar/models/federal_agency.py +++ b/src/registrar/models/federal_agency.py @@ -25,6 +25,20 @@ class FederalAgency(TimeStampedModel): help_text="Federal agency type (executive, judicial, legislative, etc.)", ) + initials = models.CharField( + max_length=10, + null=True, + blank=True, + help_text="Agency initials", + ) + + is_fceb = models.BooleanField( + null=True, + blank=True, + verbose_name="FCEB", + help_text="Determines if this agency is FCEB", + ) + def __str__(self) -> str: return f"{self.agency}" diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index 7d0500e19..06b01e672 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -6,11 +6,6 @@ from registrar.models.federal_agency import FederalAgency from .utility.time_stamped_model import TimeStampedModel -# def get_default_federal_agency(): -# """returns non-federal agency""" -# return FederalAgency.objects.filter(agency="Non-Federal Agency").first() - - class Portfolio(TimeStampedModel): """ Portfolio is used for organizing domains/domain-requests into diff --git a/src/registrar/models/portfolio_invitation.py b/src/registrar/models/portfolio_invitation.py new file mode 100644 index 000000000..2ad780429 --- /dev/null +++ b/src/registrar/models/portfolio_invitation.py @@ -0,0 +1,95 @@ +"""People are invited by email to administer domains.""" + +import logging + +from django.contrib.auth import get_user_model +from django.db import models + +from django_fsm import FSMField, transition +from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore + +from .utility.time_stamped_model import TimeStampedModel +from django.contrib.postgres.fields import ArrayField + + +logger = logging.getLogger(__name__) + + +class PortfolioInvitation(TimeStampedModel): + class Meta: + """Contains meta information about this class""" + + indexes = [ + models.Index(fields=["status"]), + ] + + # Constants for status field + class PortfolioInvitationStatus(models.TextChoices): + INVITED = "invited", "Invited" + RETRIEVED = "retrieved", "Retrieved" + + email = models.EmailField( + null=False, + blank=False, + ) + + portfolio = models.ForeignKey( + "registrar.Portfolio", + on_delete=models.CASCADE, # delete portfolio, then get rid of invitations + null=False, + related_name="portfolios", + ) + + portfolio_roles = ArrayField( + models.CharField( + max_length=50, + choices=UserPortfolioRoleChoices.choices, + ), + null=True, + blank=True, + help_text="Select one or more roles.", + ) + + portfolio_additional_permissions = ArrayField( + models.CharField( + max_length=50, + choices=UserPortfolioPermissionChoices.choices, + ), + null=True, + blank=True, + help_text="Select one or more additional permissions.", + ) + + status = FSMField( + choices=PortfolioInvitationStatus.choices, + default=PortfolioInvitationStatus.INVITED, + protected=True, # can't alter state except through transition methods! + ) + + def __str__(self): + return f"Invitation for {self.email} on {self.portfolio} is {self.status}" + + @transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED) + def retrieve(self): + """When an invitation is retrieved, create the corresponding permission. + + Raises: + RuntimeError if no matching user can be found. + """ + + # get a user with this email address + User = get_user_model() + try: + user = User.objects.get(email=self.email) + except User.DoesNotExist: + # should not happen because a matching user should exist before + # we retrieve this invitation + raise RuntimeError("Cannot find the user to retrieve this portfolio invitation.") + + # and create a role for that user on this portfolio + user.portfolio = self.portfolio + if self.portfolio_roles and len(self.portfolio_roles) > 0: + user.portfolio_roles = self.portfolio_roles + if self.portfolio_additional_permissions and len(self.portfolio_additional_permissions) > 0: + user.portfolio_additional_permissions = self.portfolio_additional_permissions + user.save() diff --git a/src/registrar/models/senior_official.py b/src/registrar/models/senior_official.py index 3cb064790..38ce4f35d 100644 --- a/src/registrar/models/senior_official.py +++ b/src/registrar/models/senior_official.py @@ -12,30 +12,43 @@ class SeniorOfficial(TimeStampedModel): """ first_name = models.CharField( - null=False, - blank=False, + null=True, + blank=True, verbose_name="first name", ) + last_name = models.CharField( - null=False, - blank=False, + null=True, + blank=True, verbose_name="last name", ) + title = models.CharField( - null=False, - blank=False, + null=True, + blank=True, verbose_name="title / role", ) + phone = PhoneNumberField( null=True, blank=True, ) + email = models.EmailField( null=True, blank=True, max_length=320, ) + federal_agency = models.ForeignKey( + "registrar.FederalAgency", + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="so_federal_agency", + help_text="The federal agency this user is associated with", + ) + def get_formatted_name(self): """Returns the contact's name in Western order.""" names = [n for n in [self.first_name, self.last_name] if n] diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index b135e30c7..bd2af40b7 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -5,8 +5,10 @@ from django.db import models from django.db.models import Q from registrar.models.user_domain_role import UserDomainRole +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from .domain_invitation import DomainInvitation +from .portfolio_invitation import PortfolioInvitation from .transition_domain import TransitionDomain from .verified_by_staff import VerifiedByStaff from .domain import Domain @@ -62,36 +64,6 @@ class User(AbstractUser): # after they login. FIXTURE_USER = "fixture_user", "Created by fixtures" - class UserPortfolioRoleChoices(models.TextChoices): - """ - Roles make it easier for admins to look at - """ - - ORGANIZATION_ADMIN = "organization_admin", "Admin" - ORGANIZATION_ADMIN_READ_ONLY = "organization_admin_read_only", "Admin read only" - ORGANIZATION_MEMBER = "organization_member", "Member" - - class UserPortfolioPermissionChoices(models.TextChoices): - """ """ - - VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports" - VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains" - # EDIT_DOMAINS is really self.domains. We add is hear and leverage it in has_permission - # so we have one way to test for portfolio and domain edit permissions - # Do we need to check for portfolio domains specifically? - # NOTE: A user on an org can currently invite a user outside the org - EDIT_DOMAINS = "edit_domains", "User is a manager on a domain" - - VIEW_MEMBER = "view_member", "View members" - EDIT_MEMBER = "edit_member", "Create and edit members" - - VIEW_ALL_REQUESTS = "view_all_requests", "View all requests" - VIEW_CREATED_REQUESTS = "view_created_requests", "View created requests" - EDIT_REQUESTS = "edit_requests", "Create and edit requests" - - VIEW_PORTFOLIO = "view_portfolio", "View organization" - EDIT_PORTFOLIO = "edit_portfolio", "Edit organization" - PORTFOLIO_ROLE_PERMISSIONS = { UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, @@ -268,11 +240,6 @@ class User(AbstractUser): def _has_portfolio_permission(self, portfolio_permission): """The views should only call this function when testing for perms and not rely on roles.""" - # EDIT_DOMAINS === user is a manager on a domain (has UserDomainRole) - # NOTE: Should we check whether the domain is in the portfolio? - if portfolio_permission == self.UserPortfolioPermissionChoices.EDIT_DOMAINS and self.domains.exists(): - return True - if not self.portfolio: return False @@ -280,27 +247,23 @@ class User(AbstractUser): return portfolio_permission in portfolio_permissions - # the methods below are checks for individual portfolio permissions. they are defined here + # the methods below are checks for individual portfolio permissions. They are defined here # to make them easier to call elsewhere throughout the application def has_base_portfolio_permission(self): - return self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO) + return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_PORTFOLIO) + + def has_edit_org_portfolio_permission(self): + return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_PORTFOLIO) def has_domains_portfolio_permission(self): - return ( - self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS) - or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS) - # or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.EDIT_DOMAINS) - ) - - def has_edit_domains_portfolio_permission(self): - return self._has_portfolio_permission(User.UserPortfolioPermissionChoices.EDIT_DOMAINS) + return self._has_portfolio_permission( + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS + ) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS) def has_domain_requests_portfolio_permission(self): - return ( - self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS) - or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS) - # or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.EDIT_REQUESTS) - ) + return self._has_portfolio_permission( + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS + ) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS) @classmethod def needs_identity_verification(cls, email, uuid): @@ -409,6 +372,24 @@ class User(AbstractUser): new_domain_invitation = DomainInvitation(email=transition_domain_email.lower(), domain=new_domain) new_domain_invitation.save() + def check_portfolio_invitations_on_login(self): + """When a user first arrives on the site, we need to retrieve any portfolio + invitations that match their email address.""" + for invitation in PortfolioInvitation.objects.filter( + email__iexact=self.email, status=PortfolioInvitation.PortfolioInvitationStatus.INVITED + ): + if self.portfolio is None: + try: + invitation.retrieve() + invitation.save() + except RuntimeError: + # retrieving should not fail because of a missing user, but + # if it does fail, log the error so a new user can continue + # logging in + logger.warn("Failed to retrieve invitation %s", invitation, exc_info=True) + else: + logger.warn("User already has a portfolio, did not retrieve invitation %s", invitation, exc_info=True) + def on_each_login(self): """Callback each time the user is authenticated. @@ -420,6 +401,7 @@ class User(AbstractUser): """ self.check_domain_invitations_on_login() + self.check_portfolio_invitations_on_login() def is_org_user(self, request): has_organization_feature_flag = flag_is_active(request, "organization_feature") diff --git a/src/registrar/models/utility/generic_helper.py b/src/registrar/models/utility/generic_helper.py index f9d4303c4..4f74b4163 100644 --- a/src/registrar/models/utility/generic_helper.py +++ b/src/registrar/models/utility/generic_helper.py @@ -170,18 +170,11 @@ 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.debug( - "create_or_update_organization_type() -> is_election_board " - f"cannot exist for {generic_org_type}. Setting to None." - ) self.instance.is_election_board = None self.instance.organization_type = generic_org_type else: # This can only happen with manual data tinkering, which causes these to be out of sync. if self.instance.is_election_board is None: - logger.warning( - "create_or_update_organization_type() -> is_election_board is out of sync. Updating value." - ) self.instance.is_election_board = False if self.instance.is_election_board: @@ -218,10 +211,6 @@ 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( - "create_or_update_organization_type() -> is_election_board " - f"cannot exist for {current_org_type}. Setting to None." - ) self.instance.is_election_board = None else: # if self.instance.organization_type is set to None, then this means diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py new file mode 100644 index 000000000..70977f312 --- /dev/null +++ b/src/registrar/models/utility/portfolio_helper.py @@ -0,0 +1,28 @@ +from django.db import models + + +class UserPortfolioRoleChoices(models.TextChoices): + """ + Roles make it easier for admins to look at + """ + + ORGANIZATION_ADMIN = "organization_admin", "Admin" + ORGANIZATION_ADMIN_READ_ONLY = "organization_admin_read_only", "Admin read only" + ORGANIZATION_MEMBER = "organization_member", "Member" + + +class UserPortfolioPermissionChoices(models.TextChoices): + """ """ + + VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports" + VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains" + + VIEW_MEMBER = "view_member", "View members" + EDIT_MEMBER = "edit_member", "Create and edit members" + + VIEW_ALL_REQUESTS = "view_all_requests", "View all requests" + VIEW_CREATED_REQUESTS = "view_created_requests", "View created requests" + EDIT_REQUESTS = "edit_requests", "Create and edit requests" + + VIEW_PORTFOLIO = "view_portfolio", "View organization" + EDIT_PORTFOLIO = "edit_portfolio", "Edit organization" diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py index dd9b5541a..2af331bc9 100644 --- a/src/registrar/registrar_middleware.py +++ b/src/registrar/registrar_middleware.py @@ -149,10 +149,10 @@ class CheckPortfolioMiddleware: request.portfolio = portfolio if request.user.has_domains_portfolio_permission(): - portfolio_redirect = reverse("portfolio-domains", kwargs={"portfolio_id": portfolio.id}) + portfolio_redirect = reverse("domains") else: # View organization is the lowest access - portfolio_redirect = reverse("portfolio-organization", kwargs={"portfolio_id": portfolio.id}) + portfolio_redirect = reverse("organization") return HttpResponseRedirect(portfolio_redirect) diff --git a/src/registrar/templates/admin/change_form.html b/src/registrar/templates/admin/change_form.html index 78dac9ac0..f2ac7f2df 100644 --- a/src/registrar/templates/admin/change_form.html +++ b/src/registrar/templates/admin/change_form.html @@ -1,4 +1,5 @@ {% extends "admin/change_form.html" %} +{% load static i18n %} {% comment %} Replace the Django ul markup with a div. We'll edit the child markup accordingly in change_form_object_tools {% endcomment %} {% block object-tools %} @@ -9,4 +10,4 @@ {% endblock %} {% endif %} -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/src/registrar/templates/admin/change_form_object_tools.html b/src/registrar/templates/admin/change_form_object_tools.html index 28c655bbc..198140c19 100644 --- a/src/registrar/templates/admin/change_form_object_tools.html +++ b/src/registrar/templates/admin/change_form_object_tools.html @@ -1,4 +1,5 @@ {% load i18n admin_urls %} +{% load i18n static %} {% comment %} Replace li with p for more semantic HTML if we have a single child {% endcomment %} {% block object-tools-items %} @@ -13,8 +14,21 @@ {% else %} -

- {% translate "History" %} -

+ {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} + diff --git a/src/registrar/templates/admin/input_with_clipboard.html b/src/registrar/templates/admin/input_with_clipboard.html index 20a029bed..ea2fbce33 100644 --- a/src/registrar/templates/admin/input_with_clipboard.html +++ b/src/registrar/templates/admin/input_with_clipboard.html @@ -8,7 +8,7 @@ Template for an input field with a clipboard
{{ field }}
+{% endblock %} diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html index 08137c094..414b28a22 100644 --- a/src/registrar/templates/domain_detail.html +++ b/src/registrar/templates/domain_detail.html @@ -40,39 +40,50 @@ {% include "includes/domain_dates.html" %} + {% if is_portfolio_user and not is_domain_manager %} +
+
+

+ To manage information for this domain, you must add yourself as a domain manager. +

+
+
+ {% endif %} + + {% url 'domain-dns-nameservers' pk=domain.id as url %} {% if domain.nameservers|length > 0 %} - {% include "includes/summary_item.html" with title='DNS name servers' domains='true' value=domain.nameservers list='true' edit_link=url editable=domain.is_editable %} + {% include "includes/summary_item.html" with title='DNS name servers' domains='true' value=domain.nameservers list='true' edit_link=url editable=is_editable %} {% else %} - {% if domain.is_editable %} + {% if is_editable %}

DNS name servers

No DNS name servers have been added yet. Before your domain can be used we’ll need information about your domain name servers.

Add DNS name servers {% else %} - {% include "includes/summary_item.html" with title='DNS name servers' domains='true' value='' edit_link=url editable=domain.is_editable %} + {% include "includes/summary_item.html" with title='DNS name servers' domains='true' value='' edit_link=url editable=is_editable %} {% endif %} {% endif %} {% url 'domain-org-name-address' pk=domain.id as url %} - {% include "includes/summary_item.html" with title='Organization name and mailing address' value=domain.domain_info address='true' edit_link=url editable=domain.is_editable %} + {% include "includes/summary_item.html" with title='Organization name and mailing address' value=domain.domain_info address='true' edit_link=url editable=is_editable %} {% url 'domain-senior-official' pk=domain.id as url %} - {% include "includes/summary_item.html" with title='Senior official' value=domain.domain_info.senior_official contact='true' edit_link=url editable=domain.is_editable %} + {% include "includes/summary_item.html" with title='Senior official' value=domain.domain_info.senior_official contact='true' edit_link=url editable=is_editable %} {# Conditionally display profile #} {% if not has_profile_feature_flag %} {% url 'domain-your-contact-information' pk=domain.id as url %} - {% include "includes/summary_item.html" with title='Your contact information' value=request.user contact='true' edit_link=url editable=domain.is_editable %} + {% include "includes/summary_item.html" with title='Your contact information' value=request.user contact='true' edit_link=url editable=is_editable %} {% endif %} {% url 'domain-security-email' pk=domain.id as url %} {% if security_email is not None and security_email not in hidden_security_emails%} - {% include "includes/summary_item.html" with title='Security email' value=security_email edit_link=url editable=domain.is_editable %} + {% include "includes/summary_item.html" with title='Security email' value=security_email edit_link=url editable=is_editable %} {% else %} - {% include "includes/summary_item.html" with title='Security email' value='None provided' edit_link=url editable=domain.is_editable %} + {% include "includes/summary_item.html" with title='Security email' value='None provided' edit_link=url editable=is_editable %} {% endif %} {% url 'domain-users' pk=domain.id as url %} - {% include "includes/summary_item.html" with title='Domain managers' users='true' list=True value=domain.permissions.all edit_link=url editable=domain.is_editable %} + {% include "includes/summary_item.html" with title='Domain managers' users='true' list=True value=domain.permissions.all edit_link=url editable=is_editable %} {% endblock %} {# domain_content #} diff --git a/src/registrar/templates/domain_request_dotgov_domain.html b/src/registrar/templates/domain_request_dotgov_domain.html index 60220e014..5864cad29 100644 --- a/src/registrar/templates/domain_request_dotgov_domain.html +++ b/src/registrar/templates/domain_request_dotgov_domain.html @@ -6,8 +6,7 @@
  • Be available
  • Relate to your organization’s name, location, and/or services
  • -
  • Be clear to the general public. Your domain name must not be easily confused - with other organizations.
  • +
  • Be unlikely to mislead or confuse the general public (even if your domain is only intended for a specific audience)

diff --git a/src/registrar/templates/domain_request_org_contact.html b/src/registrar/templates/domain_request_org_contact.html index 21cf19306..f145ee3bf 100644 --- a/src/registrar/templates/domain_request_org_contact.html +++ b/src/registrar/templates/domain_request_org_contact.html @@ -1,5 +1,5 @@ {% extends 'domain_request_form.html' %} -{% load field_helpers url_helpers %} +{% load field_helpers url_helpers static %} {% block form_instructions %}

If your domain request is approved, the name of your organization and your city/state will be listed in .gov’s public data.

@@ -37,7 +37,12 @@ {% input_with_errors forms.0.zipcode %} {% endwith %} - {% input_with_errors forms.0.urbanization %} + {% endblock %} + + + diff --git a/src/registrar/templates/domain_sidebar.html b/src/registrar/templates/domain_sidebar.html index d61e5f45c..603822d0d 100644 --- a/src/registrar/templates/domain_sidebar.html +++ b/src/registrar/templates/domain_sidebar.html @@ -12,7 +12,7 @@ - {% if domain.is_editable %} + {% if is_editable %}
  • {% url 'domain-dns' pk=domain.id as url %} diff --git a/src/registrar/templates/includes/domain_requests_table.html b/src/registrar/templates/includes/domain_requests_table.html index efebd1e28..30c206741 100644 --- a/src/registrar/templates/includes/domain_requests_table.html +++ b/src/registrar/templates/includes/domain_requests_table.html @@ -2,8 +2,7 @@
    - - {% if portfolio is None %} + {% if not has_domain_requests_portfolio_permission %}

    Domain requests

    diff --git a/src/registrar/templates/includes/domains_table.html b/src/registrar/templates/includes/domains_table.html index 3a7aee80b..64eddec41 100644 --- a/src/registrar/templates/includes/domains_table.html +++ b/src/registrar/templates/includes/domains_table.html @@ -1,16 +1,16 @@ {% load static %} -
    -
    - - {% if portfolio is None %} -
    -

    Domains

    -
    +
    +
    + {% if not has_domains_portfolio_permission %} +

    Domains

    + {% else %} + + {% endif %} -
    - - {% if portfolio %} -
    + {% if has_domains_portfolio_permission %} +
    Filter by
    @@ -142,8 +150,7 @@ Domain name Expires Status - - {% if portfolio %} + {% if has_domains_portfolio_permission %} Suborganization {% endif %} - {% with show_edit_button=True show_readonly=True group_classes="usa-form-editable usa-form-editable--no-border padding-top-2" %} + {% with toggleable_input=True toggleable_label=True group_classes="usa-form-editable usa-form-editable--no-border padding-top-2" %} {% input_with_errors form.full_name %} {% endwith %} @@ -53,8 +53,13 @@ {% endwith %}
    + {% with toggleable_input=True toggleable_label=True group_classes="usa-form-editable padding-top-2" %} + {% input_with_errors form.title %} + {% endwith %} + {% public_site_url "help/account-management/#update-your-contact-information" as login_help_url %} - {% with show_readonly=True add_class="display-none" group_classes="usa-form-editable usa-form-editable padding-top-2 bold-usa-label" %} + {% with toggleable_input=True add_class="display-none" group_classes="usa-form-editable usa-form-editable padding-top-2 bold-usa-label" %} + {% with link_href=login_help_url %} {% with sublabel_text="We recommend using your work email for your .gov account. If the wrong email is displayed below, you’ll need to update your Login.gov account and log back in. Get help with updating your contact information." %} {% with link_text="Get help with updating your contact information" target_blank=True do_not_show_max_chars=True %} @@ -64,11 +69,7 @@ {% endwith %} {% endwith %} - {% with show_edit_button=True show_readonly=True group_classes="usa-form-editable padding-top-2" %} - {% input_with_errors form.title %} - {% endwith %} - - {% with show_edit_button=True show_readonly=True group_classes="usa-form-editable padding-top-2" %} + {% with toggleable_input=True toggleable_label=True group_classes="usa-form-editable padding-top-2" %} {% with add_class="usa-input--medium" %} {% input_with_errors form.phone %} {% endwith %} diff --git a/src/registrar/templates/includes/header_extended.html b/src/registrar/templates/includes/header_extended.html index c5fc02ebb..c516b9744 100644 --- a/src/registrar/templates/includes/header_extended.html +++ b/src/registrar/templates/includes/header_extended.html @@ -1,4 +1,5 @@ {% load static %} +{% load custom_filters %}
    @@ -14,8 +15,8 @@
      {% if has_domains_portfolio_permission %}
    • - {% url 'portfolio-domains' portfolio.id as url %} - + {% url 'domains' as url %} + Domains
    • @@ -27,8 +28,8 @@ {% if has_domain_requests_portfolio_permission %}
    • - {% url 'portfolio-domain-requests' portfolio.id as url %} - + {% url 'domain-requests' as url %} + Domain requests
    • @@ -39,7 +40,7 @@
    • - {% url 'portfolio-organization' portfolio.id as url %} + {% url 'organization' as url %} diff --git a/src/registrar/templates/includes/input_read_only.html b/src/registrar/templates/includes/input_read_only.html new file mode 100644 index 000000000..b76f82e8b --- /dev/null +++ b/src/registrar/templates/includes/input_read_only.html @@ -0,0 +1,7 @@ +{% comment %} +Template include for read-only form fields +{% endcomment %} + + +

      {{ field.label }}

      +

      {{ field.value }}

      diff --git a/src/registrar/templates/includes/input_with_errors.html b/src/registrar/templates/includes/input_with_errors.html index 623ec0a33..d1e53968e 100644 --- a/src/registrar/templates/includes/input_with_errors.html +++ b/src/registrar/templates/includes/input_with_errors.html @@ -27,8 +27,8 @@ error messages, if necessary. {% endif %} {% if not field.widget_type == "checkbox" %} - {% if show_edit_button %} - {% include "includes/label_with_edit_button.html" with bold_label=True %} + {% if toggleable_label %} + {% include "includes/toggleable_label.html" with bold_label=True %} {% else %} {% include "django/forms/label.html" %} {% endif %} @@ -63,8 +63,8 @@ error messages, if necessary.
      {% endif %} - {% if show_readonly %} - {% include "includes/readonly_input.html" %} + {% if toggleable_input %} + {% include "includes/toggleable_input.html" %} {% endif %} {# this is the input field, itself #} diff --git a/src/registrar/templates/includes/readonly_input.html b/src/registrar/templates/includes/toggleable_input.html similarity index 100% rename from src/registrar/templates/includes/readonly_input.html rename to src/registrar/templates/includes/toggleable_input.html diff --git a/src/registrar/templates/includes/label_with_edit_button.html b/src/registrar/templates/includes/toggleable_label.html similarity index 100% rename from src/registrar/templates/includes/label_with_edit_button.html rename to src/registrar/templates/includes/toggleable_label.html diff --git a/src/registrar/templates/portfolio_organization.html b/src/registrar/templates/portfolio_organization.html index c7eae7130..51adba3d9 100644 --- a/src/registrar/templates/portfolio_organization.html +++ b/src/registrar/templates/portfolio_organization.html @@ -1,8 +1,75 @@ {% extends 'portfolio_base.html' %} +{% load static field_helpers%} + +{% block title %}Organization mailing address | {{ portfolio.name }} | {% endblock %} {% load static %} {% block portfolio_content %} -

      Organization

      +
      +
      +

      + Portfolio name: {{ portfolio }} +

      + {% include 'portfolio_organization_sidebar.html' %} +
      + +
      + +

      Organization

      + +

      The name of your federal agency will be publicly listed as the domain registrant.

      + + {% if has_edit_org_portfolio_permission %} +

      + The federal agency for your organization can’t be updated here. + To suggest an update, email help@get.gov. +

      + + {% include "includes/form_errors.html" with form=form %} + {% include "includes/required_fields.html" %} +
      + {% csrf_token %} +

      Federal agency

      +

      + {{ portfolio.federal_agency }} +

      + {% input_with_errors form.address_line1 %} + {% input_with_errors form.address_line2 %} + {% input_with_errors form.city %} + {% input_with_errors form.state_territory %} + {% with add_class="usa-input--small" %} + {% input_with_errors form.zipcode %} + {% endwith %} + +
      + {% else %} +

      Federal agency

      +

      + {{ portfolio.federal_agency }} +

      + {% if form.address_line1.value is not None %} + {% include "includes/input_read_only.html" with field=form.address_line1 %} + {% endif %} + {% if form.address_line2.value is not None %} + {% include "includes/input_read_only.html" with field=form.address_line2 %} + {% endif %} + {% if form.city.value is not None %} + {% include "includes/input_read_only.html" with field=form.city %} + {% endif %} + {% if form.state_territory.value is not None %} + {% include "includes/input_read_only.html" with field=form.state_territory %} + {% endif %} + {% if form.zipcode.value is not None %} + {% include "includes/input_read_only.html" with field=form.zipcode %} + {% endif %} + {% endif %} + +
      +
      {% endblock %} diff --git a/src/registrar/templates/portfolio_organization_sidebar.html b/src/registrar/templates/portfolio_organization_sidebar.html new file mode 100644 index 000000000..cfcdff3a8 --- /dev/null +++ b/src/registrar/templates/portfolio_organization_sidebar.html @@ -0,0 +1,23 @@ +{% load static url_helpers %} + +
      + +
      diff --git a/src/registrar/templatetags/custom_filters.py b/src/registrar/templatetags/custom_filters.py index 62afa1acb..8338eaf9d 100644 --- a/src/registrar/templatetags/custom_filters.py +++ b/src/registrar/templatetags/custom_filters.py @@ -145,3 +145,8 @@ def format_phone(value): phone_number = PhoneNumber.from_string(value) return phone_number.as_national return value + + +@register.filter +def in_path(url, path): + return url in path diff --git a/src/registrar/templatetags/field_helpers.py b/src/registrar/templatetags/field_helpers.py index b72f77e21..68a803711 100644 --- a/src/registrar/templatetags/field_helpers.py +++ b/src/registrar/templatetags/field_helpers.py @@ -26,7 +26,7 @@ def input_with_errors(context, field=None): # noqa: C901 add_group_class: append to input element's surrounding tag's `class` attribute attr_* - adds or replaces any single html attribute for the input add_error_attr_* - like `attr_*` but only if field.errors is not empty - show_edit_button: shows a simple edit button, and adds display-none to the input field. + toggleable_input: shows a simple edit button, and adds display-none to the input field. Example usage: ``` @@ -92,7 +92,7 @@ def input_with_errors(context, field=None): # noqa: C901 elif key == "add_group_class": group_classes.append(value) - elif key == "show_edit_button": + elif key == "toggleable_input": # Hide the primary input field. # Used such that we can toggle it with JS if "display-none" not in classes: diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index b005211a6..a4c3e2ef4 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -41,6 +41,8 @@ from registrar.models.user_domain_role import UserDomainRole from registrar.models.utility.contact_error import ContactError, ContactErrorCodes +from api.tests.common import less_console_noise_decorator + logger = logging.getLogger(__name__) @@ -525,230 +527,230 @@ class AuditedAdminMockData: class MockDb(TestCase): - """Hardcoded mocks make test case assertions straightforward.""" - def setUp(self): - super().setUp() + @classmethod + @less_console_noise_decorator + def sharedSetUp(cls): username = "test_user" first_name = "First" last_name = "Last" email = "info@example.com" - self.user = get_user_model().objects.create( + cls.user = get_user_model().objects.create( username=username, first_name=first_name, last_name=last_name, email=email ) current_date = get_time_aware_date(datetime(2024, 4, 2)) # Create start and end dates using timedelta - self.end_date = current_date + timedelta(days=2) - self.start_date = current_date - timedelta(days=2) + cls.end_date = current_date + timedelta(days=2) + cls.start_date = current_date - timedelta(days=2) - self.federal_agency_1, _ = FederalAgency.objects.get_or_create(agency="World War I Centennial Commission") - self.federal_agency_2, _ = FederalAgency.objects.get_or_create(agency="Armed Forces Retirement Home") + cls.federal_agency_1, _ = FederalAgency.objects.get_or_create(agency="World War I Centennial Commission") + cls.federal_agency_2, _ = FederalAgency.objects.get_or_create(agency="Armed Forces Retirement Home") - self.domain_1, _ = Domain.objects.get_or_create( + cls.domain_1, _ = Domain.objects.get_or_create( name="cdomain1.gov", state=Domain.State.READY, first_ready=get_time_aware_date(datetime(2024, 4, 2)) ) - self.domain_2, _ = Domain.objects.get_or_create(name="adomain2.gov", state=Domain.State.DNS_NEEDED) - self.domain_3, _ = Domain.objects.get_or_create(name="ddomain3.gov", state=Domain.State.ON_HOLD) - self.domain_4, _ = Domain.objects.get_or_create(name="bdomain4.gov", state=Domain.State.UNKNOWN) - self.domain_5, _ = Domain.objects.get_or_create( + cls.domain_2, _ = Domain.objects.get_or_create(name="adomain2.gov", state=Domain.State.DNS_NEEDED) + cls.domain_3, _ = Domain.objects.get_or_create(name="ddomain3.gov", state=Domain.State.ON_HOLD) + cls.domain_4, _ = Domain.objects.get_or_create(name="bdomain4.gov", state=Domain.State.UNKNOWN) + cls.domain_5, _ = Domain.objects.get_or_create( name="bdomain5.gov", state=Domain.State.DELETED, deleted=get_time_aware_date(datetime(2023, 11, 1)) ) - self.domain_6, _ = Domain.objects.get_or_create( + cls.domain_6, _ = Domain.objects.get_or_create( name="bdomain6.gov", state=Domain.State.DELETED, deleted=get_time_aware_date(datetime(1980, 10, 16)) ) - self.domain_7, _ = Domain.objects.get_or_create( + cls.domain_7, _ = Domain.objects.get_or_create( name="xdomain7.gov", state=Domain.State.DELETED, deleted=get_time_aware_date(datetime(2024, 4, 2)) ) - self.domain_8, _ = Domain.objects.get_or_create( + cls.domain_8, _ = Domain.objects.get_or_create( name="sdomain8.gov", state=Domain.State.DELETED, deleted=get_time_aware_date(datetime(2024, 4, 2)) ) # We use timezone.make_aware to sync to server time a datetime object with the current date (using date.today()) # and a specific time (using datetime.min.time()). # Deleted yesterday - self.domain_9, _ = Domain.objects.get_or_create( + cls.domain_9, _ = Domain.objects.get_or_create( name="zdomain9.gov", state=Domain.State.DELETED, deleted=get_time_aware_date(datetime(2024, 4, 1)), ) # ready tomorrow - self.domain_10, _ = Domain.objects.get_or_create( + cls.domain_10, _ = Domain.objects.get_or_create( name="adomain10.gov", state=Domain.State.READY, first_ready=get_time_aware_date(datetime(2024, 4, 3)), ) - self.domain_11, _ = Domain.objects.get_or_create( + cls.domain_11, _ = Domain.objects.get_or_create( name="cdomain11.gov", state=Domain.State.READY, first_ready=get_time_aware_date(datetime(2024, 4, 2)) ) - self.domain_12, _ = Domain.objects.get_or_create( + cls.domain_12, _ = Domain.objects.get_or_create( name="zdomain12.gov", state=Domain.State.READY, first_ready=get_time_aware_date(datetime(2024, 4, 2)) ) - self.domain_information_1, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_1, + cls.domain_information_1, _ = DomainInformation.objects.get_or_create( + creator=cls.user, + domain=cls.domain_1, generic_org_type="federal", - federal_agency=self.federal_agency_1, + federal_agency=cls.federal_agency_1, federal_type="executive", is_election_board=False, ) - self.domain_information_2, _ = DomainInformation.objects.get_or_create( - creator=self.user, domain=self.domain_2, generic_org_type="interstate", is_election_board=True + cls.domain_information_2, _ = DomainInformation.objects.get_or_create( + creator=cls.user, domain=cls.domain_2, generic_org_type="interstate", is_election_board=True ) - self.domain_information_3, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_3, + cls.domain_information_3, _ = DomainInformation.objects.get_or_create( + creator=cls.user, + domain=cls.domain_3, generic_org_type="federal", - federal_agency=self.federal_agency_2, + federal_agency=cls.federal_agency_2, is_election_board=False, ) - self.domain_information_4, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_4, + cls.domain_information_4, _ = DomainInformation.objects.get_or_create( + creator=cls.user, + domain=cls.domain_4, generic_org_type="federal", - federal_agency=self.federal_agency_2, + federal_agency=cls.federal_agency_2, is_election_board=False, ) - self.domain_information_5, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_5, + cls.domain_information_5, _ = DomainInformation.objects.get_or_create( + creator=cls.user, + domain=cls.domain_5, generic_org_type="federal", - federal_agency=self.federal_agency_2, + federal_agency=cls.federal_agency_2, is_election_board=False, ) - self.domain_information_6, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_6, + cls.domain_information_6, _ = DomainInformation.objects.get_or_create( + creator=cls.user, + domain=cls.domain_6, generic_org_type="federal", - federal_agency=self.federal_agency_2, + federal_agency=cls.federal_agency_2, is_election_board=False, ) - self.domain_information_7, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_7, + cls.domain_information_7, _ = DomainInformation.objects.get_or_create( + creator=cls.user, + domain=cls.domain_7, generic_org_type="federal", - federal_agency=self.federal_agency_2, + federal_agency=cls.federal_agency_2, is_election_board=False, ) - self.domain_information_8, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_8, + cls.domain_information_8, _ = DomainInformation.objects.get_or_create( + creator=cls.user, + domain=cls.domain_8, generic_org_type="federal", - federal_agency=self.federal_agency_2, + federal_agency=cls.federal_agency_2, is_election_board=False, ) - self.domain_information_9, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_9, + cls.domain_information_9, _ = DomainInformation.objects.get_or_create( + creator=cls.user, + domain=cls.domain_9, generic_org_type="federal", - federal_agency=self.federal_agency_2, + federal_agency=cls.federal_agency_2, is_election_board=False, ) - self.domain_information_10, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_10, + cls.domain_information_10, _ = DomainInformation.objects.get_or_create( + creator=cls.user, + domain=cls.domain_10, generic_org_type="federal", - federal_agency=self.federal_agency_2, + federal_agency=cls.federal_agency_2, is_election_board=False, ) - self.domain_information_11, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_11, + cls.domain_information_11, _ = DomainInformation.objects.get_or_create( + creator=cls.user, + domain=cls.domain_11, generic_org_type="federal", - federal_agency=self.federal_agency_1, + federal_agency=cls.federal_agency_1, federal_type="executive", is_election_board=False, ) - self.domain_information_12, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_12, + cls.domain_information_12, _ = DomainInformation.objects.get_or_create( + creator=cls.user, + domain=cls.domain_12, generic_org_type="interstate", is_election_board=False, ) - self.meoward_user = get_user_model().objects.create( + cls.meoward_user = get_user_model().objects.create( username="meoward_username", first_name="first_meoward", last_name="last_meoward", email="meoward@rocks.com" ) - lebowski_user = get_user_model().objects.create( + cls.lebowski_user = get_user_model().objects.create( username="big_lebowski", first_name="big", last_name="lebowski", email="big_lebowski@dude.co" ) _, created = UserDomainRole.objects.get_or_create( - user=self.meoward_user, domain=self.domain_1, role=UserDomainRole.Roles.MANAGER + user=cls.meoward_user, domain=cls.domain_1, role=UserDomainRole.Roles.MANAGER ) _, created = UserDomainRole.objects.get_or_create( - user=self.user, domain=self.domain_1, role=UserDomainRole.Roles.MANAGER + user=cls.user, domain=cls.domain_1, role=UserDomainRole.Roles.MANAGER ) _, created = UserDomainRole.objects.get_or_create( - user=lebowski_user, domain=self.domain_1, role=UserDomainRole.Roles.MANAGER + user=cls.lebowski_user, domain=cls.domain_1, role=UserDomainRole.Roles.MANAGER ) _, created = UserDomainRole.objects.get_or_create( - user=self.meoward_user, domain=self.domain_2, role=UserDomainRole.Roles.MANAGER + user=cls.meoward_user, domain=cls.domain_2, role=UserDomainRole.Roles.MANAGER ) _, created = UserDomainRole.objects.get_or_create( - user=self.meoward_user, domain=self.domain_11, role=UserDomainRole.Roles.MANAGER + user=cls.meoward_user, domain=cls.domain_11, role=UserDomainRole.Roles.MANAGER ) _, created = UserDomainRole.objects.get_or_create( - user=self.meoward_user, domain=self.domain_12, role=UserDomainRole.Roles.MANAGER + user=cls.meoward_user, domain=cls.domain_12, role=UserDomainRole.Roles.MANAGER ) _, created = DomainInvitation.objects.get_or_create( - email=self.meoward_user.email, - domain=self.domain_1, + email=cls.meoward_user.email, + domain=cls.domain_1, status=DomainInvitation.DomainInvitationStatus.RETRIEVED, ) _, created = DomainInvitation.objects.get_or_create( email="woofwardthethird@rocks.com", - domain=self.domain_1, + domain=cls.domain_1, status=DomainInvitation.DomainInvitationStatus.INVITED, ) _, created = DomainInvitation.objects.get_or_create( - email="squeaker@rocks.com", domain=self.domain_2, status=DomainInvitation.DomainInvitationStatus.INVITED + email="squeaker@rocks.com", domain=cls.domain_2, status=DomainInvitation.DomainInvitationStatus.INVITED ) _, created = DomainInvitation.objects.get_or_create( - email="squeaker@rocks.com", domain=self.domain_10, status=DomainInvitation.DomainInvitationStatus.INVITED + email="squeaker@rocks.com", domain=cls.domain_10, status=DomainInvitation.DomainInvitationStatus.INVITED ) with less_console_noise(): - self.domain_request_1 = completed_domain_request( + cls.domain_request_1 = completed_domain_request( status=DomainRequest.DomainRequestStatus.STARTED, name="city1.gov", ) - self.domain_request_2 = completed_domain_request( + cls.domain_request_2 = completed_domain_request( status=DomainRequest.DomainRequestStatus.IN_REVIEW, name="city2.gov", ) - self.domain_request_3 = completed_domain_request( + cls.domain_request_3 = completed_domain_request( status=DomainRequest.DomainRequestStatus.STARTED, name="city3.gov", ) - self.domain_request_4 = completed_domain_request( + cls.domain_request_4 = completed_domain_request( status=DomainRequest.DomainRequestStatus.STARTED, name="city4.gov", is_election_board=True, generic_org_type="city", ) - self.domain_request_5 = completed_domain_request( + cls.domain_request_5 = completed_domain_request( status=DomainRequest.DomainRequestStatus.APPROVED, name="city5.gov", ) - self.domain_request_6 = completed_domain_request( + cls.domain_request_6 = completed_domain_request( status=DomainRequest.DomainRequestStatus.STARTED, name="city6.gov", ) - self.domain_request_3.submit() - self.domain_request_4.submit() - self.domain_request_6.submit() + cls.domain_request_3.submit() + cls.domain_request_4.submit() + cls.domain_request_6.submit() other, _ = Contact.objects.get_or_create( first_name="Testy1232", @@ -769,29 +771,56 @@ class MockDb(TestCase): website_3, _ = Website.objects.get_or_create(website="https://www.example.com") website_4, _ = Website.objects.get_or_create(website="https://www.example2.com") - self.domain_request_3.other_contacts.add(other, other_2) - self.domain_request_3.alternative_domains.add(website, website_2) - self.domain_request_3.current_websites.add(website_3, website_4) - self.domain_request_3.cisa_representative_email = "test@igorville.com" - self.domain_request_3.submission_date = get_time_aware_date(datetime(2024, 4, 2)) - self.domain_request_3.save() + cls.domain_request_3.other_contacts.add(other, other_2) + cls.domain_request_3.alternative_domains.add(website, website_2) + cls.domain_request_3.current_websites.add(website_3, website_4) + cls.domain_request_3.cisa_representative_email = "test@igorville.com" + cls.domain_request_3.submission_date = get_time_aware_date(datetime(2024, 4, 2)) + cls.domain_request_3.save() - self.domain_request_4.submission_date = get_time_aware_date(datetime(2024, 4, 2)) - self.domain_request_4.save() + cls.domain_request_4.submission_date = get_time_aware_date(datetime(2024, 4, 2)) + cls.domain_request_4.save() - self.domain_request_6.submission_date = get_time_aware_date(datetime(2024, 4, 2)) - self.domain_request_6.save() + cls.domain_request_6.submission_date = get_time_aware_date(datetime(2024, 4, 2)) + cls.domain_request_6.save() - def tearDown(self): - super().tearDown() + @classmethod + def sharedTearDown(cls): PublicContact.objects.all().delete() Domain.objects.all().delete() DomainInformation.objects.all().delete() DomainRequest.objects.all().delete() - User.objects.all().delete() UserDomainRole.objects.all().delete() + User.objects.all().delete() DomainInvitation.objects.all().delete() - FederalAgency.objects.all().delete() + cls.federal_agency_1.delete() + cls.federal_agency_2.delete() + + +class MockDbForSharedTests(MockDb): + """Set up and tear down test data that is shared across all tests in a class""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.sharedSetUp() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + cls.sharedTearDown() + + +class MockDbForIndividualTests(MockDb): + """Set up and tear down test data for each test in a class""" + + def setUp(self): + super().setUp() + self.sharedSetUp() + + def tearDown(self): + super().tearDown() + self.sharedTearDown() def mock_user(): @@ -840,6 +869,19 @@ def create_user(): return user +def create_test_user(): + username = "test_user" + first_name = "First" + last_name = "Last" + email = "info@example.com" + phone = "8003111234" + title = "test title" + user = get_user_model().objects.create( + username=username, first_name=first_name, last_name=last_name, email=email, phone=phone, title=title + ) + return user + + def create_ready_domain(): domain, _ = Domain.objects.get_or_create(name="city.gov", state=Domain.State.READY) return domain diff --git a/src/registrar/tests/data/fake_federal_cio.csv b/src/registrar/tests/data/fake_federal_cio.csv new file mode 100644 index 000000000..590d1bd75 --- /dev/null +++ b/src/registrar/tests/data/fake_federal_cio.csv @@ -0,0 +1,5 @@ +Contact Status,Email,First Name,Last Name,Role/Position,Initials,Agency,Agency Status,Notes,Active?,Modified,Created +Unconfirmed,fakemrfake@igorville.gov,Jan,Uary,CIO,ABMC,American Battle Monuments Commission,FCEB,,,,, +Import,reggie.ronald@igorville.gov,Reggie,Ronald,CIO,ACHP,Advisory Council on Historic Preservation,FCEB,Some notes field,,, +,,,,,AMTRAK,,,,,, +,,,,,KC,John F. Kennedy Center for Performing Arts,,,,, \ No newline at end of file diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 39559523b..4ec3336ba 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1,17 +1,11 @@ -from datetime import date, datetime +from datetime import datetime from django.utils import timezone -import re -from django.test import TestCase, RequestFactory, Client, override_settings +from django.test import TestCase, RequestFactory, Client from django.contrib.admin.sites import AdminSite -from contextlib import ExitStack from api.tests.common import less_console_noise_decorator -from django_webtest import WebTest # type: ignore -from django.contrib import messages from django.urls import reverse from registrar.admin import ( DomainAdmin, - DomainRequestAdmin, - DomainRequestAdminForm, DomainInvitationAdmin, ListHeaderAdmin, MyUserAdmin, @@ -19,6 +13,7 @@ from registrar.admin import ( ContactAdmin, DomainInformationAdmin, MyHostAdmin, + PortfolioInvitationAdmin, UserDomainRoleAdmin, VerifiedByStaffAdmin, FsmModelResource, @@ -44,12 +39,12 @@ from registrar.models import ( UserGroup, TransitionDomain, ) +from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.senior_official import SeniorOfficial from registrar.models.user_domain_role import UserDomainRole from registrar.models.verified_by_staff import VerifiedByStaff from .common import ( - MockDb, - MockSESClient, + MockDbForSharedTests, AuditedAdminMockData, completed_domain_request, generic_domain_object, @@ -57,18 +52,13 @@ from .common import ( mock_user, create_superuser, create_user, - create_ready_domain, multiple_unalphabetical_domain_objects, - MockEppLib, GenericTestHelper, ) from django.contrib.sessions.backends.db import SessionStore from django.contrib.auth import get_user_model -from unittest.mock import ANY, call, patch, Mock -from unittest import skip +from unittest.mock import patch, Mock -from django.conf import settings -import boto3_mocking # type: ignore import logging logger = logging.getLogger(__name__) @@ -78,6 +68,7 @@ class TestFsmModelResource(TestCase): def setUp(self): self.resource = FsmModelResource() + @less_console_noise_decorator def test_init_instance(self): """Test initializing an instance of a class with a FSM field""" @@ -92,6 +83,7 @@ class TestFsmModelResource(TestCase): self.assertIsInstance(instance, Domain) self.assertEqual(instance.state, "ready") + @less_console_noise_decorator def test_import_field(self): """Test that importing a field does not import FSM field""" @@ -117,2709 +109,37 @@ class TestFsmModelResource(TestCase): fsm_field_mock.save.assert_not_called() -class TestDomainAdmin(MockEppLib, WebTest): - # csrf checks do not work with WebTest. - # We disable them here. TODO for another ticket. - csrf_checks = False - - def setUp(self): - self.site = AdminSite() - self.admin = DomainAdmin(model=Domain, admin_site=self.site) - self.client = Client(HTTP_HOST="localhost:8080") - self.superuser = create_superuser() - self.staffuser = create_user() - self.factory = RequestFactory() - 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, - user=self.superuser, - admin=self.admin, - url=reverse("admin:registrar_domain_changelist"), - model=Domain, - client=self.client, - ) - super().setUp() - - @less_console_noise_decorator - def test_staff_can_see_cisa_region_federal(self): - """Tests if staff can see CISA Region: N/A""" - - # Create a fake domain request - _domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - _domain_request.approve() - - domain = _domain_request.approved_domain - p = "userpass" - self.client.login(username="staffuser", password=p) - response = self.client.get( - "/admin/registrar/domain/{}/change/".format(domain.pk), - follow=True, - ) - - # Make sure the page loaded, and that we're on the right page - self.assertEqual(response.status_code, 200) - self.assertContains(response, domain.name) - - # Test if the page has the right CISA region - expected_html = '
      CISA region: N/A
      ' - # Remove whitespace from expected_html - expected_html = "".join(expected_html.split()) - - # Remove whitespace from response content - response_content = "".join(response.content.decode().split()) - - # Check if response contains expected_html - self.assertIn(expected_html, response_content) - - @less_console_noise_decorator - def test_staff_can_see_cisa_region_non_federal(self): - """Tests if staff can see the correct CISA region""" - - # Create a fake domain request. State will be NY (2). - _domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.IN_REVIEW, generic_org_type="interstate" - ) - - _domain_request.approve() - - domain = _domain_request.approved_domain - - p = "userpass" - self.client.login(username="staffuser", password=p) - response = self.client.get( - "/admin/registrar/domain/{}/change/".format(domain.pk), - follow=True, - ) - - # Make sure the page loaded, and that we're on the right page - self.assertEqual(response.status_code, 200) - self.assertContains(response, domain.name) - - # Test if the page has the right CISA region - expected_html = '
      CISA region: 2
      ' - # Remove whitespace from expected_html - expected_html = "".join(expected_html.split()) - - # Remove whitespace from response content - response_content = "".join(response.content.decode().split()) - - # Check if response contains expected_html - self.assertIn(expected_html, response_content) - - @less_console_noise_decorator - def test_has_model_description(self): - """Tests if this model has a model description on the table view""" - p = "adminpass" - self.client.login(username="superuser", password=p) - response = self.client.get( - "/admin/registrar/domain/", - follow=True, - ) - - # Make sure that the page is loaded correctly - self.assertEqual(response.status_code, 200) - - # Test for a description snippet - self.assertContains(response, "This table contains all approved domains in the .gov registrar.") - self.assertContains(response, "Show more") - - @less_console_noise_decorator - def test_contact_fields_on_domain_change_form_have_detail_table(self): - """Tests if the contact fields in the inlined Domain information have the detail table - which displays title, email, and phone""" - - # Create fake creator - _creator = User.objects.create( - username="MrMeoward", - first_name="Meoward", - last_name="Jones", - email="meoward.jones@igorville.gov", - phone="(555) 123 12345", - title="Treat inspector", - ) - - # Create a fake domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) - domain_request.approve() - _domain_info = DomainInformation.objects.filter(domain=domain_request.approved_domain).get() - domain = Domain.objects.filter(domain_info=_domain_info).get() - - p = "adminpass" - self.client.login(username="superuser", password=p) - response = self.client.get( - "/admin/registrar/domain/{}/change/".format(domain.pk), - follow=True, - ) - - # Make sure the page loaded, and that we're on the right page - self.assertEqual(response.status_code, 200) - self.assertContains(response, domain.name) - - # Check that the fields have the right values. - # == Check for the creator == # - - # Check for the right title, email, and phone number in the response. - # We only need to check for the end tag - # (Otherwise this test will fail if we change classes, etc) - self.assertContains(response, "Treat inspector") - self.assertContains(response, "meoward.jones@igorville.gov") - self.assertContains(response, "(555) 123 12345") - - # Check for the field itself - self.assertContains(response, "Meoward Jones") - - # == Check for the submitter == # - self.assertContains(response, "mayor@igorville.gov") - - self.assertContains(response, "Admin Tester") - self.assertContains(response, "(555) 555 5556") - self.assertContains(response, "Testy2 Tester2") - - # == Check for the senior_official == # - self.assertContains(response, "testy@town.com") - self.assertContains(response, "Chief Tester") - self.assertContains(response, "(555) 555 5555") - - # Includes things like readonly fields - self.assertContains(response, "Testy Tester") - - # == Test the other_employees field == # - self.assertContains(response, "testy2@town.com") - self.assertContains(response, "Another Tester") - self.assertContains(response, "(555) 555 5557") - - # 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): - """ - Tests if extend_expiration_date modal gives an accurate date - """ - - # Create a ready domain with a preset expiration date - domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) - response = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk])) - # load expiration date into cache and registrar with below command - domain.registry_expiration_date - # Make sure the ex date is what we expect it to be - domain_ex_date = Domain.objects.get(id=domain.id).expiration_date - self.assertEqual(domain_ex_date, date(2023, 5, 25)) - - # Make sure that the page is loading as expected - self.assertEqual(response.status_code, 200) - self.assertContains(response, domain.name) - self.assertContains(response, "Extend expiration date") - - # Grab the form to submit - form = response.forms["domain_form"] - - with patch("django.contrib.messages.add_message") as mock_add_message: - # Submit the form - response = form.submit("_extend_expiration_date") - - # Follow the response - response = response.follow() - - # Assert that everything on the page looks correct - self.assertEqual(response.status_code, 200) - self.assertContains(response, domain.name) - self.assertContains(response, "Extend expiration date") - - # Ensure the message we recieve is in line with what we expect - expected_message = "Successfully extended the expiration date." - expected_call = call( - # The WGSI request doesn't need to be tested - ANY, - messages.INFO, - expected_message, - extra_tags="", - fail_silently=False, - ) - - mock_add_message.assert_has_calls([expected_call], 1) - - @less_console_noise_decorator - def test_analyst_can_see_inline_domain_information_in_domain_change_form(self): - """Tests if an analyst can still see the inline domain information form""" - - # Create fake creator - _creator = User.objects.create( - username="MrMeoward", - first_name="Meoward", - last_name="Jones", - ) - - # Create a fake domain request - _domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) - - # Creates a Domain and DomainInformation object - _domain_request.approve() - - domain_information = DomainInformation.objects.filter(domain_request=_domain_request).get() - domain_information.organization_name = "MonkeySeeMonkeyDo" - domain_information.save() - - # We use filter here rather than just domain_information.domain just to get the latest data. - domain = Domain.objects.filter(domain_info=domain_information).get() - - p = "userpass" - self.client.login(username="staffuser", password=p) - response = self.client.get( - "/admin/registrar/domain/{}/change/".format(domain.pk), - follow=True, - ) - - # Make sure the page loaded, and that we're on the right page - self.assertEqual(response.status_code, 200) - self.assertContains(response, domain.name) - - # Test for data. We only need to test one since its all interconnected. - expected_organization_name = "MonkeySeeMonkeyDo" - self.assertContains(response, expected_organization_name) - - @less_console_noise_decorator - def test_admin_can_see_inline_domain_information_in_domain_change_form(self): - """Tests if an admin can still see the inline domain information form""" - # Create fake creator - _creator = User.objects.create( - username="MrMeoward", - first_name="Meoward", - last_name="Jones", - ) - - # Create a fake domain request - _domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) - - # Creates a Domain and DomainInformation object - _domain_request.approve() - - domain_information = DomainInformation.objects.filter(domain_request=_domain_request).get() - domain_information.organization_name = "MonkeySeeMonkeyDo" - domain_information.save() - - # We use filter here rather than just domain_information.domain just to get the latest data. - domain = Domain.objects.filter(domain_info=domain_information).get() - - p = "adminpass" - self.client.login(username="superuser", password=p) - response = self.client.get( - "/admin/registrar/domain/{}/change/".format(domain.pk), - follow=True, - ) - - # Make sure the page loaded, and that we're on the right page - self.assertEqual(response.status_code, 200) - self.assertContains(response, domain.name) - - # Test for data. We only need to test one since its all interconnected. - expected_organization_name = "MonkeySeeMonkeyDo" - self.assertContains(response, expected_organization_name) - - @patch("registrar.admin.DomainAdmin._get_current_date", return_value=date(2024, 1, 1)) - def test_extend_expiration_date_button_epp(self, mock_date_today): - """ - Tests if extend_expiration_date button sends the right epp command - """ - - # Create a ready domain with a preset expiration date - domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) - - response = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk])) - - # Make sure that the page is loading as expected - self.assertEqual(response.status_code, 200) - self.assertContains(response, domain.name) - self.assertContains(response, "Extend expiration date") - - # Grab the form to submit - form = response.forms["domain_form"] - - with patch("django.contrib.messages.add_message") as mock_add_message: - with patch("registrar.models.Domain.renew_domain") as renew_mock: - # Submit the form - response = form.submit("_extend_expiration_date") - - # Follow the response - response = response.follow() - - # Assert that it is calling the function with the default extension length. - # We only need to test the value that EPP sends, as we can assume the other - # test cases cover the "renew" function. - renew_mock.assert_has_calls([call()], any_order=False) - - # We should not make duplicate calls - self.assertEqual(renew_mock.call_count, 1) - - # Assert that everything on the page looks correct - self.assertEqual(response.status_code, 200) - self.assertContains(response, domain.name) - self.assertContains(response, "Extend expiration date") - - # Ensure the message we recieve is in line with what we expect - expected_message = "Successfully extended the expiration date." - expected_call = call( - # The WGSI request doesn't need to be tested - ANY, - messages.INFO, - expected_message, - extra_tags="", - fail_silently=False, - ) - mock_add_message.assert_has_calls([expected_call], 1) - - def test_custom_delete_confirmation_page(self): - """Tests if we override the delete confirmation page for custom content""" - # Create a ready domain with a preset expiration date - domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) - - domain_change_page = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk])) - - self.assertContains(domain_change_page, "fake.gov") - # click the "Manage" link - confirmation_page = domain_change_page.click("Delete", index=0) - - content_slice = "When a domain is deleted:" - self.assertContains(confirmation_page, content_slice) - - def test_custom_delete_confirmation_page_table(self): - """Tests if we override the delete confirmation page for custom content on the table""" - # Create a ready domain - domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) - - # Get the index. The post expects the index to be encoded as a string - index = f"{domain.id}" - - # Simulate selecting a single record, then clicking "Delete selected domains" - response = self.test_helper.get_table_delete_confirmation_page("0", index) - - # Check that our content exists - content_slice = "When a domain is deleted:" - self.assertContains(response, content_slice) - - def test_short_org_name_in_domains_list(self): - """ - Make sure the short name is displaying in admin on the list page - """ - with less_console_noise(): - self.client.force_login(self.superuser) - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - mock_client = MockSESClient() - with boto3_mocking.clients.handler_for("sesv2", mock_client): - domain_request.approve() - - response = self.client.get("/admin/registrar/domain/") - # There are 4 template references to Federal (4) plus four references in the table - # for our actual domain_request - self.assertContains(response, "Federal", count=56) - # This may be a bit more robust - self.assertContains(response, 'Federal', count=1) - # Now let's make sure the long description does not exist - self.assertNotContains(response, "Federal: an agency of the U.S. government") - - @skip("Why did this test stop working, and is is a good test") - def test_place_and_remove_hold(self): - domain = create_ready_domain() - # get admin page and assert Place Hold button - p = "userpass" - self.client.login(username="staffuser", password=p) - response = self.client.get( - "/admin/registrar/domain/{}/change/".format(domain.pk), - follow=True, - ) - self.assertEqual(response.status_code, 200) - self.assertContains(response, domain.name) - self.assertContains(response, "Place hold") - self.assertNotContains(response, "Remove hold") - - # submit place_client_hold and assert Remove Hold button - response = self.client.post( - "/admin/registrar/domain/{}/change/".format(domain.pk), - {"_place_client_hold": "Place hold", "name": domain.name}, - follow=True, - ) - self.assertEqual(response.status_code, 200) - self.assertContains(response, domain.name) - self.assertContains(response, "Remove hold") - self.assertNotContains(response, "Place hold") - - # submit remove client hold and assert Place hold button - response = self.client.post( - "/admin/registrar/domain/{}/change/".format(domain.pk), - {"_remove_client_hold": "Remove hold", "name": domain.name}, - follow=True, - ) - self.assertEqual(response.status_code, 200) - self.assertContains(response, domain.name) - self.assertContains(response, "Place hold") - self.assertNotContains(response, "Remove hold") - - def test_deletion_is_successful(self): - """ - Scenario: Domain deletion is unsuccessful - When the domain is deleted - Then a user-friendly success message is returned for displaying on the web - And `state` is set to `DELETED` - """ - with less_console_noise(): - domain = create_ready_domain() - # Put in client hold - domain.place_client_hold() - p = "userpass" - self.client.login(username="staffuser", password=p) - # Ensure everything is displaying correctly - response = self.client.get( - "/admin/registrar/domain/{}/change/".format(domain.pk), - follow=True, - ) - self.assertEqual(response.status_code, 200) - self.assertContains(response, domain.name) - self.assertContains(response, "Remove from registry") - - # The contents of the modal should exist before and after the post. - # Check for the header - self.assertContains(response, "Are you sure you want to remove this domain from the registry?") - - # Check for some of its body - self.assertContains(response, "When a domain is removed from the registry:") - - # Check for some of the button content - self.assertContains(response, "Yes, remove from registry") - - # Test the info dialog - request = self.factory.post( - "/admin/registrar/domain/{}/change/".format(domain.pk), - {"_delete_domain": "Remove from registry", "name": domain.name}, - follow=True, - ) - request.user = self.client - with patch("django.contrib.messages.add_message") as mock_add_message: - self.admin.do_delete_domain(request, domain) - mock_add_message.assert_called_once_with( - request, - messages.INFO, - "Domain city.gov has been deleted. Thanks!", - extra_tags="", - fail_silently=False, - ) - - # The modal should still exist - self.assertContains(response, "Are you sure you want to remove this domain from the registry?") - self.assertContains(response, "When a domain is removed from the registry:") - self.assertContains(response, "Yes, remove from registry") - - self.assertEqual(domain.state, Domain.State.DELETED) - - def test_on_hold_is_successful_web_test(self): - """ - Scenario: Domain on_hold is successful through webtest - """ - with less_console_noise(): - domain = create_ready_domain() - - response = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk])) - - # Check the contents of the modal - # Check for the header - self.assertContains(response, "Are you sure you want to place this domain on hold?") - - # Check for some of its body - self.assertContains(response, "When a domain is on hold:") - - # Check for some of the button content - self.assertContains(response, "Yes, place hold") - - # Grab the form to submit - form = response.forms["domain_form"] - - # Submit the form - response = form.submit("_place_client_hold") - - # Follow the response - response = response.follow() - - self.assertEqual(response.status_code, 200) - self.assertContains(response, domain.name) - self.assertContains(response, "Remove hold") - - # The modal should still exist - # Check for the header - self.assertContains(response, "Are you sure you want to place this domain on hold?") - - # Check for some of its body - self.assertContains(response, "When a domain is on hold:") - - # Check for some of the button content - self.assertContains(response, "Yes, place hold") - - # Web test has issues grabbing up to date data from the db, so we can test - # the returned view instead - self.assertContains(response, '
      On hold
      ') - - def test_deletion_ready_fsm_failure(self): - """ - Scenario: Domain deletion is unsuccessful - When an error is returned from epplibwrapper - Then a user-friendly error message is returned for displaying on the web - And `state` is not set to `DELETED` - """ - with less_console_noise(): - domain = create_ready_domain() - p = "userpass" - self.client.login(username="staffuser", password=p) - # Ensure everything is displaying correctly - response = self.client.get( - "/admin/registrar/domain/{}/change/".format(domain.pk), - follow=True, - ) - self.assertEqual(response.status_code, 200) - self.assertContains(response, domain.name) - self.assertContains(response, "Remove from registry") - # Test the error - request = self.factory.post( - "/admin/registrar/domain/{}/change/".format(domain.pk), - {"_delete_domain": "Remove from registry", "name": domain.name}, - follow=True, - ) - request.user = self.client - with patch("django.contrib.messages.add_message") as mock_add_message: - self.admin.do_delete_domain(request, domain) - mock_add_message.assert_called_once_with( - request, - messages.ERROR, - "Error deleting this Domain: " - "Can't switch from state 'ready' to 'deleted'" - ", must be either 'dns_needed' or 'on_hold'", - extra_tags="", - fail_silently=False, - ) - - self.assertEqual(domain.state, Domain.State.READY) - - def test_analyst_deletes_domain_idempotent(self): - """ - Scenario: Analyst tries to delete an already deleted domain - Given `state` is already `DELETED` - When `domain.deletedInEpp()` is called - Then `commands.DeleteDomain` is sent to the registry - And Domain returns normally without an error dialog - """ - with less_console_noise(): - domain = create_ready_domain() - # Put in client hold - domain.place_client_hold() - p = "userpass" - self.client.login(username="staffuser", password=p) - # Ensure everything is displaying correctly - response = self.client.get( - "/admin/registrar/domain/{}/change/".format(domain.pk), - follow=True, - ) - self.assertEqual(response.status_code, 200) - self.assertContains(response, domain.name) - self.assertContains(response, "Remove from registry") - # Test the info dialog - request = self.factory.post( - "/admin/registrar/domain/{}/change/".format(domain.pk), - {"_delete_domain": "Remove from registry", "name": domain.name}, - follow=True, - ) - request.user = self.client - # Delete it once - with patch("django.contrib.messages.add_message") as mock_add_message: - self.admin.do_delete_domain(request, domain) - mock_add_message.assert_called_once_with( - request, - messages.INFO, - "Domain city.gov has been deleted. Thanks!", - extra_tags="", - fail_silently=False, - ) - - self.assertEqual(domain.state, Domain.State.DELETED) - # Try to delete it again - # Test the info dialog - request = self.factory.post( - "/admin/registrar/domain/{}/change/".format(domain.pk), - {"_delete_domain": "Remove from registry", "name": domain.name}, - follow=True, - ) - request.user = self.client - with patch("django.contrib.messages.add_message") as mock_add_message: - self.admin.do_delete_domain(request, domain) - mock_add_message.assert_called_once_with( - request, - messages.INFO, - "This domain is already deleted", - extra_tags="", - fail_silently=False, - ) - self.assertEqual(domain.state, Domain.State.DELETED) - - @skip("Waiting on epp lib to implement") - def test_place_and_remove_hold_epp(self): - raise - - @override_settings(IS_PRODUCTION=True) - def test_prod_only_shows_export(self): - """Test that production environment only displays export""" - with less_console_noise(): - response = self.client.get("/admin/registrar/domain/") - self.assertContains(response, ">Export<") - self.assertNotContains(response, ">Import<") - - def tearDown(self): - super().tearDown() - PublicContact.objects.all().delete() - Host.objects.all().delete() - Domain.objects.all().delete() - DomainInformation.objects.all().delete() - DomainRequest.objects.all().delete() - User.objects.all().delete() - - -class TestDomainRequestAdminForm(TestCase): - def setUp(self): - # Create a test domain request with an initial state of started - self.domain_request = completed_domain_request() - - def test_form_choices(self): - with less_console_noise(): - # Create a form instance with the test domain request - form = DomainRequestAdminForm(instance=self.domain_request) - - # Verify that the form choices match the available transitions for started - expected_choices = [("started", "Started"), ("submitted", "Submitted")] - self.assertEqual(form.fields["status"].widget.choices, expected_choices) - - def test_form_no_rejection_reason(self): - with less_console_noise(): - # Create a form instance with the test domain request - form = DomainRequestAdminForm(instance=self.domain_request) - - form = DomainRequestAdminForm( - instance=self.domain_request, - data={ - "status": DomainRequest.DomainRequestStatus.REJECTED, - "rejection_reason": None, - }, - ) - self.assertFalse(form.is_valid()) - self.assertIn("rejection_reason", form.errors) - - rejection_reason = form.errors.get("rejection_reason") - self.assertEqual(rejection_reason, ["A reason is required for this status."]) - - def test_form_choices_when_no_instance(self): - with less_console_noise(): - # Create a form instance without an instance - form = DomainRequestAdminForm() - - # Verify that the form choices show all choices when no instance is provided; - # this is necessary to show all choices when creating a new domain - # request in django admin; - # note that FSM ensures that no domain request exists with invalid status, - # so don't need to test for invalid status - self.assertEqual( - form.fields["status"].widget.choices, - DomainRequest._meta.get_field("status").choices, - ) - - def test_form_choices_when_ineligible(self): - with less_console_noise(): - # Create a form instance with a domain request with ineligible status - ineligible_domain_request = DomainRequest(status="ineligible") - - # Attempt to create a form with the ineligible domain request - # The form should not raise an error, but choices should be the - # full list of possible choices - form = DomainRequestAdminForm(instance=ineligible_domain_request) - - self.assertEqual( - form.fields["status"].widget.choices, - DomainRequest._meta.get_field("status").choices, - ) - - -@boto3_mocking.patching -class TestDomainRequestAdmin(MockEppLib): - def setUp(self): - super().setUp() - self.site = AdminSite() - self.factory = RequestFactory() - self.admin = DomainRequestAdmin(model=DomainRequest, admin_site=self.site) - self.superuser = create_superuser() - self.staffuser = create_user() - self.client = Client(HTTP_HOST="localhost:8080") - self.test_helper = GenericTestHelper( - factory=self.factory, - user=self.superuser, - admin=self.admin, - url="/admin/registrar/domainrequest/", - model=DomainRequest, - ) - self.mock_client = MockSESClient() - - def test_domain_request_senior_official_is_alphabetically_sorted(self): - """Tests if the senior offical dropdown is alphanetically sorted in the django admin display""" - - SeniorOfficial.objects.get_or_create(first_name="mary", last_name="joe", title="some other guy") - SeniorOfficial.objects.get_or_create(first_name="alex", last_name="smoe", title="some guy") - SeniorOfficial.objects.get_or_create(first_name="Zoup", last_name="Soup", title="title") - - contact, _ = Contact.objects.get_or_create(first_name="Henry", last_name="McFakerson") - domain_request = completed_domain_request(submitter=contact, name="city1.gov") - request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) - model_admin = AuditedAdmin(DomainRequest, self.site) - - # Get the queryset that would be returned for the list - senior_offical_queryset = model_admin.formfield_for_foreignkey( - DomainInformation.senior_official.field, request - ).queryset - - # Make the list we're comparing on a bit prettier display-wise. Optional step. - current_sort_order = [] - for official in senior_offical_queryset: - current_sort_order.append(f"{official.first_name} {official.last_name}") - - expected_sort_order = ["alex smoe", "mary joe", "Zoup Soup"] - - self.assertEqual(current_sort_order, expected_sort_order) - - @less_console_noise_decorator - def test_has_model_description(self): - """Tests if this model has a model description on the table view""" - p = "adminpass" - self.client.login(username="superuser", password=p) - response = self.client.get( - "/admin/registrar/domainrequest/", - follow=True, - ) - - # Make sure that the page is loaded correctly - self.assertEqual(response.status_code, 200) - - # Test for a description snippet - self.assertContains(response, "This table contains all domain requests") - self.assertContains(response, "Show more") - - @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. - """ - - def assert_status_count(normalized_content, status, count): - """Helper function to assert the count of a status in the HTML content.""" - self.assertEqual(normalized_content.count(f" {status} "), count) - - def assert_status_order(normalized_content, statuses): - """Helper function to assert the order of statuses in the HTML content.""" - start_index = 0 - for status in statuses: - index = normalized_content.find(f" {status} ", start_index) - self.assertNotEqual(index, -1, f"Status '{status}' not found in the expected order.") - start_index = index + len(status) - - # 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) - - domain_request.submit() - domain_request.save() - - domain_request.in_review() - domain_request.save() - - domain_request.action_needed() - domain_request.action_needed_reason = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS - domain_request.save() - - # Let's just change the action needed reason - domain_request.action_needed_reason = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR - domain_request.save() - - domain_request.reject() - domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE - domain_request.save() - - domain_request.in_review() - domain_request.save() - - response = self.client.get( - "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), - follow=True, - ) - - # Normalize the HTML response content - normalized_content = " ".join(response.content.decode("utf-8").split()) - - # Define the expected sequence of status changes - expected_status_changes = [ - "In review", - "Rejected - Purpose requirements not met", - "Action needed - Unclear organization eligibility", - "Action needed - Already has domains", - "In review", - "Submitted", - "Started", - ] - - assert_status_order(normalized_content, expected_status_changes) - - assert_status_count(normalized_content, "Started", 1) - assert_status_count(normalized_content, "Submitted", 1) - assert_status_count(normalized_content, "In review", 2) - assert_status_count(normalized_content, "Action needed - Already has domains", 1) - assert_status_count(normalized_content, "Action needed - Unclear organization eligibility", 1) - assert_status_count(normalized_content, "Rejected - Purpose requirements not met", 1) - - def test_collaspe_toggle_button_markup(self): - """ - Tests for the correct collapse toggle button markup - """ - - # Create a fake domain request and domain - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - - p = "adminpass" - self.client.login(username="superuser", password=p) - response = self.client.get( - "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), - follow=True, - ) - - # Make sure the page loaded, and that we're on the right page - self.assertEqual(response.status_code, 200) - self.assertContains(response, domain_request.requested_domain.name) - self.test_helper.assertContains(response, "Show details") - - @less_console_noise_decorator - def test_analyst_can_see_and_edit_alternative_domain(self): - """Tests if an analyst can still see and edit the alternative domain field""" - - # Create fake creator - _creator = User.objects.create( - username="MrMeoward", - first_name="Meoward", - last_name="Jones", - ) - - # Create a fake domain request - _domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) - - fake_website = Website.objects.create(website="thisisatest.gov") - _domain_request.alternative_domains.add(fake_website) - _domain_request.save() - - p = "userpass" - self.client.login(username="staffuser", 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) - - # Test if the page has the alternative domain - self.assertContains(response, "thisisatest.gov") - - # Check that the page contains the url we expect - expected_href = reverse("admin:registrar_website_change", args=[fake_website.id]) - self.assertContains(response, expected_href) - - # Navigate to the website to ensure that we can still edit it - response = self.client.get( - "/admin/registrar/website/{}/change/".format(fake_website.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, "thisisatest.gov") - - @less_console_noise_decorator - def test_analyst_can_see_and_edit_requested_domain(self): - """Tests if an analyst can still see and edit the requested domain field""" - - # Create fake creator - _creator = User.objects.create( - username="MrMeoward", - first_name="Meoward", - last_name="Jones", - ) - - # Create a fake domain request - _domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) - - p = "userpass" - self.client.login(username="staffuser", password=p) - response = self.client.get( - "/admin/registrar/domainrequest/{}/change/".format(_domain_request.pk), - follow=True, - ) - - # Filter to get the latest from the DB (rather than direct assignment) - requested_domain = DraftDomain.objects.filter(name=_domain_request.requested_domain.name).get() - - # Make sure the page loaded, and that we're on the right page - self.assertEqual(response.status_code, 200) - self.assertContains(response, requested_domain.name) - - # Check that the page contains the url we expect - expected_href = reverse("admin:registrar_draftdomain_change", args=[requested_domain.id]) - self.assertContains(response, expected_href) - - # Navigate to the website to ensure that we can still edit it - response = self.client.get( - "/admin/registrar/draftdomain/{}/change/".format(requested_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, "city.gov") - - @less_console_noise_decorator - def test_analyst_can_see_current_websites(self): - """Tests if an analyst can still see current website field""" - - # Create fake creator - _creator = User.objects.create( - username="MrMeoward", - first_name="Meoward", - last_name="Jones", - ) - - # Create a fake domain request - _domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) - - fake_website = Website.objects.create(website="thisisatest.gov") - _domain_request.current_websites.add(fake_website) - _domain_request.save() - - p = "userpass" - self.client.login(username="staffuser", 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) - - # Test if the page has the current website - self.assertContains(response, "thisisatest.gov") - - def test_domain_sortable(self): - """Tests if the DomainRequest sorts by domain correctly""" - with less_console_noise(): - p = "adminpass" - self.client.login(username="superuser", password=p) - - multiple_unalphabetical_domain_objects("domain_request") - - # Assert that our sort works correctly - self.test_helper.assert_table_sorted("1", ("requested_domain__name",)) - - # Assert that sorting in reverse works correctly - self.test_helper.assert_table_sorted("-1", ("-requested_domain__name",)) - - def test_submitter_sortable(self): - """Tests if the DomainRequest sorts by submitter correctly""" - with less_console_noise(): - p = "adminpass" - self.client.login(username="superuser", password=p) - - multiple_unalphabetical_domain_objects("domain_request") - - additional_domain_request = generic_domain_object("domain_request", "Xylophone") - new_user = User.objects.filter(username=additional_domain_request.investigator.username).get() - new_user.first_name = "Xylophonic" - new_user.save() - - # Assert that our sort works correctly - self.test_helper.assert_table_sorted( - "11", - ( - "submitter__first_name", - "submitter__last_name", - ), - ) - - # Assert that sorting in reverse works correctly - self.test_helper.assert_table_sorted( - "-11", - ( - "-submitter__first_name", - "-submitter__last_name", - ), - ) - - def test_investigator_sortable(self): - """Tests if the DomainRequest sorts by investigator correctly""" - with less_console_noise(): - p = "adminpass" - self.client.login(username="superuser", password=p) - - multiple_unalphabetical_domain_objects("domain_request") - additional_domain_request = generic_domain_object("domain_request", "Xylophone") - new_user = User.objects.filter(username=additional_domain_request.investigator.username).get() - new_user.first_name = "Xylophonic" - new_user.save() - - # Assert that our sort works correctly - self.test_helper.assert_table_sorted( - "12", - ( - "investigator__first_name", - "investigator__last_name", - ), - ) - - # Assert that sorting in reverse works correctly - self.test_helper.assert_table_sorted( - "-12", - ( - "-investigator__first_name", - "-investigator__last_name", - ), - ) - - @less_console_noise_decorator - def test_default_sorting_in_domain_requests_list(self): - """ - Make sure the default sortin in on the domain requests list page is reverse submission_date - then alphabetical requested_domain - """ - - # Create domain requests with different names - domain_requests = [ - completed_domain_request(status=DomainRequest.DomainRequestStatus.SUBMITTED, name=name) - for name in ["ccc.gov", "bbb.gov", "eee.gov", "aaa.gov", "zzz.gov", "ddd.gov"] - ] - - domain_requests[0].submission_date = timezone.make_aware(datetime(2024, 10, 16)) - domain_requests[1].submission_date = timezone.make_aware(datetime(2001, 10, 16)) - domain_requests[2].submission_date = timezone.make_aware(datetime(1980, 10, 16)) - domain_requests[3].submission_date = timezone.make_aware(datetime(1998, 10, 16)) - domain_requests[4].submission_date = timezone.make_aware(datetime(2013, 10, 16)) - domain_requests[5].submission_date = timezone.make_aware(datetime(1980, 10, 16)) - - # Save the modified domain requests to update their attributes in the database - for domain_request in domain_requests: - domain_request.save() - - # Refresh domain request objects from the database to reflect the changes - domain_requests = [DomainRequest.objects.get(pk=domain_request.pk) for domain_request in domain_requests] - - # Login as superuser and retrieve the domain request list page - self.client.force_login(self.superuser) - response = self.client.get("/admin/registrar/domainrequest/") - - # Check that the response is successful - self.assertEqual(response.status_code, 200) - - # Extract the domain names from the response content using regex - domain_names_match = re.findall(r"(\w+\.gov)", response.content.decode("utf-8")) - - logger.info(f"domain_names_match {domain_names_match}") - - # Verify that domain names are found - self.assertTrue(domain_names_match) - - # Extract the domain names - domain_names = [match for match in domain_names_match] - - # Verify that the domain names are displayed in the expected order - expected_order = [ - "ccc.gov", - "zzz.gov", - "bbb.gov", - "aaa.gov", - "ddd.gov", - "eee.gov", - ] - - # Remove duplicates - # Remove duplicates from domain_names list while preserving order - unique_domain_names = [] - for domain_name in domain_names: - if domain_name not in unique_domain_names: - unique_domain_names.append(domain_name) - - self.assertEqual(unique_domain_names, expected_order) - - def test_short_org_name_in_domain_requests_list(self): - """ - Make sure the short name is displaying in admin on the list page - """ - with less_console_noise(): - self.client.force_login(self.superuser) - completed_domain_request() - response = self.client.get("/admin/registrar/domainrequest/?generic_org_type__exact=federal") - # There are 2 template references to Federal (4) and two in the results data - # of the request - self.assertContains(response, "Federal", count=52) - # This may be a bit more robust - self.assertContains(response, 'Federal', count=1) - # Now let's make sure the long description does not exist - self.assertNotContains(response, "Federal: an agency of the U.S. government") - - def test_default_status_in_domain_requests_list(self): - """ - Make sure the default status in admin is selected on the domain requests list page - """ - with less_console_noise(): - self.client.force_login(self.superuser) - completed_domain_request() - response = self.client.get("/admin/registrar/domainrequest/") - # The results are filtered by "status in [submitted,in review,action needed]" - self.assertContains(response, "status in [submitted,in review,action needed]", count=1) - - @less_console_noise_decorator - def transition_state_and_send_email( - self, domain_request, status, rejection_reason=None, action_needed_reason=None, action_needed_reason_email=None - ): - """Helper method for the email test cases.""" - - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - # Create a mock request - request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) - - # Create a fake session to hook to - request.session = {} - - # Modify the domain request's properties - domain_request.status = status - - if rejection_reason: - domain_request.rejection_reason = rejection_reason - - if action_needed_reason: - domain_request.action_needed_reason = action_needed_reason - - if action_needed_reason_email: - domain_request.action_needed_reason_email = action_needed_reason_email - - # Use the model admin's save_model method - self.admin.save_model(request, domain_request, form=None, change=True) - - def assert_email_is_accurate( - self, expected_string, email_index, email_address, test_that_no_bcc=False, bcc_email_address="" - ): - """Helper method for the email test cases. - email_index is the index of the email in mock_client.""" - - with less_console_noise(): - # Access the arguments passed to send_email - call_args = self.mock_client.EMAILS_SENT - kwargs = call_args[email_index]["kwargs"] - - # Retrieve the email details from the arguments - from_email = kwargs.get("FromEmailAddress") - to_email = kwargs["Destination"]["ToAddresses"][0] - email_content = kwargs["Content"] - email_body = email_content["Simple"]["Body"]["Text"]["Data"] - - # Assert or perform other checks on the email details - self.assertEqual(from_email, settings.DEFAULT_FROM_EMAIL) - self.assertEqual(to_email, email_address) - self.assertIn(expected_string, email_body) - - if test_that_no_bcc: - _ = "" - with self.assertRaises(KeyError): - with less_console_noise(): - _ = kwargs["Destination"]["BccAddresses"][0] - self.assertEqual(_, "") - - if bcc_email_address: - bcc_email = kwargs["Destination"]["BccAddresses"][0] - self.assertEqual(bcc_email, bcc_email_address) - - @override_settings(IS_PRODUCTION=True) - def test_action_needed_sends_reason_email_prod_bcc(self): - """When an action needed reason is set, an email is sent out and help@get.gov - is BCC'd in production""" - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - BCC_EMAIL = settings.DEFAULT_FROM_EMAIL - User.objects.filter(email=EMAIL).delete() - in_review = DomainRequest.DomainRequestStatus.IN_REVIEW - action_needed = DomainRequest.DomainRequestStatus.ACTION_NEEDED - - # Create a sample domain request - domain_request = completed_domain_request(status=in_review) - - # Test the email sent out for already_has_domains - already_has_domains = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS - self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_domains) - - self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) - - # Test the email sent out for bad_name - bad_name = DomainRequest.ActionNeededReasons.BAD_NAME - self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=bad_name) - self.assert_email_is_accurate( - "DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS", 1, EMAIL, bcc_email_address=BCC_EMAIL - ) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) - - # Test the email sent out for eligibility_unclear - eligibility_unclear = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR - self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=eligibility_unclear) - self.assert_email_is_accurate( - "ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", 2, EMAIL, bcc_email_address=BCC_EMAIL - ) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) - - # Test that a custom email is sent out for questionable_so - questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL - self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=questionable_so) - self.assert_email_is_accurate( - "SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, EMAIL, bcc_email_address=BCC_EMAIL - ) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 4) - - # Assert that no other emails are sent on OTHER - other = DomainRequest.ActionNeededReasons.OTHER - self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=other) - - # Should be unchanged from before - self.assertEqual(len(self.mock_client.EMAILS_SENT), 4) - - # Tests if an analyst can override existing email content - questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL - self.transition_state_and_send_email( - domain_request, - action_needed, - action_needed_reason=questionable_so, - action_needed_reason_email="custom email content", - ) - - domain_request.refresh_from_db() - self.assert_email_is_accurate("custom email content", 4, EMAIL, bcc_email_address=BCC_EMAIL) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 5) - - # Tests if a new email gets sent when just the email is changed. - # An email should NOT be sent out if we just modify the email content. - self.transition_state_and_send_email( - domain_request, - action_needed, - action_needed_reason=questionable_so, - action_needed_reason_email="dummy email content", - ) - - self.assertEqual(len(self.mock_client.EMAILS_SENT), 5) - - # Set the request back to in review - domain_request.in_review() - - # Try sending another email when changing states AND including content - self.transition_state_and_send_email( - domain_request, - action_needed, - action_needed_reason=eligibility_unclear, - action_needed_reason_email="custom content when starting anew", - ) - self.assert_email_is_accurate("custom content when starting anew", 5, EMAIL, bcc_email_address=BCC_EMAIL) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 6) - - def test_save_model_sends_submitted_email(self): - """When transitioning to submitted from started or withdrawn on a domain request, - an email is sent out. - - When transitioning to submitted from dns needed or in review on a domain request, - no email is sent out. - - Also test that the default email set in settings is NOT BCCd on non-prod whenever - an email does go out.""" - - with less_console_noise(): - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() - - # Create a sample domain request - domain_request = completed_domain_request() - - # Test Submitted Status from started - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) - self.assert_email_is_accurate("We received your .gov domain request.", 0, EMAIL, True) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) - - # Test Withdrawn Status - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.WITHDRAWN) - self.assert_email_is_accurate( - "Your .gov domain request has been withdrawn and will not be reviewed by our team.", 1, EMAIL, True - ) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) - - # Test Submitted Status Again (from withdrawn) - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) - - # Move it to IN_REVIEW - other = DomainRequest.ActionNeededReasons.OTHER - in_review = DomainRequest.DomainRequestStatus.IN_REVIEW - self.transition_state_and_send_email(domain_request, in_review, action_needed_reason=other) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) - - # Test Submitted Status Again from in IN_REVIEW, no new email should be sent - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) - - # Move it to IN_REVIEW - self.transition_state_and_send_email(domain_request, in_review, action_needed_reason=other) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) - - # Move it to ACTION_NEEDED - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.ACTION_NEEDED) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) - - # Test Submitted Status Again from in ACTION_NEEDED, no new email should be sent - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) - - @less_console_noise_decorator - def test_model_displays_action_needed_email(self): - """Tests if the action needed email is visible for Domain Requests""" - - _domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.ACTION_NEEDED, - action_needed_reason=DomainRequest.ActionNeededReasons.BAD_NAME, - ) - - p = "userpass" - self.client.login(username="staffuser", password=p) - response = self.client.get( - "/admin/registrar/domainrequest/{}/change/".format(_domain_request.pk), - follow=True, - ) - - self.assertContains(response, "DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS") - - @override_settings(IS_PRODUCTION=True) - def test_save_model_sends_submitted_email_with_bcc_on_prod(self): - """When transitioning to submitted from started or withdrawn on a domain request, - an email is sent out. - - When transitioning to submitted from dns needed or in review on a domain request, - no email is sent out. - - Also test that the default email set in settings IS BCCd on prod whenever - an email does go out.""" - - with less_console_noise(): - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() - - BCC_EMAIL = settings.DEFAULT_FROM_EMAIL - - # Create a sample domain request - domain_request = completed_domain_request() - - # Test Submitted Status from started - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) - self.assert_email_is_accurate("We received your .gov domain request.", 0, EMAIL, False, BCC_EMAIL) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) - - # Test Withdrawn Status - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.WITHDRAWN) - self.assert_email_is_accurate( - "Your .gov domain request has been withdrawn and will not be reviewed by our team.", 1, EMAIL - ) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) - - # Test Submitted Status Again (from withdrawn) - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) - self.assert_email_is_accurate("We received your .gov domain request.", 0, EMAIL, False, BCC_EMAIL) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) - - # Move it to IN_REVIEW - other = domain_request.ActionNeededReasons.OTHER - in_review = DomainRequest.DomainRequestStatus.IN_REVIEW - self.transition_state_and_send_email(domain_request, in_review, action_needed_reason=other) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) - - # Test Submitted Status Again from in IN_REVIEW, no new email should be sent - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) - - # Move it to IN_REVIEW - self.transition_state_and_send_email(domain_request, in_review, action_needed_reason=other) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) - - # Move it to ACTION_NEEDED - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.ACTION_NEEDED) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) - - # Test Submitted Status Again from in ACTION_NEEDED, no new email should be sent - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) - - def test_save_model_sends_approved_email(self): - """When transitioning to approved on a domain request, - an email is sent out every time.""" - - with less_console_noise(): - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() - - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - - # Test Submitted Status - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) - self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 0, EMAIL) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) - - # Test Withdrawn Status - self.transition_state_and_send_email( - domain_request, - DomainRequest.DomainRequestStatus.REJECTED, - DomainRequest.RejectionReasons.DOMAIN_PURPOSE, - ) - self.assert_email_is_accurate("Your .gov domain request has been rejected.", 1, EMAIL) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) - - # Test Submitted Status Again (No new email should be sent) - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) - - def test_save_model_sends_rejected_email_purpose_not_met(self): - """When transitioning to rejected on a domain request, an email is sent - explaining why when the reason is domain purpose.""" - - with less_console_noise(): - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() - - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - - # Reject for reason DOMAIN_PURPOSE and test email - self.transition_state_and_send_email( - domain_request, - DomainRequest.DomainRequestStatus.REJECTED, - DomainRequest.RejectionReasons.DOMAIN_PURPOSE, - ) - self.assert_email_is_accurate( - "Your domain request was rejected because the purpose you provided did not meet our \nrequirements.", - 0, - EMAIL, - ) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) - - # Approve - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) - self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) - - def test_save_model_sends_rejected_email_requestor(self): - """When transitioning to rejected on a domain request, an email is sent - explaining why when the reason is requestor.""" - - with less_console_noise(): - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() - - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - - # Reject for reason REQUESTOR and test email including dynamic organization name - self.transition_state_and_send_email( - domain_request, DomainRequest.DomainRequestStatus.REJECTED, DomainRequest.RejectionReasons.REQUESTOR - ) - self.assert_email_is_accurate( - "Your domain request was rejected because we don’t believe you’re eligible to request a \n.gov " - "domain on behalf of Testorg", - 0, - EMAIL, - ) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) - - # Approve - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) - self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) - - def test_save_model_sends_rejected_email_org_has_domain(self): - """When transitioning to rejected on a domain request, an email is sent - explaining why when the reason is second domain.""" - - with less_console_noise(): - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() - - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - - # Reject for reason SECOND_DOMAIN_REASONING and test email including dynamic organization name - self.transition_state_and_send_email( - domain_request, - DomainRequest.DomainRequestStatus.REJECTED, - DomainRequest.RejectionReasons.SECOND_DOMAIN_REASONING, - ) - self.assert_email_is_accurate( - "Your domain request was rejected because Testorg has a .gov domain.", 0, EMAIL - ) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) - - # Approve - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) - self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) - - def test_save_model_sends_rejected_email_contacts_or_org_legitimacy(self): - """When transitioning to rejected on a domain request, an email is sent - explaining why when the reason is contacts or org legitimacy.""" - - with less_console_noise(): - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() - - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - - # Reject for reason CONTACTS_OR_ORGANIZATION_LEGITIMACY and test email including dynamic organization name - self.transition_state_and_send_email( - domain_request, - DomainRequest.DomainRequestStatus.REJECTED, - DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY, - ) - self.assert_email_is_accurate( - "Your domain request was rejected because we could not verify the organizational \n" - "contacts you provided. If you have questions or comments, reply to this email.", - 0, - EMAIL, - ) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) - - # Approve - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) - self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) - - def test_save_model_sends_rejected_email_org_eligibility(self): - """When transitioning to rejected on a domain request, an email is sent - explaining why when the reason is org eligibility.""" - - with less_console_noise(): - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() - - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - - # Reject for reason ORGANIZATION_ELIGIBILITY and test email including dynamic organization name - self.transition_state_and_send_email( - domain_request, - DomainRequest.DomainRequestStatus.REJECTED, - DomainRequest.RejectionReasons.ORGANIZATION_ELIGIBILITY, - ) - self.assert_email_is_accurate( - "Your domain request was rejected because we determined that Testorg is not \neligible for " - "a .gov domain.", - 0, - EMAIL, - ) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) - - # Approve - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) - self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) - - def test_save_model_sends_rejected_email_naming(self): - """When transitioning to rejected on a domain request, an email is sent - explaining why when the reason is naming.""" - - with less_console_noise(): - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() - - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - - # Reject for reason NAMING_REQUIREMENTS and test email including dynamic organization name - self.transition_state_and_send_email( - domain_request, - DomainRequest.DomainRequestStatus.REJECTED, - DomainRequest.RejectionReasons.NAMING_REQUIREMENTS, - ) - self.assert_email_is_accurate( - "Your domain request was rejected because it does not meet our naming requirements.", 0, EMAIL - ) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) - - # Approve - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) - self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) - - def test_save_model_sends_rejected_email_other(self): - """When transitioning to rejected on a domain request, an email is sent - explaining why when the reason is other.""" - - with less_console_noise(): - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() - - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - - # Reject for reason NAMING_REQUIREMENTS and test email including dynamic organization name - self.transition_state_and_send_email( - domain_request, - DomainRequest.DomainRequestStatus.REJECTED, - DomainRequest.RejectionReasons.OTHER, - ) - self.assert_email_is_accurate("Choosing a .gov domain name", 0, EMAIL) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) - - # Approve - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) - self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) - - def test_transition_to_rejected_without_rejection_reason_does_trigger_error(self): - """ - When transitioning to rejected without a rejection reason, admin throws a user friendly message. - - The transition fails. - """ - - with less_console_noise(): - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED) - - # Create a request object with a superuser - request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) - request.user = self.superuser - - with ExitStack() as stack: - stack.enter_context(patch.object(messages, "error")) - domain_request.status = DomainRequest.DomainRequestStatus.REJECTED - - self.admin.save_model(request, domain_request, None, True) - - messages.error.assert_called_once_with( - request, - "A reason is required for this status.", - ) - - domain_request.refresh_from_db() - self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.APPROVED) - - def test_transition_to_rejected_with_rejection_reason_does_not_trigger_error(self): - """ - When transitioning to rejected with a rejection reason, admin does not throw an error alert. - - The transition is successful. - """ - - with less_console_noise(): - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED) - - # Create a request object with a superuser - request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) - request.user = self.superuser - - with ExitStack() as stack: - stack.enter_context(patch.object(messages, "error")) - domain_request.status = DomainRequest.DomainRequestStatus.REJECTED - domain_request.rejection_reason = DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY - - self.admin.save_model(request, domain_request, None, True) - - messages.error.assert_not_called() - - domain_request.refresh_from_db() - self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.REJECTED) - - def test_save_model_sends_withdrawn_email(self): - """When transitioning to withdrawn on a domain request, - an email is sent out every time.""" - - with less_console_noise(): - # Ensure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() - - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - - # Test Submitted Status - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.WITHDRAWN) - self.assert_email_is_accurate( - "Your .gov domain request has been withdrawn and will not be reviewed by our team.", 0, EMAIL - ) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) - - # Test Withdrawn Status - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) - self.assert_email_is_accurate("We received your .gov domain request.", 1, EMAIL) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) - - # Test Submitted Status Again (No new email should be sent) - self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.WITHDRAWN) - self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) - - def test_save_model_sets_approved_domain(self): - with less_console_noise(): - # make sure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() - - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - - # Create a mock request - request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) - - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - # Modify the domain request's property - domain_request.status = DomainRequest.DomainRequestStatus.APPROVED - - # Use the model admin's save_model method - self.admin.save_model(request, domain_request, form=None, change=True) - - # Test that approved domain exists and equals requested domain - self.assertEqual(domain_request.requested_domain.name, domain_request.approved_domain.name) - - @less_console_noise_decorator - def test_sticky_submit_row(self): - """Test that the change_form template contains strings indicative of the customization - of the sticky submit bar. - - Also test that it does NOT contain a CSS class meant for analysts only when logged in as superuser.""" - - # make sure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() - self.client.force_login(self.superuser) - - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - - # Create a mock request - request = self.client.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) - - # Since we're using client to mock the request, we can only test against - # non-interpolated values - expected_content = "Requested domain:" - expected_content2 = '' - expected_content3 = '
      ' - not_expected_content = "submit-row-wrapper--analyst-view>" - self.assertContains(request, expected_content) - self.assertContains(request, expected_content2) - self.assertContains(request, expected_content3) - self.assertNotContains(request, not_expected_content) - - @less_console_noise_decorator - def test_sticky_submit_row_has_extra_class_for_analysts(self): - """Test that the change_form template contains strings indicative of the customization - of the sticky submit bar. - - Also test that it DOES contain a CSS class meant for analysts only when logged in as analyst.""" - - # make sure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() - self.client.force_login(self.staffuser) - - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - - # Create a mock request - request = self.client.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) - - # Since we're using client to mock the request, we can only test against - # non-interpolated values - expected_content = "Requested domain:" - expected_content2 = '' - expected_content3 = '
      ' - self.assertContains(request, expected_content) - self.assertContains(request, expected_content2) - self.assertContains(request, expected_content3) - - def test_other_contacts_has_readonly_link(self): - """Tests if the readonly other_contacts field has links""" - - # Create a fake domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - - # Get the other contact - other_contact = domain_request.other_contacts.all().first() - - p = "userpass" - self.client.login(username="staffuser", 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) - - # Check that the page contains the url we expect - expected_href = reverse("admin:registrar_contact_change", args=[other_contact.id]) - self.assertContains(response, expected_href) - - # Check that the page contains the link we expect. - # Since the url is dynamic (populated by JS), we can test for its existence - # by checking for the end tag. - expected_url = "Testy Tester" - self.assertContains(response, expected_url) - - @less_console_noise_decorator - def test_other_websites_has_readonly_link(self): - """Tests if the readonly other_websites field has links""" - - # Create a fake domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - - p = "userpass" - self.client.login(username="staffuser", 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) - - # Check that the page contains the link we expect. - expected_url = 'city.com' - self.assertContains(response, expected_url) - - @less_console_noise_decorator - def test_contact_fields_have_detail_table(self): - """Tests if the contact fields have the detail table which displays title, email, and phone""" - - # Create fake creator - _creator = User.objects.create( - username="MrMeoward", - first_name="Meoward", - last_name="Jones", - email="meoward.jones@igorville.gov", - phone="(555) 123 12345", - title="Treat inspector", - ) - - # Create a fake domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) - - p = "userpass" - self.client.login(username="staffuser", 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) - - # == Check for the creator == # - - # Check for the right title and phone number in the response. - # Email will appear more than once - expected_creator_fields = [ - # Field, expected value - ("title", "Treat inspector"), - ("phone", "(555) 123 12345"), - ] - self.test_helper.assert_response_contains_distinct_values(response, expected_creator_fields) - - # Check for the field itself - self.assertContains(response, "Meoward Jones") - - # == Check for the submitter == # - self.assertContains(response, "mayor@igorville.gov", count=2) - expected_submitter_fields = [ - # Field, expected value - ("title", "Admin Tester"), - ("phone", "(555) 555 5556"), - ] - self.test_helper.assert_response_contains_distinct_values(response, expected_submitter_fields) - self.assertContains(response, "Testy2 Tester2") - self.assertContains(response, "meoward.jones@igorville.gov") - - # == Check for the senior_official == # - self.assertContains(response, "testy@town.com", count=2) - expected_so_fields = [ - # Field, expected value - ("phone", "(555) 555 5555"), - ] - - self.test_helper.assert_response_contains_distinct_values(response, expected_so_fields) - self.assertContains(response, "Chief Tester") - - # == Test the other_employees field == # - self.assertContains(response, "testy2@town.com") - expected_other_employees_fields = [ - # Field, expected value - ("title", "Another Tester"), - ("phone", "(555) 555 5557"), - ] - self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) - - # Test for the copy link - self.assertContains(response, "usa-button__clipboard", count=4) - - # Test that Creator counts display properly - self.assertNotContains(response, "Approved domains") - self.assertContains(response, "Active requests") - - def test_save_model_sets_restricted_status_on_user(self): - with less_console_noise(): - # make sure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() - - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - - # Create a mock request - request = self.factory.post( - "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), follow=True - ) - - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - # Modify the domain request's property - domain_request.status = DomainRequest.DomainRequestStatus.INELIGIBLE - - # Use the model admin's save_model method - self.admin.save_model(request, domain_request, form=None, change=True) - - # Test that approved domain exists and equals requested domain - self.assertEqual(domain_request.creator.status, "restricted") - - def test_user_sets_restricted_status_modal(self): - """Tests the modal for when a user sets the status to restricted""" - with less_console_noise(): - # make sure there is no user with this email - EMAIL = "mayor@igorville.gov" - User.objects.filter(email=EMAIL).delete() - - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - - p = "userpass" - self.client.login(username="staffuser", password=p) - response = self.client.get( - "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), - follow=True, - ) - - self.assertEqual(response.status_code, 200) - self.assertContains(response, domain_request.requested_domain.name) - - # Check that the modal has the right content - # Check for the header - self.assertContains(response, "Are you sure you want to select ineligible status?") - - # Check for some of its body - self.assertContains(response, "When a domain request is in ineligible status") - - # Check for some of the button content - self.assertContains(response, "Yes, select ineligible status") - - # Create a mock request - request = self.factory.post( - "/admin/registrar/domainrequest{}/change/".format(domain_request.pk), follow=True - ) - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - # Modify the domain request's property - domain_request.status = DomainRequest.DomainRequestStatus.INELIGIBLE - - # Use the model admin's save_model method - self.admin.save_model(request, domain_request, form=None, change=True) - - # Test that approved domain exists and equals requested domain - self.assertEqual(domain_request.creator.status, "restricted") - - # 'Get' to the domain request again - response = self.client.get( - "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), - follow=True, - ) - - self.assertEqual(response.status_code, 200) - self.assertContains(response, domain_request.requested_domain.name) - - # The modal should be unchanged - self.assertContains(response, "Are you sure you want to select ineligible status?") - self.assertContains(response, "When a domain request is in ineligible status") - self.assertContains(response, "Yes, select ineligible status") - - @less_console_noise_decorator - def test_readonly_when_restricted_creator(self): - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - domain_request.creator.status = User.RESTRICTED - domain_request.creator.save() - - request = self.factory.get("/") - request.user = self.superuser - - readonly_fields = self.admin.get_readonly_fields(request, domain_request) - - expected_fields = [ - "other_contacts", - "current_websites", - "alternative_domains", - "is_election_board", - "status_history", - "id", - "created_at", - "updated_at", - "status", - "rejection_reason", - "action_needed_reason", - "action_needed_reason_email", - "federal_agency", - "portfolio", - "sub_organization", - "creator", - "investigator", - "generic_org_type", - "is_election_board", - "organization_type", - "federally_recognized_tribe", - "state_recognized_tribe", - "tribe_name", - "federal_type", - "organization_name", - "address_line1", - "address_line2", - "city", - "state_territory", - "zipcode", - "urbanization", - "about_your_organization", - "senior_official", - "approved_domain", - "requested_domain", - "submitter", - "purpose", - "no_other_contacts_rationale", - "anything_else", - "has_anything_else_text", - "cisa_representative_email", - "cisa_representative_first_name", - "cisa_representative_last_name", - "has_cisa_representative", - "is_policy_acknowledged", - "submission_date", - "notes", - "alternative_domains", - ] - - self.assertEqual(readonly_fields, expected_fields) - - def test_readonly_fields_for_analyst(self): - with less_console_noise(): - request = self.factory.get("/") # Use the correct method and path - request.user = self.staffuser - - readonly_fields = self.admin.get_readonly_fields(request) - - expected_fields = [ - "other_contacts", - "current_websites", - "alternative_domains", - "is_election_board", - "status_history", - "federal_agency", - "creator", - "about_your_organization", - "requested_domain", - "approved_domain", - "alternative_domains", - "purpose", - "submitter", - "no_other_contacts_rationale", - "anything_else", - "is_policy_acknowledged", - "cisa_representative_first_name", - "cisa_representative_last_name", - "cisa_representative_email", - ] - - self.assertEqual(readonly_fields, expected_fields) - - def test_readonly_fields_for_superuser(self): - with less_console_noise(): - request = self.factory.get("/") # Use the correct method and path - request.user = self.superuser - - readonly_fields = self.admin.get_readonly_fields(request) - - expected_fields = [ - "other_contacts", - "current_websites", - "alternative_domains", - "is_election_board", - "status_history", - ] - - self.assertEqual(readonly_fields, expected_fields) - - def test_saving_when_restricted_creator(self): - with less_console_noise(): - # Create an instance of the model - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - domain_request.creator.status = User.RESTRICTED - domain_request.creator.save() - - # Create a request object with a superuser - request = self.factory.get("/") - request.user = self.superuser - - with patch("django.contrib.messages.error") as mock_error: - # Simulate saving the model - self.admin.save_model(request, domain_request, None, False) - - # Assert that the error message was called with the correct argument - mock_error.assert_called_once_with( - request, - "This action is not permitted for domain requests with a restricted creator.", - ) - - # Assert that the status has not changed - self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.IN_REVIEW) - - def test_change_view_with_restricted_creator(self): - with less_console_noise(): - # Create an instance of the model - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - domain_request.creator.status = User.RESTRICTED - domain_request.creator.save() - - with patch("django.contrib.messages.warning") as mock_warning: - # Create a request object with a superuser - request = self.factory.get("/admin/your_app/domainrequest/{}/change/".format(domain_request.pk)) - request.user = self.superuser - - self.admin.display_restricted_warning(request, domain_request) - - # Assert that the error message was called with the correct argument - mock_warning.assert_called_once_with( - request, - "Cannot edit a domain request with a restricted creator.", - ) - - def trigger_saving_approved_to_another_state(self, domain_is_active, another_state, rejection_reason=None): - """Helper method that triggers domain request state changes from approved to another state, - with an associated domain that can be either active (READY) or not. - - Used to test errors when saving a change with an active domain, also used to test side effects - when saving a change goes through.""" - - with less_console_noise(): - # Create an instance of the model - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED) - domain = Domain.objects.create(name=domain_request.requested_domain.name) - domain_information = DomainInformation.objects.create(creator=self.superuser, domain=domain) - domain_request.approved_domain = domain - domain_request.save() - - # Create a request object with a superuser - request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) - request.user = self.superuser - - request.session = {} - - # Define a custom implementation for is_active - def custom_is_active(self): - return domain_is_active # Override to return True - - # Use ExitStack to combine patch contexts - with ExitStack() as stack: - # Patch Domain.is_active and django.contrib.messages.error simultaneously - stack.enter_context(patch.object(Domain, "is_active", custom_is_active)) - stack.enter_context(patch.object(messages, "error")) - - domain_request.status = another_state - - if another_state == DomainRequest.DomainRequestStatus.ACTION_NEEDED: - domain_request.action_needed_reason = domain_request.ActionNeededReasons.OTHER - - domain_request.rejection_reason = rejection_reason - - self.admin.save_model(request, domain_request, None, True) - - # Assert that the error message was called with the correct argument - if domain_is_active: - messages.error.assert_called_once_with( - request, - "This action is not permitted. The domain " + "is already active.", - ) - else: - # Assert that the error message was never called - messages.error.assert_not_called() - - self.assertEqual(domain_request.approved_domain, None) - - # Assert that Domain got Deleted - with self.assertRaises(Domain.DoesNotExist): - domain.refresh_from_db() - - # Assert that DomainInformation got Deleted - with self.assertRaises(DomainInformation.DoesNotExist): - domain_information.refresh_from_db() - - def test_error_when_saving_approved_to_in_review_and_domain_is_active(self): - self.trigger_saving_approved_to_another_state(True, DomainRequest.DomainRequestStatus.IN_REVIEW) - - def test_error_when_saving_approved_to_action_needed_and_domain_is_active(self): - self.trigger_saving_approved_to_another_state(True, DomainRequest.DomainRequestStatus.ACTION_NEEDED) - - def test_error_when_saving_approved_to_rejected_and_domain_is_active(self): - self.trigger_saving_approved_to_another_state(True, DomainRequest.DomainRequestStatus.REJECTED) - - def test_error_when_saving_approved_to_ineligible_and_domain_is_active(self): - self.trigger_saving_approved_to_another_state(True, DomainRequest.DomainRequestStatus.INELIGIBLE) - - def test_side_effects_when_saving_approved_to_in_review(self): - self.trigger_saving_approved_to_another_state(False, DomainRequest.DomainRequestStatus.IN_REVIEW) - - def test_side_effects_when_saving_approved_to_action_needed(self): - self.trigger_saving_approved_to_another_state(False, DomainRequest.DomainRequestStatus.ACTION_NEEDED) - - def test_side_effects_when_saving_approved_to_rejected(self): - self.trigger_saving_approved_to_another_state( - False, - DomainRequest.DomainRequestStatus.REJECTED, - DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY, - ) - - def test_side_effects_when_saving_approved_to_ineligible(self): - self.trigger_saving_approved_to_another_state(False, DomainRequest.DomainRequestStatus.INELIGIBLE) - - def test_has_correct_filters(self): - """ - This test verifies that DomainRequestAdmin has the correct filters set up. - - It retrieves the current list of filters from DomainRequestAdmin - and checks that it matches the expected list of filters. - """ - with less_console_noise(): - request = self.factory.get("/") - request.user = self.superuser - - # Grab the current list of table filters - readonly_fields = self.admin.get_list_filter(request) - expected_fields = ( - DomainRequestAdmin.StatusListFilter, - "generic_org_type", - "federal_type", - DomainRequestAdmin.ElectionOfficeFilter, - "rejection_reason", - DomainRequestAdmin.InvestigatorFilter, - ) - - self.assertEqual(readonly_fields, expected_fields) - - def test_table_sorted_alphabetically(self): - """ - This test verifies that the DomainRequestAdmin table is sorted alphabetically - by the 'requested_domain__name' field. - - It creates a list of DomainRequest instances in a non-alphabetical order, - then retrieves the queryset from the DomainRequestAdmin and checks - that it matches the expected queryset, - which is sorted alphabetically by the 'requested_domain__name' field. - """ - with less_console_noise(): - # Creates a list of DomainRequests in scrambled order - multiple_unalphabetical_domain_objects("domain_request") - - request = self.factory.get("/") - request.user = self.superuser - - # Get the expected list of alphabetically sorted DomainRequests - expected_order = DomainRequest.objects.order_by("requested_domain__name") - - # Get the returned queryset - queryset = self.admin.get_queryset(request) - - # Check the order - self.assertEqual( - list(queryset), - list(expected_order), - ) - - def test_displays_investigator_filter(self): - """ - This test verifies that the investigator filter in the admin interface for - the DomainRequest model displays correctly. - - It creates two DomainRequest instances, each with a different investigator. - It then simulates a staff user logging in and applying the investigator filter - on the DomainRequest admin page. - - We then test if the page displays the filter we expect, but we do not test - if we get back the correct response in the table. This is to isolate if - the filter displays correctly, when the filter isn't filtering correctly. - """ - - with less_console_noise(): - # Create a mock DomainRequest object, with a fake investigator - domain_request: DomainRequest = generic_domain_object("domain_request", "SomeGuy") - investigator_user = User.objects.filter(username=domain_request.investigator.username).get() - investigator_user.is_staff = True - investigator_user.save() - - p = "userpass" - self.client.login(username="staffuser", password=p) - response = self.client.get( - "/admin/registrar/domainrequest/", - { - "investigator__id__exact": investigator_user.id, - }, - follow=True, - ) - - # Then, test if the filter actually exists - self.assertIn("filters", response.context) - - # Assert the content of filters and search_query - filters = response.context["filters"] - - self.assertEqual( - filters, - [ - { - "parameter_name": "investigator", - "parameter_value": "SomeGuy first_name:investigator SomeGuy last_name:investigator", - }, - ], - ) - - def test_investigator_dropdown_displays_only_staff(self): - """ - This test verifies that the dropdown for the 'investigator' field in the DomainRequestAdmin - interface only displays users who are marked as staff. - - It creates two DomainRequest instances, one with an investigator - who is a staff user and another with an investigator who is not a staff user. - - It then retrieves the queryset for the 'investigator' dropdown from DomainRequestAdmin - and checks that it matches the expected queryset, which only includes staff users. - """ - - with less_console_noise(): - # Create a mock DomainRequest object, with a fake investigator - domain_request: DomainRequest = generic_domain_object("domain_request", "SomeGuy") - investigator_user = User.objects.filter(username=domain_request.investigator.username).get() - investigator_user.is_staff = True - investigator_user.save() - - # Create a mock DomainRequest object, with a user that is not staff - domain_request_2: DomainRequest = generic_domain_object("domain_request", "SomeOtherGuy") - investigator_user_2 = User.objects.filter(username=domain_request_2.investigator.username).get() - investigator_user_2.is_staff = False - investigator_user_2.save() - - p = "userpass" - self.client.login(username="staffuser", password=p) - - request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) - - # Get the actual field from the model's meta information - investigator_field = DomainRequest._meta.get_field("investigator") - - # We should only be displaying staff users, in alphabetical order - sorted_fields = ["first_name", "last_name", "email"] - expected_dropdown = list(User.objects.filter(is_staff=True).order_by(*sorted_fields)) - - # Grab the current dropdown. We do an API call to autocomplete to get this info. - domain_request_queryset = self.admin.formfield_for_foreignkey(investigator_field, request).queryset - user_request = self.factory.post( - "/admin/autocomplete/?app_label=registrar&model_name=domainrequest&field_name=investigator" - ) - user_admin = MyUserAdmin(User, self.site) - user_queryset = user_admin.get_search_results(user_request, domain_request_queryset, None)[0] - current_dropdown = list(user_queryset) - - self.assertEqual(expected_dropdown, current_dropdown) - - # Non staff users should not be in the list - self.assertNotIn(domain_request_2, current_dropdown) - - def test_investigator_list_is_alphabetically_sorted(self): - """ - This test verifies that filter list for the 'investigator' - is displayed alphabetically - """ - with less_console_noise(): - # Create a mock DomainRequest object, with a fake investigator - domain_request: DomainRequest = generic_domain_object("domain_request", "SomeGuy") - investigator_user = User.objects.filter(username=domain_request.investigator.username).get() - investigator_user.is_staff = True - investigator_user.save() - - domain_request_2: DomainRequest = generic_domain_object("domain_request", "AGuy") - investigator_user_2 = User.objects.filter(username=domain_request_2.investigator.username).get() - investigator_user_2.first_name = "AGuy" - investigator_user_2.is_staff = True - investigator_user_2.save() - - domain_request_3: DomainRequest = generic_domain_object("domain_request", "FinalGuy") - investigator_user_3 = User.objects.filter(username=domain_request_3.investigator.username).get() - investigator_user_3.first_name = "FinalGuy" - investigator_user_3.is_staff = True - investigator_user_3.save() - - p = "userpass" - self.client.login(username="staffuser", password=p) - request = RequestFactory().get("/") - - # These names have metadata embedded in them. :investigator implicitly tests if - # these are actually from the attribute "investigator". - expected_list = [ - "AGuy AGuy last_name:investigator", - "FinalGuy FinalGuy last_name:investigator", - "SomeGuy first_name:investigator SomeGuy last_name:investigator", - ] - - # Get the actual sorted list of investigators from the lookups method - actual_list = [item for _, item in self.admin.InvestigatorFilter.lookups(self, request, self.admin)] - - self.assertEqual(expected_list, actual_list) - - @less_console_noise_decorator - def test_staff_can_see_cisa_region_federal(self): - """Tests if staff can see CISA Region: N/A""" - - # Create a fake domain request - _domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - - p = "userpass" - self.client.login(username="staffuser", 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) - - # Test if the page has the right CISA region - expected_html = '
      CISA region: N/A
      ' - # Remove whitespace from expected_html - expected_html = "".join(expected_html.split()) - - # Remove whitespace from response content - response_content = "".join(response.content.decode().split()) - - # Check if response contains expected_html - self.assertIn(expected_html, response_content) - - @less_console_noise_decorator - def test_staff_can_see_cisa_region_non_federal(self): - """Tests if staff can see the correct CISA region""" - - # Create a fake domain request. State will be NY (2). - _domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.IN_REVIEW, generic_org_type="interstate" - ) - - p = "userpass" - self.client.login(username="staffuser", 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) - - # Test if the page has the right CISA region - expected_html = '
      CISA region: 2
      ' - # Remove whitespace from expected_html - expected_html = "".join(expected_html.split()) - - # Remove whitespace from response content - response_content = "".join(response.content.decode().split()) - - # Check if response contains expected_html - self.assertIn(expected_html, response_content) - - def tearDown(self): - super().tearDown() - Domain.objects.all().delete() - DomainInformation.objects.all().delete() - DomainRequest.objects.all().delete() - User.objects.all().delete() - Contact.objects.all().delete() - Website.objects.all().delete() - SeniorOfficial.objects.all().delete() - self.mock_client.EMAILS_SENT.clear() - - class TestDomainInvitationAdmin(TestCase): - """Tests for the DomainInvitation page""" + """Tests for the DomainInvitationAdmin class as super user + + Notes: + all tests share superuser; do not change this model in tests + tests have available superuser, client, and admin + """ + + @classmethod + def setUpClass(cls): + cls.factory = RequestFactory() + cls.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite()) + cls.superuser = create_superuser() def setUp(self): """Create a client object""" self.client = Client(HTTP_HOST="localhost:8080") - self.factory = RequestFactory() - self.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite()) - self.superuser = create_superuser() def tearDown(self): """Delete all DomainInvitation objects""" DomainInvitation.objects.all().delete() - User.objects.all().delete() Contact.objects.all().delete() + @classmethod + def tearDownClass(self): + User.objects.all().delete() + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) response = self.client.get( "/admin/registrar/domaininvitation/", follow=True, @@ -2837,9 +157,7 @@ class TestDomainInvitationAdmin(TestCase): def test_get_filters(self): """Ensures that our filters are displaying correctly""" with less_console_noise(): - # Have to get creative to get past linter - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) response = self.client.get( "/admin/registrar/domaininvitation/", @@ -2847,6 +165,77 @@ class TestDomainInvitationAdmin(TestCase): follow=True, ) + # Assert that the filters are added + self.assertContains(response, "invited", count=5) + self.assertContains(response, "Invited", count=2) + self.assertContains(response, "retrieved", count=2) + self.assertContains(response, "Retrieved", count=2) + + # Check for the HTML context specificially + invited_html = 'Invited' + retrieved_html = 'Retrieved' + + self.assertContains(response, invited_html, count=1) + self.assertContains(response, retrieved_html, count=1) + + +class TestPortfolioInvitationAdmin(TestCase): + """Tests for the PortfolioInvitationAdmin class as super user + + Notes: + all tests share superuser; do not change this model in tests + tests have available superuser, client, and admin + """ + + @classmethod + def setUpClass(cls): + cls.factory = RequestFactory() + cls.admin = ListHeaderAdmin(model=PortfolioInvitationAdmin, admin_site=AdminSite()) + cls.superuser = create_superuser() + + def setUp(self): + """Create a client object""" + self.client = Client(HTTP_HOST="localhost:8080") + + def tearDown(self): + """Delete all DomainInvitation objects""" + PortfolioInvitation.objects.all().delete() + Contact.objects.all().delete() + + @classmethod + def tearDownClass(self): + User.objects.all().delete() + + @less_console_noise_decorator + def test_has_model_description(self): + """Tests if this model has a model description on the table view""" + self.client.force_login(self.superuser) + response = self.client.get( + "/admin/registrar/portfolioinvitation/", + follow=True, + ) + + # Make sure that the page is loaded correctly + self.assertEqual(response.status_code, 200) + + # Test for a description snippet + self.assertContains( + response, + "Portfolio invitations contain all individuals who have been invited to become members of an organization.", + ) + self.assertContains(response, "Show more") + + def test_get_filters(self): + """Ensures that our filters are displaying correctly""" + with less_console_noise(): + self.client.force_login(self.superuser) + + response = self.client.get( + "/admin/registrar/portfolioinvitation/", + {}, + follow=True, + ) + # Assert that the filters are added self.assertContains(response, "invited", count=4) self.assertContains(response, "Invited", count=2) @@ -2862,32 +251,38 @@ class TestDomainInvitationAdmin(TestCase): class TestHostAdmin(TestCase): + """Tests for the HostAdmin class as super user + + Notes: + all tests share superuser; do not change this model in tests + tests have available superuser, client, and admin + """ + + @classmethod + def setUpClass(cls): + cls.site = AdminSite() + cls.factory = RequestFactory() + cls.admin = MyHostAdmin(model=Host, admin_site=cls.site) + cls.superuser = create_superuser() + 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() + @classmethod + def tearDownClass(cls): + User.objects.all().delete() + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) response = self.client.get( "/admin/registrar/host/", follow=True, @@ -2909,8 +304,7 @@ class TestHostAdmin(TestCase): # 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) + self.client.force_login(self.superuser) response = self.client.get( "/admin/registrar/host/{}/change/".format(host.pk), follow=True, @@ -2919,6 +313,13 @@ class TestHostAdmin(TestCase): # Make sure the page loaded self.assertEqual(response.status_code, 200) + self.test_helper = GenericTestHelper( + factory=self.factory, + user=self.superuser, + admin=self.admin, + url="/admin/registrar/Host/", + model=Host, + ) # These should exist in the response expected_values = [ ("domain", "Domain associated with this host"), @@ -2927,48 +328,32 @@ class TestHostAdmin(TestCase): class TestDomainInformationAdmin(TestCase): - def setUp(self): - """Setup environment for a mock admin user""" - self.site = AdminSite() - self.factory = RequestFactory() - self.admin = DomainInformationAdmin(model=DomainInformation, admin_site=self.site) - self.client = Client(HTTP_HOST="localhost:8080") - self.superuser = create_superuser() - self.staffuser = create_user() - self.mock_data_generator = AuditedAdminMockData() + """Tests for the DomainInformationAdmin class as super or staff user - self.test_helper = GenericTestHelper( - factory=self.factory, - user=self.superuser, - admin=self.admin, + Notes: + all tests share superuser/staffuser; do not change these models in tests + tests have available staffuser, superuser, client, test_helper and admin + """ + + @classmethod + def setUpClass(cls): + """Setup environment for a mock admin user""" + cls.site = AdminSite() + cls.factory = RequestFactory() + cls.admin = DomainInformationAdmin(model=DomainInformation, admin_site=cls.site) + cls.superuser = create_superuser() + cls.staffuser = create_user() + cls.mock_data_generator = AuditedAdminMockData() + cls.test_helper = GenericTestHelper( + factory=cls.factory, + user=cls.superuser, + admin=cls.admin, url="/admin/registrar/DomainInformation/", model=DomainInformation, ) - # Create fake DomainInformation objects - DomainInformation.objects.create( - creator=self.mock_data_generator.dummy_user("fake", "creator"), - domain=self.mock_data_generator.dummy_domain("Apple"), - submitter=self.mock_data_generator.dummy_contact("Zebra", "submitter"), - ) - - DomainInformation.objects.create( - creator=self.mock_data_generator.dummy_user("fake", "creator"), - domain=self.mock_data_generator.dummy_domain("Zebra"), - submitter=self.mock_data_generator.dummy_contact("Apple", "submitter"), - ) - - DomainInformation.objects.create( - creator=self.mock_data_generator.dummy_user("fake", "creator"), - domain=self.mock_data_generator.dummy_domain("Circus"), - submitter=self.mock_data_generator.dummy_contact("Xylophone", "submitter"), - ) - - DomainInformation.objects.create( - creator=self.mock_data_generator.dummy_user("fake", "creator"), - domain=self.mock_data_generator.dummy_domain("Xylophone"), - submitter=self.mock_data_generator.dummy_contact("Circus", "submitter"), - ) + def setUp(self): + self.client = Client(HTTP_HOST="localhost:8080") def tearDown(self): """Delete all Users, Domains, and UserDomainRoles""" @@ -2976,9 +361,13 @@ class TestDomainInformationAdmin(TestCase): DomainRequest.objects.all().delete() Domain.objects.all().delete() Contact.objects.all().delete() + + @classmethod + def tearDownClass(cls): User.objects.all().delete() SeniorOfficial.objects.all().delete() + @less_console_noise_decorator def test_domain_information_senior_official_is_alphabetically_sorted(self): """Tests if the senior offical dropdown is alphanetically sorted in the django admin display""" @@ -3020,8 +409,7 @@ class TestDomainInformationAdmin(TestCase): domain_information = DomainInformation.objects.filter(domain_request=_domain_request).get() - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) response = self.client.get( "/admin/registrar/domaininformation/{}/change/".format(domain_information.pk), follow=True, @@ -3053,8 +441,7 @@ class TestDomainInformationAdmin(TestCase): _domain_request.approve() domain_information = DomainInformation.objects.filter(domain_request=_domain_request).get() - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) response = self.client.get( "/admin/registrar/domaininformation/{}/change/".format(domain_information.pk), follow=True, @@ -3078,8 +465,7 @@ class TestDomainInformationAdmin(TestCase): @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) response = self.client.get( "/admin/registrar/domaininformation/", follow=True, @@ -3103,8 +489,7 @@ class TestDomainInformationAdmin(TestCase): domain_request.approve() domain_info = DomainInformation.objects.filter(domain=domain_request.approved_domain).get() - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) response = self.client.get( "/admin/registrar/domaininformation/{}/change/".format(domain_info.pk), follow=True, @@ -3136,8 +521,7 @@ class TestDomainInformationAdmin(TestCase): # Get the other contact other_contact = domain_info.other_contacts.all().first() - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) response = self.client.get( "/admin/registrar/domaininformation/{}/change/".format(domain_info.pk), @@ -3173,8 +557,7 @@ class TestDomainInformationAdmin(TestCase): domain_request.approve() domain_info = DomainInformation.objects.filter(domain=domain_request.approved_domain).get() - p = "userpass" - self.client.login(username="staffuser", password=p) + self.client.force_login(self.staffuser) response = self.client.get( "/admin/registrar/domaininformation/{}/change/".format(domain_info.pk), follow=True, @@ -3185,8 +568,7 @@ class TestDomainInformationAdmin(TestCase): # To make sure that its not a fluke, swap to an admin user # and try to access the same page. This should succeed. - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) response = self.client.get( "/admin/registrar/domaininformation/{}/change/".format(domain_info.pk), follow=True, @@ -3215,8 +597,7 @@ class TestDomainInformationAdmin(TestCase): domain_request.approve() domain_info = DomainInformation.objects.filter(domain=domain_request.approved_domain).get() - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) response = self.client.get( "/admin/registrar/domaininformation/{}/change/".format(domain_info.pk), follow=True, @@ -3276,7 +657,12 @@ class TestDomainInformationAdmin(TestCase): self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) # Test for the copy link - self.assertContains(response, "usa-button__clipboard", count=4) + self.assertContains(response, "button--clipboard", count=4) + + # cleanup this test + domain_info.delete() + domain_request.delete() + _creator.delete() def test_readonly_fields_for_analyst(self): """Ensures that analysts have their permissions setup correctly""" @@ -3306,8 +692,7 @@ class TestDomainInformationAdmin(TestCase): def test_domain_sortable(self): """Tests if DomainInformation sorts by domain correctly""" with less_console_noise(): - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) # Assert that our sort works correctly self.test_helper.assert_table_sorted("1", ("domain__name",)) @@ -3318,8 +703,7 @@ class TestDomainInformationAdmin(TestCase): def test_submitter_sortable(self): """Tests if DomainInformation sorts by submitter correctly""" with less_console_noise(): - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) # Assert that our sort works correctly self.test_helper.assert_table_sorted( @@ -3332,32 +716,49 @@ class TestDomainInformationAdmin(TestCase): class TestUserDomainRoleAdmin(TestCase): - def setUp(self): - """Setup environment for a mock admin user""" - self.site = AdminSite() - self.factory = RequestFactory() - self.admin = UserDomainRoleAdmin(model=UserDomainRole, 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, + """Tests for the UserDomainRoleAdmin class as super user + + Notes: + all tests share superuser; do not change this model in tests + tests have available superuser, client, test_helper and admin + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.site = AdminSite() + cls.factory = RequestFactory() + cls.admin = UserDomainRoleAdmin(model=UserDomainRole, admin_site=cls.site) + cls.superuser = create_superuser() + cls.test_helper = GenericTestHelper( + factory=cls.factory, + user=cls.superuser, + admin=cls.admin, url="/admin/registrar/UserDomainRole/", model=UserDomainRole, ) + def setUp(self): + """Setup environment for a mock admin user""" + super().setUp() + self.client = Client(HTTP_HOST="localhost:8080") + def tearDown(self): """Delete all Users, Domains, and UserDomainRoles""" - User.objects.all().delete() - Domain.objects.all().delete() + super().tearDown() UserDomainRole.objects.all().delete() + Domain.objects.all().delete() + User.objects.exclude(username="superuser").delete() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + User.objects.all().delete() @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) response = self.client.get( "/admin/registrar/userdomainrole/", follow=True, @@ -3375,8 +776,7 @@ class TestUserDomainRoleAdmin(TestCase): def test_domain_sortable(self): """Tests if the UserDomainrole sorts by domain correctly""" with less_console_noise(): - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) fake_user = User.objects.create( username="dummyuser", first_name="Stewart", last_name="Jones", email="AntarcticPolarBears@example.com" @@ -3397,8 +797,7 @@ class TestUserDomainRoleAdmin(TestCase): def test_user_sortable(self): """Tests if the UserDomainrole sorts by user correctly""" with less_console_noise(): - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) mock_data_generator = AuditedAdminMockData() @@ -3421,8 +820,7 @@ class TestUserDomainRoleAdmin(TestCase): Should return no results for an invalid email.""" with less_console_noise(): # Have to get creative to get past linter - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) fake_user = User.objects.create( username="dummyuser", first_name="Stewart", last_name="Jones", email="AntarcticPolarBears@example.com" @@ -3454,8 +852,7 @@ class TestUserDomainRoleAdmin(TestCase): Should return results for an valid email.""" with less_console_noise(): # Have to get creative to get past linter - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) fake_user = User.objects.create( username="dummyuser", first_name="Joe", last_name="Jones", email="AntarcticPolarBears@example.com" @@ -3484,18 +881,38 @@ class TestUserDomainRoleAdmin(TestCase): class TestListHeaderAdmin(TestCase): + """Tests for the ListHeaderAdmin class as super user + + Notes: + all tests share superuser; do not change this model in tests + tests have available superuser, client and admin + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.site = AdminSite() + cls.factory = RequestFactory() + cls.admin = ListHeaderAdmin(model=DomainRequest, admin_site=None) + cls.superuser = create_superuser() + def setUp(self): - self.site = AdminSite() - self.factory = RequestFactory() - self.admin = ListHeaderAdmin(model=DomainRequest, admin_site=None) + super().setUp() self.client = Client(HTTP_HOST="localhost:8080") - self.superuser = create_superuser() + + def tearDown(self): + # delete any domain requests too + DomainInformation.objects.all().delete() + DomainRequest.objects.all().delete() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + User.objects.all().delete() def test_changelist_view(self): with less_console_noise(): - # Have to get creative to get past linter - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) # Mock a user user = mock_user() # Make the request using the Client class @@ -3549,33 +966,42 @@ class TestListHeaderAdmin(TestCase): ], ) - def tearDown(self): - # delete any domain requests too - DomainInformation.objects.all().delete() - DomainRequest.objects.all().delete() - User.objects.all().delete() +class TestMyUserAdmin(MockDbForSharedTests): + """Tests for the MyUserAdmin class as super or staff user + + Notes: + all tests share superuser/staffuser; do not change these models in tests + all tests share MockDb; do not change models defined therein in tests + tests have available staffuser, superuser, client, test_helper and admin + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + admin_site = AdminSite() + cls.admin = MyUserAdmin(model=get_user_model(), admin_site=admin_site) + cls.superuser = create_superuser() + cls.staffuser = create_user() + cls.test_helper = GenericTestHelper(admin=cls.admin) -class TestMyUserAdmin(MockDb): def setUp(self): super().setUp() - 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.staffuser = create_user() - self.test_helper = GenericTestHelper(admin=self.admin) def tearDown(self): super().tearDown() DomainRequest.objects.all().delete() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() User.objects.all().delete() @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) response = self.client.get( "/admin/registrar/user/", follow=True, @@ -3595,8 +1021,7 @@ class TestMyUserAdmin(MockDb): """ user = self.staffuser - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) response = self.client.get( "/admin/registrar/user/{}/change/".format(user.pk), follow=True, @@ -3616,21 +1041,20 @@ class TestMyUserAdmin(MockDb): @less_console_noise_decorator def test_list_display_without_username(self): - with less_console_noise(): - request = self.client.request().wsgi_request - request.user = self.staffuser + request = self.client.request().wsgi_request + request.user = self.staffuser - list_display = self.admin.get_list_display(request) - expected_list_display = [ - "email", - "first_name", - "last_name", - "group", - "status", - ] + list_display = self.admin.get_list_display(request) + expected_list_display = [ + "email", + "first_name", + "last_name", + "group", + "status", + ] - self.assertEqual(list_display, expected_list_display) - self.assertNotIn("username", list_display) + self.assertEqual(list_display, expected_list_display) + self.assertNotIn("username", list_display) def test_get_fieldsets_superuser(self): with less_console_noise(): @@ -3670,6 +1094,7 @@ class TestMyUserAdmin(MockDb): ) self.assertEqual(fieldsets, expected_fieldsets) + @less_console_noise_decorator def test_analyst_can_see_related_domains_and_requests_in_user_form(self): """Tests if an analyst can see the related domains and domain requests for a user in that user's form""" @@ -3706,12 +1131,11 @@ class TestMyUserAdmin(MockDb): domain_deleted, _ = Domain.objects.get_or_create( name="domain_deleted.gov", state=Domain.State.DELETED, deleted=timezone.make_aware(datetime(2024, 4, 2)) ) - _, created = UserDomainRole.objects.get_or_create( + role, _ = UserDomainRole.objects.get_or_create( user=self.meoward_user, domain=domain_deleted, role=UserDomainRole.Roles.MANAGER ) - p = "userpass" - self.client.login(username="staffuser", password=p) + self.client.force_login(self.staffuser) response = self.client.get( "/admin/registrar/user/{}/change/".format(self.meoward_user.id), follow=True, @@ -3761,12 +1185,32 @@ class TestMyUserAdmin(MockDb): expected_href = reverse("admin:registrar_domain_change", args=[domain_deleted.pk]) self.assertNotContains(response, expected_href) + # Must clean up within test since MockDB is shared across tests for performance reasons + domain_request_started_id = domain_request_started.id + domain_request_submitted_id = domain_request_submitted.id + domain_request_in_review_id = domain_request_in_review.id + domain_request_withdrawn_id = domain_request_withdrawn.id + domain_request_approved_id = domain_request_approved.id + domain_request_rejected_id = domain_request_rejected.id + domain_request_ineligible_id = domain_request_ineligible.id + domain_request_ids = [ + domain_request_started_id, + domain_request_submitted_id, + domain_request_in_review_id, + domain_request_withdrawn_id, + domain_request_approved_id, + domain_request_rejected_id, + domain_request_ineligible_id, + ] + DomainRequest.objects.filter(id__in=domain_request_ids).delete() + domain_deleted.delete() + role.delete() + def test_analyst_cannot_see_selects_for_portfolio_role_and_permissions_in_user_form(self): """Can only test for the presence of a base element. The multiselects and the h2->h3 conversion are all dynamically generated.""" - p = "userpass" - self.client.login(username="staffuser", password=p) + self.client.force_login(self.staffuser) response = self.client.get( "/admin/registrar/user/{}/change/".format(self.meoward_user.id), follow=True, @@ -3779,12 +1223,24 @@ class TestMyUserAdmin(MockDb): class AuditedAdminTest(TestCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.site = AdminSite() + cls.factory = RequestFactory() + def setUp(self): - self.site = AdminSite() - self.factory = RequestFactory() + super().setUp() self.client = Client(HTTP_HOST="localhost:8080") self.staffuser = create_user() + def tearDown(self): + super().tearDown() + DomainInformation.objects.all().delete() + DomainRequest.objects.all().delete() + DomainInvitation.objects.all().delete() + def order_by_desired_field_helper(self, obj_to_sort: AuditedAdmin, request, field_name, *obj_names): with less_console_noise(): formatted_sort_fields = [] @@ -3797,6 +1253,7 @@ class AuditedAdminTest(TestCase): return ordered_list + @less_console_noise_decorator def test_alphabetically_sorted_domain_request_investigator(self): """Tests if the investigator field is alphabetically sorted by mimicking the call event flow""" @@ -4012,26 +1469,31 @@ class AuditedAdminTest(TestCase): else: return None - def tearDown(self): - DomainInformation.objects.all().delete() - DomainRequest.objects.all().delete() - DomainInvitation.objects.all().delete() - class DomainSessionVariableTest(TestCase): """Test cases for session variables in Django Admin""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.factory = RequestFactory() + cls.admin = DomainAdmin(Domain, None) + cls.superuser = create_superuser() + def setUp(self): - self.factory = RequestFactory() - self.admin = DomainAdmin(Domain, None) + super().setUp() self.client = Client(HTTP_HOST="localhost:8080") + @classmethod + def tearDownClass(cls): + super().tearDownClass() + User.objects.all().delete() + def test_session_vars_set_correctly(self): """Checks if session variables are being set correctly""" with less_console_noise(): - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) dummy_domain_information = generic_domain_object("information", "session") request = self.get_factory_post_edit_domain(dummy_domain_information.domain.pk) @@ -4046,8 +1508,7 @@ class DomainSessionVariableTest(TestCase): """Checks if session variables are being set correctly""" with less_console_noise(): - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) dummy_domain_information: Domain = generic_domain_object("information", "session") dummy_domain_information.domain.pk = 1 @@ -4061,8 +1522,7 @@ class DomainSessionVariableTest(TestCase): """Checks if incorrect session variables get overridden""" with less_console_noise(): - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) dummy_domain_information = generic_domain_object("information", "session") request = self.get_factory_post_edit_domain(dummy_domain_information.domain.pk) @@ -4079,8 +1539,7 @@ class DomainSessionVariableTest(TestCase): """Checks to see if session variables retain old information""" with less_console_noise(): - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) dummy_domain_information_list = multiple_unalphabetical_domain_objects("information") for item in dummy_domain_information_list: @@ -4094,8 +1553,7 @@ class DomainSessionVariableTest(TestCase): """Simulates two requests at once""" with less_console_noise(): - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) info_first = generic_domain_object("information", "session") info_second = generic_domain_object("information", "session2") @@ -4144,19 +1602,34 @@ class DomainSessionVariableTest(TestCase): class TestContactAdmin(TestCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.site = AdminSite() + cls.factory = RequestFactory() + cls.admin = ContactAdmin(model=Contact, admin_site=None) + cls.superuser = create_superuser() + cls.staffuser = create_user() + def setUp(self): - self.site = AdminSite() - self.factory = RequestFactory() + super().setUp() self.client = Client(HTTP_HOST="localhost:8080") - self.admin = ContactAdmin(model=get_user_model(), admin_site=None) - self.superuser = create_superuser() - self.staffuser = create_user() + + def tearDown(self): + super().tearDown() + DomainRequest.objects.all().delete() + Contact.objects.all().delete() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + User.objects.all().delete() @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) response = self.client.get( "/admin/registrar/contact/", follow=True, @@ -4229,6 +1702,10 @@ class TestContactAdmin(TestCase): "
    ", ) + # cleanup this test + DomainRequest.objects.all().delete() + contact.delete() + def test_change_view_for_joined_contact_five_or_more(self): """Create a contact, join it to 6 domain requests. Assert that the warning on the contact form lists 5 joins and a '1 more' ellispsis.""" @@ -4268,33 +1745,39 @@ class TestContactAdmin(TestCase): "" "

    And 1 more...

    ", ) - - def tearDown(self): - DomainRequest.objects.all().delete() - Contact.objects.all().delete() - User.objects.all().delete() + # cleanup this test + DomainRequest.objects.all().delete() + contact.delete() class TestVerifiedByStaffAdmin(TestCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.site = AdminSite() + cls.superuser = create_superuser() + cls.admin = VerifiedByStaffAdmin(model=VerifiedByStaff, admin_site=cls.site) + cls.factory = RequestFactory() + cls.test_helper = GenericTestHelper(admin=cls.admin) + 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() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() User.objects.all().delete() @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) response = self.client.get( "/admin/registrar/verifiedbystaff/", follow=True, @@ -4316,8 +1799,7 @@ class TestVerifiedByStaffAdmin(TestCase): """ vip_instance, _ = VerifiedByStaff.objects.get_or_create(email="test@example.com", notes="Test Notes") - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) response = self.client.get( "/admin/registrar/verifiedbystaff/{}/change/".format(vip_instance.pk), follow=True, @@ -4371,8 +1853,7 @@ class TestWebsiteAdmin(TestCase): @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) response = self.client.get( "/admin/registrar/website/", follow=True, @@ -4387,25 +1868,33 @@ class TestWebsiteAdmin(TestCase): class TestDraftDomain(TestCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.site = AdminSite() + cls.superuser = create_superuser() + cls.admin = DraftDomainAdmin(model=DraftDomain, admin_site=cls.site) + cls.factory = RequestFactory() + cls.test_helper = GenericTestHelper(admin=cls.admin) + def setUp(self): super().setUp() - self.site = AdminSite() - self.superuser = create_superuser() - self.admin = DraftDomainAdmin(model=DraftDomain, 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() DraftDomain.objects.all().delete() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() User.objects.all().delete() @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) response = self.client.get( "/admin/registrar/draftdomain/", follow=True, @@ -4422,25 +1911,28 @@ class TestDraftDomain(TestCase): class TestFederalAgency(TestCase): - def setUp(self): - super().setUp() - self.site = AdminSite() - self.superuser = create_superuser() - self.admin = FederalAgencyAdmin(model=FederalAgency, 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() - FederalAgency.objects.all().delete() + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.site = AdminSite() + cls.superuser = create_superuser() + cls.admin = FederalAgencyAdmin(model=FederalAgency, admin_site=cls.site) + cls.factory = RequestFactory() + cls.test_helper = GenericTestHelper(admin=cls.admin) + + def setUp(self): + self.client = Client(HTTP_HOST="localhost:8080") + + @classmethod + def tearDownClass(cls): + super().tearDownClass() User.objects.all().delete() @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) response = self.client.get( "/admin/registrar/federalagency/", follow=True, @@ -4505,8 +1997,7 @@ class TestTransitionDomain(TestCase): @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) response = self.client.get( "/admin/registrar/transitiondomain/", follow=True, @@ -4537,8 +2028,7 @@ class TestUserGroup(TestCase): @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" - p = "adminpass" - self.client.login(username="superuser", password=p) + self.client.force_login(self.superuser) response = self.client.get( "/admin/registrar/usergroup/", follow=True, diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py new file mode 100644 index 000000000..e156dd377 --- /dev/null +++ b/src/registrar/tests/test_admin_domain.py @@ -0,0 +1,824 @@ +from datetime import date +from django.test import TestCase, RequestFactory, Client, override_settings +from django.contrib.admin.sites import AdminSite +from api.tests.common import less_console_noise_decorator +from django_webtest import WebTest # type: ignore +from django.contrib import messages +from django.urls import reverse +from registrar.admin import ( + DomainAdmin, +) +from registrar.models import ( + Domain, + DomainRequest, + DomainInformation, + User, + Host, +) +from .common import ( + MockSESClient, + completed_domain_request, + less_console_noise, + create_superuser, + create_user, + create_ready_domain, + MockEppLib, + GenericTestHelper, +) +from unittest.mock import ANY, call, patch + +import boto3_mocking # type: ignore +import logging + +logger = logging.getLogger(__name__) + + +class TestDomainAdminAsStaff(MockEppLib): + """Test DomainAdmin class as staff user. + + Notes: + all tests share staffuser; do not change staffuser model in tests + tests have available staffuser, client, and admin + """ + + @classmethod + def setUpClass(self): + super().setUpClass() + self.staffuser = create_user() + self.site = AdminSite() + self.admin = DomainAdmin(model=Domain, admin_site=self.site) + self.factory = RequestFactory() + + def setUp(self): + self.client = Client(HTTP_HOST="localhost:8080") + self.client.force_login(self.staffuser) + super().setUp() + + def tearDown(self): + super().tearDown() + Host.objects.all().delete() + Domain.objects.all().delete() + DomainInformation.objects.all().delete() + DomainRequest.objects.all().delete() + + @classmethod + def tearDownClass(self): + User.objects.all().delete() + super().tearDownClass() + + @less_console_noise_decorator + def test_staff_can_see_cisa_region_federal(self): + """Tests if staff can see CISA Region: N/A""" + + # Create a fake domain request + _domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + _domain_request.approve() + + domain = _domain_request.approved_domain + response = self.client.get( + "/admin/registrar/domain/{}/change/".format(domain.pk), + follow=True, + ) + + # Make sure the page loaded, and that we're on the right page + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + + # Test if the page has the right CISA region + expected_html = '
    CISA region: N/A
    ' + # Remove whitespace from expected_html + expected_html = "".join(expected_html.split()) + + # Remove whitespace from response content + response_content = "".join(response.content.decode().split()) + + # Check if response contains expected_html + self.assertIn(expected_html, response_content) + + @less_console_noise_decorator + def test_staff_can_see_cisa_region_non_federal(self): + """Tests if staff can see the correct CISA region""" + + # Create a fake domain request. State will be NY (2). + _domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.IN_REVIEW, generic_org_type="interstate" + ) + + _domain_request.approve() + + domain = _domain_request.approved_domain + + response = self.client.get( + "/admin/registrar/domain/{}/change/".format(domain.pk), + follow=True, + ) + + # Make sure the page loaded, and that we're on the right page + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + + # Test if the page has the right CISA region + expected_html = '
    CISA region: 2
    ' + # Remove whitespace from expected_html + expected_html = "".join(expected_html.split()) + + # Remove whitespace from response content + response_content = "".join(response.content.decode().split()) + + # Check if response contains expected_html + self.assertIn(expected_html, response_content) + + @less_console_noise_decorator + def test_analyst_can_see_inline_domain_information_in_domain_change_form(self): + """Tests if an analyst can still see the inline domain information form""" + + # Create fake creator + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + ) + + # Create a fake domain request + _domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) + + # Creates a Domain and DomainInformation object + _domain_request.approve() + + domain_information = DomainInformation.objects.filter(domain_request=_domain_request).get() + domain_information.organization_name = "MonkeySeeMonkeyDo" + domain_information.save() + + # We use filter here rather than just domain_information.domain just to get the latest data. + domain = Domain.objects.filter(domain_info=domain_information).get() + + response = self.client.get( + "/admin/registrar/domain/{}/change/".format(domain.pk), + follow=True, + ) + + # Make sure the page loaded, and that we're on the right page + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + + # Test for data. We only need to test one since its all interconnected. + expected_organization_name = "MonkeySeeMonkeyDo" + self.assertContains(response, expected_organization_name) + + # clean up this test's data + domain.delete() + domain_information.delete() + _domain_request.delete() + _creator.delete() + + @less_console_noise_decorator + def test_deletion_is_successful(self): + """ + Scenario: Domain deletion is unsuccessful + When the domain is deleted + Then a user-friendly success message is returned for displaying on the web + And `state` is set to `DELETED` + """ + domain = create_ready_domain() + # Put in client hold + domain.place_client_hold() + # Ensure everything is displaying correctly + response = self.client.get( + "/admin/registrar/domain/{}/change/".format(domain.pk), + follow=True, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + self.assertContains(response, "Remove from registry") + + # The contents of the modal should exist before and after the post. + # Check for the header + self.assertContains(response, "Are you sure you want to remove this domain from the registry?") + + # Check for some of its body + self.assertContains(response, "When a domain is removed from the registry:") + + # Check for some of the button content + self.assertContains(response, "Yes, remove from registry") + + # Test the info dialog + request = self.factory.post( + "/admin/registrar/domain/{}/change/".format(domain.pk), + {"_delete_domain": "Remove from registry", "name": domain.name}, + follow=True, + ) + request.user = self.client + with patch("django.contrib.messages.add_message") as mock_add_message: + self.admin.do_delete_domain(request, domain) + mock_add_message.assert_called_once_with( + request, + messages.INFO, + "Domain city.gov has been deleted. Thanks!", + extra_tags="", + fail_silently=False, + ) + + # The modal should still exist + self.assertContains(response, "Are you sure you want to remove this domain from the registry?") + self.assertContains(response, "When a domain is removed from the registry:") + self.assertContains(response, "Yes, remove from registry") + + self.assertEqual(domain.state, Domain.State.DELETED) + + # clean up data within this test + domain.delete() + + @less_console_noise_decorator + def test_deletion_ready_fsm_failure(self): + """ + Scenario: Domain deletion is unsuccessful + When an error is returned from epplibwrapper + Then a user-friendly error message is returned for displaying on the web + And `state` is not set to `DELETED` + """ + + domain = create_ready_domain() + # Ensure everything is displaying correctly + response = self.client.get( + "/admin/registrar/domain/{}/change/".format(domain.pk), + follow=True, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + self.assertContains(response, "Remove from registry") + # Test the error + request = self.factory.post( + "/admin/registrar/domain/{}/change/".format(domain.pk), + {"_delete_domain": "Remove from registry", "name": domain.name}, + follow=True, + ) + request.user = self.client + with patch("django.contrib.messages.add_message") as mock_add_message: + self.admin.do_delete_domain(request, domain) + mock_add_message.assert_called_once_with( + request, + messages.ERROR, + "Error deleting this Domain: " + "Can't switch from state 'ready' to 'deleted'" + ", must be either 'dns_needed' or 'on_hold'", + extra_tags="", + fail_silently=False, + ) + + self.assertEqual(domain.state, Domain.State.READY) + + # delete data created in this test + domain.delete() + + @less_console_noise_decorator + def test_analyst_deletes_domain_idempotent(self): + """ + Scenario: Analyst tries to delete an already deleted domain + Given `state` is already `DELETED` + When `domain.deletedInEpp()` is called + Then `commands.DeleteDomain` is sent to the registry + And Domain returns normally without an error dialog + """ + domain = create_ready_domain() + # Put in client hold + domain.place_client_hold() + # Ensure everything is displaying correctly + response = self.client.get( + "/admin/registrar/domain/{}/change/".format(domain.pk), + follow=True, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + self.assertContains(response, "Remove from registry") + # Test the info dialog + request = self.factory.post( + "/admin/registrar/domain/{}/change/".format(domain.pk), + {"_delete_domain": "Remove from registry", "name": domain.name}, + follow=True, + ) + request.user = self.client + # Delete it once + with patch("django.contrib.messages.add_message") as mock_add_message: + self.admin.do_delete_domain(request, domain) + mock_add_message.assert_called_once_with( + request, + messages.INFO, + "Domain city.gov has been deleted. Thanks!", + extra_tags="", + fail_silently=False, + ) + + self.assertEqual(domain.state, Domain.State.DELETED) + # Try to delete it again + # Test the info dialog + request = self.factory.post( + "/admin/registrar/domain/{}/change/".format(domain.pk), + {"_delete_domain": "Remove from registry", "name": domain.name}, + follow=True, + ) + request.user = self.client + with patch("django.contrib.messages.add_message") as mock_add_message: + self.admin.do_delete_domain(request, domain) + mock_add_message.assert_called_once_with( + request, + messages.INFO, + "This domain is already deleted", + extra_tags="", + fail_silently=False, + ) + self.assertEqual(domain.state, Domain.State.DELETED) + + # delete data created in this test + domain.delete() + + +class TestDomainAdminWithClient(TestCase): + """Test DomainAdmin class as super user. + + Notes: + all tests share superuser; tests must not update superuser + tests have available superuser, client, and admin + """ + + @classmethod + def setUpClass(self): + super().setUpClass() + self.site = AdminSite() + self.admin = DomainAdmin(model=Domain, admin_site=self.site) + self.factory = RequestFactory() + self.superuser = create_superuser() + + def setUp(self): + self.client = Client(HTTP_HOST="localhost:8080") + self.client.force_login(self.superuser) + super().setUp() + + def tearDown(self): + super().tearDown() + Host.objects.all().delete() + Domain.objects.all().delete() + DomainInformation.objects.all().delete() + DomainRequest.objects.all().delete() + + @classmethod + def tearDownClass(self): + User.objects.all().delete() + super().tearDownClass() + + @less_console_noise_decorator + def test_has_model_description(self): + """Tests if this model has a model description on the table view""" + response = self.client.get( + "/admin/registrar/domain/", + follow=True, + ) + + # Make sure that the page is loaded correctly + self.assertEqual(response.status_code, 200) + + # Test for a description snippet + self.assertContains(response, "This table contains all approved domains in the .gov registrar.") + self.assertContains(response, "Show more") + + @less_console_noise_decorator + def test_contact_fields_on_domain_change_form_have_detail_table(self): + """Tests if the contact fields in the inlined Domain information have the detail table + which displays title, email, and phone""" + + # Create fake creator + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + email="meoward.jones@igorville.gov", + phone="(555) 123 12345", + title="Treat inspector", + ) + + # Create a fake domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) + domain_request.approve() + _domain_info = DomainInformation.objects.filter(domain=domain_request.approved_domain).get() + domain = Domain.objects.filter(domain_info=_domain_info).get() + + 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) + + # Check that the fields have the right values. + # == Check for the creator == # + + # Check for the right title, email, and phone number in the response. + # We only need to check for the end tag + # (Otherwise this test will fail if we change classes, etc) + self.assertContains(response, "Treat inspector") + self.assertContains(response, "meoward.jones@igorville.gov") + self.assertContains(response, "(555) 123 12345") + + # Check for the field itself + self.assertContains(response, "Meoward Jones") + + # == Check for the submitter == # + self.assertContains(response, "mayor@igorville.gov") + + self.assertContains(response, "Admin Tester") + self.assertContains(response, "(555) 555 5556") + self.assertContains(response, "Testy2 Tester2") + + # == Check for the senior_official == # + self.assertContains(response, "testy@town.com") + self.assertContains(response, "Chief Tester") + self.assertContains(response, "(555) 555 5555") + + # Includes things like readonly fields + self.assertContains(response, "Testy Tester") + + # == Test the other_employees field == # + self.assertContains(response, "testy2@town.com") + self.assertContains(response, "Another Tester") + self.assertContains(response, "(555) 555 5557") + + # Test for the copy link + self.assertContains(response, "button--clipboard") + + # cleanup from this test + domain.delete() + _domain_info.delete() + domain_request.delete() + _creator.delete() + + @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) + + 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) + + # Contains some test tools + test_helper = GenericTestHelper( + factory=self.factory, + user=self.superuser, + admin=self.admin, + url=reverse("admin:registrar_domain_changelist"), + model=Domain, + client=self.client, + ) + # 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'), + ] + 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 + """ + + # Add domain data + ready_domain, _ = Domain.objects.get_or_create(name="fakeready.gov", state=Domain.State.READY) + unknown_domain, _ = Domain.objects.get_or_create(name="fakeunknown.gov", state=Domain.State.UNKNOWN) + dns_domain, _ = Domain.objects.get_or_create(name="fakedns.gov", state=Domain.State.DNS_NEEDED) + hold_domain, _ = Domain.objects.get_or_create(name="fakehold.gov", state=Domain.State.ON_HOLD) + deleted_domain, _ = Domain.objects.get_or_create(name="fakedeleted.gov", state=Domain.State.DELETED) + + # 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 = [ + (ready_domain, "This domain has name servers and is ready for use."), + (unknown_domain, expected_unknown_domain_message), + (dns_domain, expected_dns_message), + (hold_domain, expected_hold_message), + (deleted_domain, expected_deleted_message), + ] + + 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) + + @less_console_noise_decorator + def test_admin_can_see_inline_domain_information_in_domain_change_form(self): + """Tests if an admin can still see the inline domain information form""" + # Create fake creator + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + ) + + # Create a fake domain request + _domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) + + # Creates a Domain and DomainInformation object + _domain_request.approve() + + domain_information = DomainInformation.objects.filter(domain_request=_domain_request).get() + domain_information.organization_name = "MonkeySeeMonkeyDo" + domain_information.save() + + # We use filter here rather than just domain_information.domain just to get the latest data. + domain = Domain.objects.filter(domain_info=domain_information).get() + + response = self.client.get( + "/admin/registrar/domain/{}/change/".format(domain.pk), + follow=True, + ) + + # Make sure the page loaded, and that we're on the right page + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + + # Test for data. We only need to test one since its all interconnected. + expected_organization_name = "MonkeySeeMonkeyDo" + self.assertContains(response, expected_organization_name) + + # cleanup from this test + domain.delete() + domain_information.delete() + _domain_request.delete() + _creator.delete() + + @less_console_noise_decorator + def test_custom_delete_confirmation_page_table(self): + """Tests if we override the delete confirmation page for custom content on the table""" + # Create a ready domain + domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) + + # Get the index. The post expects the index to be encoded as a string + index = f"{domain.id}" + + # Contains some test tools + test_helper = GenericTestHelper( + factory=self.factory, + user=self.superuser, + admin=self.admin, + url=reverse("admin:registrar_domain_changelist"), + model=Domain, + client=self.client, + ) + + # Simulate selecting a single record, then clicking "Delete selected domains" + response = test_helper.get_table_delete_confirmation_page("0", index) + + # Check that our content exists + content_slice = "When a domain is deleted:" + self.assertContains(response, content_slice) + + @less_console_noise_decorator + def test_short_org_name_in_domains_list(self): + """ + Make sure the short name is displaying in admin on the list page + """ + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + mock_client = MockSESClient() + with boto3_mocking.clients.handler_for("sesv2", mock_client): + domain_request.approve() + + response = self.client.get("/admin/registrar/domain/") + # There are 4 template references to Federal (4) plus four references in the table + # for our actual domain_request + self.assertContains(response, "Federal", count=56) + # This may be a bit more robust + self.assertContains(response, 'Federal', count=1) + # Now let's make sure the long description does not exist + self.assertNotContains(response, "Federal: an agency of the U.S. government") + + @override_settings(IS_PRODUCTION=True) + @less_console_noise_decorator + def test_prod_only_shows_export(self): + """Test that production environment only displays export""" + response = self.client.get("/admin/registrar/domain/") + self.assertContains(response, ">Export<") + self.assertNotContains(response, ">Import<") + + +class TestDomainAdminWebTest(MockEppLib, WebTest): + """Test DomainAdmin class as super user, using WebTest. + WebTest allows for easier handling of forms and html responses. + + Notes: + all tests share superuser; tests must not update superuser + tests have available superuser, app, and admin + """ + + # csrf checks do not work with WebTest. + # We disable them here. TODO for another ticket. + csrf_checks = False + + @classmethod + def setUpClass(self): + super().setUpClass() + self.site = AdminSite() + self.admin = DomainAdmin(model=Domain, admin_site=self.site) + self.superuser = create_superuser() + self.factory = RequestFactory() + + def setUp(self): + super().setUp() + self.app.set_user(self.superuser.username) + + def tearDown(self): + super().tearDown() + Host.objects.all().delete() + Domain.objects.all().delete() + DomainInformation.objects.all().delete() + DomainRequest.objects.all().delete() + + @classmethod + def tearDownClass(self): + User.objects.all().delete() + super().tearDownClass() + + @less_console_noise_decorator + @patch("registrar.admin.DomainAdmin._get_current_date", return_value=date(2024, 1, 1)) + def test_extend_expiration_date_button(self, mock_date_today): + """ + Tests if extend_expiration_date modal gives an accurate date + """ + + # Create a ready domain with a preset expiration date + domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) + response = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk])) + # load expiration date into cache and registrar with below command + domain.registry_expiration_date + # Make sure the ex date is what we expect it to be + domain_ex_date = Domain.objects.get(id=domain.id).expiration_date + self.assertEqual(domain_ex_date, date(2023, 5, 25)) + + # Make sure that the page is loading as expected + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + self.assertContains(response, "Extend expiration date") + + # Grab the form to submit + form = response.forms["domain_form"] + + with patch("django.contrib.messages.add_message") as mock_add_message: + # Submit the form + response = form.submit("_extend_expiration_date") + + # Follow the response + response = response.follow() + + # Assert that everything on the page looks correct + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + self.assertContains(response, "Extend expiration date") + + # Ensure the message we recieve is in line with what we expect + expected_message = "Successfully extended the expiration date." + expected_call = call( + # The WGSI request doesn't need to be tested + ANY, + messages.INFO, + expected_message, + extra_tags="", + fail_silently=False, + ) + + mock_add_message.assert_has_calls([expected_call], 1) + + @less_console_noise_decorator + @patch("registrar.admin.DomainAdmin._get_current_date", return_value=date(2024, 1, 1)) + def test_extend_expiration_date_button_epp(self, mock_date_today): + """ + Tests if extend_expiration_date button sends the right epp command + """ + + # Create a ready domain with a preset expiration date + domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) + + response = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk])) + + # Make sure that the page is loading as expected + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + self.assertContains(response, "Extend expiration date") + + # Grab the form to submit + form = response.forms["domain_form"] + + with patch("django.contrib.messages.add_message") as mock_add_message: + with patch("registrar.models.Domain.renew_domain") as renew_mock: + # Submit the form + response = form.submit("_extend_expiration_date") + + # Follow the response + response = response.follow() + + # Assert that it is calling the function with the default extension length. + # We only need to test the value that EPP sends, as we can assume the other + # test cases cover the "renew" function. + renew_mock.assert_has_calls([call()], any_order=False) + + # We should not make duplicate calls + self.assertEqual(renew_mock.call_count, 1) + + # Assert that everything on the page looks correct + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + self.assertContains(response, "Extend expiration date") + + # Ensure the message we recieve is in line with what we expect + expected_message = "Successfully extended the expiration date." + expected_call = call( + # The WGSI request doesn't need to be tested + ANY, + messages.INFO, + expected_message, + extra_tags="", + fail_silently=False, + ) + mock_add_message.assert_has_calls([expected_call], 1) + + @less_console_noise_decorator + def test_custom_delete_confirmation_page(self): + """Tests if we override the delete confirmation page for custom content""" + # Create a ready domain with a preset expiration date + domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) + + domain_change_page = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk])) + + self.assertContains(domain_change_page, "fake.gov") + # click the "Delete" link + confirmation_page = domain_change_page.click("Delete", index=0) + + content_slice = "When a domain is deleted:" + self.assertContains(confirmation_page, content_slice) + + @less_console_noise_decorator + def test_on_hold_is_successful_web_test(self): + """ + Scenario: Domain on_hold is successful through webtest + """ + with less_console_noise(): + domain = create_ready_domain() + + response = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk])) + + # Check the contents of the modal + # Check for the header + self.assertContains(response, "Are you sure you want to place this domain on hold?") + + # Check for some of its body + self.assertContains(response, "When a domain is on hold:") + + # Check for some of the button content + self.assertContains(response, "Yes, place hold") + + # Grab the form to submit + form = response.forms["domain_form"] + + # Submit the form + response = form.submit("_place_client_hold") + + # Follow the response + response = response.follow() + + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + self.assertContains(response, "Remove hold") + + # The modal should still exist + # Check for the header + self.assertContains(response, "Are you sure you want to place this domain on hold?") + + # Check for some of its body + self.assertContains(response, "When a domain is on hold:") + + # Check for some of the button content + self.assertContains(response, "Yes, place hold") + + # Web test has issues grabbing up to date data from the db, so we can test + # the returned view instead + self.assertContains(response, '
    On hold
    ') diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py new file mode 100644 index 000000000..c4fc8bcee --- /dev/null +++ b/src/registrar/tests/test_admin_request.py @@ -0,0 +1,2064 @@ +from datetime import datetime +from django.utils import timezone +import re +from django.test import RequestFactory, Client, TestCase, override_settings +from django.contrib.admin.sites import AdminSite +from contextlib import ExitStack +from api.tests.common import less_console_noise_decorator +from django.contrib import messages +from django.urls import reverse +from registrar.admin import ( + DomainRequestAdmin, + DomainRequestAdminForm, + MyUserAdmin, + AuditedAdmin, +) +from registrar.models import ( + Domain, + DomainRequest, + DomainInformation, + DraftDomain, + User, + Contact, + Website, + SeniorOfficial, +) +from .common import ( + MockSESClient, + completed_domain_request, + generic_domain_object, + less_console_noise, + create_superuser, + create_user, + multiple_unalphabetical_domain_objects, + MockEppLib, + GenericTestHelper, +) +from unittest.mock import patch + +from django.conf import settings +import boto3_mocking # type: ignore +import logging + +logger = logging.getLogger(__name__) + + +@boto3_mocking.patching +class TestDomainRequestAdmin(MockEppLib): + """Test DomainRequestAdmin class as either staff or super user. + + Notes: + all tests share superuser/staffuser; do not change these models in tests + tests have available staffuser, superuser, client, admin and test_helper + """ + + @classmethod + def setUpClass(self): + super().setUpClass() + self.site = AdminSite() + self.factory = RequestFactory() + self.admin = DomainRequestAdmin(model=DomainRequest, admin_site=self.site) + self.superuser = create_superuser() + self.staffuser = create_user() + self.client = Client(HTTP_HOST="localhost:8080") + self.test_helper = GenericTestHelper( + factory=self.factory, + user=self.superuser, + admin=self.admin, + url="/admin/registrar/domainrequest/", + model=DomainRequest, + ) + self.mock_client = MockSESClient() + + def tearDown(self): + super().tearDown() + Domain.objects.all().delete() + DomainInformation.objects.all().delete() + DomainRequest.objects.all().delete() + Contact.objects.all().delete() + Website.objects.all().delete() + SeniorOfficial.objects.all().delete() + self.mock_client.EMAILS_SENT.clear() + + @classmethod + def tearDownClass(self): + super().tearDownClass() + User.objects.all().delete() + + @less_console_noise_decorator + def test_domain_request_senior_official_is_alphabetically_sorted(self): + """Tests if the senior offical dropdown is alphanetically sorted in the django admin display""" + + SeniorOfficial.objects.get_or_create(first_name="mary", last_name="joe", title="some other guy") + SeniorOfficial.objects.get_or_create(first_name="alex", last_name="smoe", title="some guy") + SeniorOfficial.objects.get_or_create(first_name="Zoup", last_name="Soup", title="title") + + contact, _ = Contact.objects.get_or_create(first_name="Henry", last_name="McFakerson") + domain_request = completed_domain_request(submitter=contact, name="city1.gov") + request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) + model_admin = AuditedAdmin(DomainRequest, self.site) + + # Get the queryset that would be returned for the list + senior_offical_queryset = model_admin.formfield_for_foreignkey( + DomainInformation.senior_official.field, request + ).queryset + + # Make the list we're comparing on a bit prettier display-wise. Optional step. + current_sort_order = [] + for official in senior_offical_queryset: + current_sort_order.append(f"{official.first_name} {official.last_name}") + + expected_sort_order = ["alex smoe", "mary joe", "Zoup Soup"] + + self.assertEqual(current_sort_order, expected_sort_order) + + @less_console_noise_decorator + def test_has_model_description(self): + """Tests if this model has a model description on the table view""" + self.client.force_login(self.superuser) + response = self.client.get( + "/admin/registrar/domainrequest/", + follow=True, + ) + + # Make sure that the page is loaded correctly + self.assertEqual(response.status_code, 200) + + # Test for a description snippet + self.assertContains(response, "This table contains all domain requests") + self.assertContains(response, "Show more") + + @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) + + self.client.force_login(self.superuser) + 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. + """ + + def assert_status_count(normalized_content, status, count): + """Helper function to assert the count of a status in the HTML content.""" + self.assertEqual(normalized_content.count(f" {status} "), count) + + def assert_status_order(normalized_content, statuses): + """Helper function to assert the order of statuses in the HTML content.""" + start_index = 0 + for status in statuses: + index = normalized_content.find(f" {status} ", start_index) + self.assertNotEqual(index, -1, f"Status '{status}' not found in the expected order.") + start_index = index + len(status) + + # Create a fake domain request and domain + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.STARTED) + + self.client.force_login(self.superuser) + 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) + + domain_request.submit() + domain_request.save() + + domain_request.in_review() + domain_request.save() + + domain_request.action_needed() + domain_request.action_needed_reason = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS + domain_request.save() + + # Let's just change the action needed reason + domain_request.action_needed_reason = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR + domain_request.save() + + domain_request.reject() + domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE + domain_request.save() + + domain_request.in_review() + domain_request.save() + + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), + follow=True, + ) + + # Normalize the HTML response content + normalized_content = " ".join(response.content.decode("utf-8").split()) + + # Define the expected sequence of status changes + expected_status_changes = [ + "In review", + "Rejected - Purpose requirements not met", + "Action needed - Unclear organization eligibility", + "Action needed - Already has domains", + "In review", + "Submitted", + "Started", + ] + + assert_status_order(normalized_content, expected_status_changes) + + assert_status_count(normalized_content, "Started", 1) + assert_status_count(normalized_content, "Submitted", 1) + assert_status_count(normalized_content, "In review", 2) + assert_status_count(normalized_content, "Action needed - Already has domains", 1) + assert_status_count(normalized_content, "Action needed - Unclear organization eligibility", 1) + assert_status_count(normalized_content, "Rejected - Purpose requirements not met", 1) + + @less_console_noise_decorator + 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) + + self.client.force_login(self.superuser) + 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.assertContains(response, "Show details") + + @less_console_noise_decorator + def test_analyst_can_see_and_edit_alternative_domain(self): + """Tests if an analyst can still see and edit the alternative domain field""" + + # Create fake creator + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + ) + + # Create a fake domain request + _domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) + + fake_website = Website.objects.create(website="thisisatest.gov") + _domain_request.alternative_domains.add(fake_website) + _domain_request.save() + + self.client.force_login(self.staffuser) + 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) + + # Test if the page has the alternative domain + self.assertContains(response, "thisisatest.gov") + + # Check that the page contains the url we expect + expected_href = reverse("admin:registrar_website_change", args=[fake_website.id]) + self.assertContains(response, expected_href) + + # Navigate to the website to ensure that we can still edit it + response = self.client.get( + "/admin/registrar/website/{}/change/".format(fake_website.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, "thisisatest.gov") + + # clean up objects in this test + fake_website.delete() + _domain_request.delete() + _creator.delete() + + @less_console_noise_decorator + def test_analyst_can_see_and_edit_requested_domain(self): + """Tests if an analyst can still see and edit the requested domain field""" + + # Create fake creator + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + ) + + # Create a fake domain request + _domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) + + self.client.force_login(self.staffuser) + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(_domain_request.pk), + follow=True, + ) + + # Filter to get the latest from the DB (rather than direct assignment) + requested_domain = DraftDomain.objects.filter(name=_domain_request.requested_domain.name).get() + + # Make sure the page loaded, and that we're on the right page + self.assertEqual(response.status_code, 200) + self.assertContains(response, requested_domain.name) + + # Check that the page contains the url we expect + expected_href = reverse("admin:registrar_draftdomain_change", args=[requested_domain.id]) + self.assertContains(response, expected_href) + + # Navigate to the website to ensure that we can still edit it + response = self.client.get( + "/admin/registrar/draftdomain/{}/change/".format(requested_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, "city.gov") + + # clean up objects in this test + _domain_request.delete() + requested_domain.delete() + _creator.delete() + + @less_console_noise_decorator + def test_analyst_can_see_current_websites(self): + """Tests if an analyst can still see current website field""" + + # Create fake creator + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + ) + + # Create a fake domain request + _domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) + + fake_website = Website.objects.create(website="thisisatest.gov") + _domain_request.current_websites.add(fake_website) + _domain_request.save() + + self.client.force_login(self.staffuser) + 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) + + # Test if the page has the current website + self.assertContains(response, "thisisatest.gov") + + # clean up objects in this test + fake_website.delete() + _domain_request.delete() + _creator.delete() + + @less_console_noise_decorator + def test_domain_sortable(self): + """Tests if the DomainRequest sorts by domain correctly""" + self.client.force_login(self.superuser) + + multiple_unalphabetical_domain_objects("domain_request") + + # Assert that our sort works correctly + self.test_helper.assert_table_sorted("1", ("requested_domain__name",)) + + # Assert that sorting in reverse works correctly + self.test_helper.assert_table_sorted("-1", ("-requested_domain__name",)) + + @less_console_noise_decorator + def test_submitter_sortable(self): + """Tests if the DomainRequest sorts by submitter correctly""" + self.client.force_login(self.superuser) + + multiple_unalphabetical_domain_objects("domain_request") + + additional_domain_request = generic_domain_object("domain_request", "Xylophone") + new_user = User.objects.filter(username=additional_domain_request.investigator.username).get() + new_user.first_name = "Xylophonic" + new_user.save() + + # Assert that our sort works correctly + self.test_helper.assert_table_sorted( + "11", + ( + "submitter__first_name", + "submitter__last_name", + ), + ) + + # Assert that sorting in reverse works correctly + self.test_helper.assert_table_sorted( + "-11", + ( + "-submitter__first_name", + "-submitter__last_name", + ), + ) + + # clean up objects in this test + new_user.delete() + + @less_console_noise_decorator + def test_investigator_sortable(self): + """Tests if the DomainRequest sorts by investigator correctly""" + self.client.force_login(self.superuser) + + multiple_unalphabetical_domain_objects("domain_request") + additional_domain_request = generic_domain_object("domain_request", "Xylophone") + new_user = User.objects.filter(username=additional_domain_request.investigator.username).get() + new_user.first_name = "Xylophonic" + new_user.save() + + # Assert that our sort works correctly + self.test_helper.assert_table_sorted( + "12", + ( + "investigator__first_name", + "investigator__last_name", + ), + ) + + # Assert that sorting in reverse works correctly + self.test_helper.assert_table_sorted( + "-12", + ( + "-investigator__first_name", + "-investigator__last_name", + ), + ) + + # clean up objects in this test + new_user.delete() + + @less_console_noise_decorator + def test_default_sorting_in_domain_requests_list(self): + """ + Make sure the default sortin in on the domain requests list page is reverse submission_date + then alphabetical requested_domain + """ + + # Create domain requests with different names + domain_requests = [ + completed_domain_request(status=DomainRequest.DomainRequestStatus.SUBMITTED, name=name) + for name in ["ccc.gov", "bbb.gov", "eee.gov", "aaa.gov", "zzz.gov", "ddd.gov"] + ] + + domain_requests[0].submission_date = timezone.make_aware(datetime(2024, 10, 16)) + domain_requests[1].submission_date = timezone.make_aware(datetime(2001, 10, 16)) + domain_requests[2].submission_date = timezone.make_aware(datetime(1980, 10, 16)) + domain_requests[3].submission_date = timezone.make_aware(datetime(1998, 10, 16)) + domain_requests[4].submission_date = timezone.make_aware(datetime(2013, 10, 16)) + domain_requests[5].submission_date = timezone.make_aware(datetime(1980, 10, 16)) + + # Save the modified domain requests to update their attributes in the database + for domain_request in domain_requests: + domain_request.save() + + # Refresh domain request objects from the database to reflect the changes + domain_requests = [DomainRequest.objects.get(pk=domain_request.pk) for domain_request in domain_requests] + + # Login as superuser and retrieve the domain request list page + self.client.force_login(self.superuser) + response = self.client.get("/admin/registrar/domainrequest/") + + # Check that the response is successful + self.assertEqual(response.status_code, 200) + + # Extract the domain names from the response content using regex + domain_names_match = re.findall(r"(\w+\.gov)", response.content.decode("utf-8")) + + logger.info(f"domain_names_match {domain_names_match}") + + # Verify that domain names are found + self.assertTrue(domain_names_match) + + # Extract the domain names + domain_names = [match for match in domain_names_match] + + # Verify that the domain names are displayed in the expected order + expected_order = [ + "ccc.gov", + "zzz.gov", + "bbb.gov", + "aaa.gov", + "ddd.gov", + "eee.gov", + ] + + # Remove duplicates + # Remove duplicates from domain_names list while preserving order + unique_domain_names = [] + for domain_name in domain_names: + if domain_name not in unique_domain_names: + unique_domain_names.append(domain_name) + + self.assertEqual(unique_domain_names, expected_order) + + @less_console_noise_decorator + def test_short_org_name_in_domain_requests_list(self): + """ + Make sure the short name is displaying in admin on the list page + """ + self.client.force_login(self.superuser) + completed_domain_request() + response = self.client.get("/admin/registrar/domainrequest/?generic_org_type__exact=federal") + # There are 2 template references to Federal (4) and two in the results data + # of the request + self.assertContains(response, "Federal", count=52) + # This may be a bit more robust + self.assertContains(response, 'Federal', count=1) + # Now let's make sure the long description does not exist + self.assertNotContains(response, "Federal: an agency of the U.S. government") + + @less_console_noise_decorator + def test_default_status_in_domain_requests_list(self): + """ + Make sure the default status in admin is selected on the domain requests list page + """ + self.client.force_login(self.superuser) + completed_domain_request() + response = self.client.get("/admin/registrar/domainrequest/") + # The results are filtered by "status in [submitted,in review,action needed]" + self.assertContains(response, "status in [submitted,in review,action needed]", count=1) + + @less_console_noise_decorator + def transition_state_and_send_email( + self, domain_request, status, rejection_reason=None, action_needed_reason=None, action_needed_reason_email=None + ): + """Helper method for the email test cases.""" + + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + # Create a mock request + request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) + + # Create a fake session to hook to + request.session = {} + + # Modify the domain request's properties + domain_request.status = status + + if rejection_reason: + domain_request.rejection_reason = rejection_reason + + if action_needed_reason: + domain_request.action_needed_reason = action_needed_reason + + if action_needed_reason_email: + domain_request.action_needed_reason_email = action_needed_reason_email + + # Use the model admin's save_model method + self.admin.save_model(request, domain_request, form=None, change=True) + + def assert_email_is_accurate( + self, expected_string, email_index, email_address, test_that_no_bcc=False, bcc_email_address="" + ): + """Helper method for the email test cases. + email_index is the index of the email in mock_client.""" + + with less_console_noise(): + # Access the arguments passed to send_email + call_args = self.mock_client.EMAILS_SENT + kwargs = call_args[email_index]["kwargs"] + + # Retrieve the email details from the arguments + from_email = kwargs.get("FromEmailAddress") + to_email = kwargs["Destination"]["ToAddresses"][0] + email_content = kwargs["Content"] + email_body = email_content["Simple"]["Body"]["Text"]["Data"] + + # Assert or perform other checks on the email details + self.assertEqual(from_email, settings.DEFAULT_FROM_EMAIL) + self.assertEqual(to_email, email_address) + self.assertIn(expected_string, email_body) + + if test_that_no_bcc: + _ = "" + with self.assertRaises(KeyError): + with less_console_noise(): + _ = kwargs["Destination"]["BccAddresses"][0] + self.assertEqual(_, "") + + if bcc_email_address: + bcc_email = kwargs["Destination"]["BccAddresses"][0] + self.assertEqual(bcc_email, bcc_email_address) + + @override_settings(IS_PRODUCTION=True) + @less_console_noise_decorator + def test_action_needed_sends_reason_email_prod_bcc(self): + """When an action needed reason is set, an email is sent out and help@get.gov + is BCC'd in production""" + # Ensure there is no user with this email + EMAIL = "mayor@igorville.gov" + BCC_EMAIL = settings.DEFAULT_FROM_EMAIL + User.objects.filter(email=EMAIL).delete() + in_review = DomainRequest.DomainRequestStatus.IN_REVIEW + action_needed = DomainRequest.DomainRequestStatus.ACTION_NEEDED + + # Create a sample domain request + domain_request = completed_domain_request(status=in_review) + + # Test the email sent out for already_has_domains + already_has_domains = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS + self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_domains) + + self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) + + # Test the email sent out for bad_name + bad_name = DomainRequest.ActionNeededReasons.BAD_NAME + self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=bad_name) + self.assert_email_is_accurate( + "DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS", 1, EMAIL, bcc_email_address=BCC_EMAIL + ) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) + + # Test the email sent out for eligibility_unclear + eligibility_unclear = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR + self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=eligibility_unclear) + self.assert_email_is_accurate( + "ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", 2, EMAIL, bcc_email_address=BCC_EMAIL + ) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + + # Test that a custom email is sent out for questionable_so + questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL + self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=questionable_so) + self.assert_email_is_accurate( + "SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, EMAIL, bcc_email_address=BCC_EMAIL + ) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 4) + + # Assert that no other emails are sent on OTHER + other = DomainRequest.ActionNeededReasons.OTHER + self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=other) + + # Should be unchanged from before + self.assertEqual(len(self.mock_client.EMAILS_SENT), 4) + + # Tests if an analyst can override existing email content + questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL + self.transition_state_and_send_email( + domain_request, + action_needed, + action_needed_reason=questionable_so, + action_needed_reason_email="custom email content", + ) + + domain_request.refresh_from_db() + self.assert_email_is_accurate("custom email content", 4, EMAIL, bcc_email_address=BCC_EMAIL) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 5) + + # Tests if a new email gets sent when just the email is changed. + # An email should NOT be sent out if we just modify the email content. + self.transition_state_and_send_email( + domain_request, + action_needed, + action_needed_reason=questionable_so, + action_needed_reason_email="dummy email content", + ) + + self.assertEqual(len(self.mock_client.EMAILS_SENT), 5) + + # Set the request back to in review + domain_request.in_review() + + # Try sending another email when changing states AND including content + self.transition_state_and_send_email( + domain_request, + action_needed, + action_needed_reason=eligibility_unclear, + action_needed_reason_email="custom content when starting anew", + ) + self.assert_email_is_accurate("custom content when starting anew", 5, EMAIL, bcc_email_address=BCC_EMAIL) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 6) + + # def test_action_needed_sends_reason_email_prod_bcc(self): + # """When an action needed reason is set, an email is sent out and help@get.gov + # is BCC'd in production""" + # # Ensure there is no user with this email + # EMAIL = "mayor@igorville.gov" + # BCC_EMAIL = settings.DEFAULT_FROM_EMAIL + # User.objects.filter(email=EMAIL).delete() + # in_review = DomainRequest.DomainRequestStatus.IN_REVIEW + # action_needed = DomainRequest.DomainRequestStatus.ACTION_NEEDED + + # # Create a sample domain request + # domain_request = completed_domain_request(status=in_review) + + # # Test the email sent out for already_has_domains + # already_has_domains = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS + # self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_domains) + # self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL) + # self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) + + # # Test the email sent out for bad_name + # bad_name = DomainRequest.ActionNeededReasons.BAD_NAME + # self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=bad_name) + # self.assert_email_is_accurate( + # "DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS", 1, EMAIL, bcc_email_address=BCC_EMAIL + # ) + # self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) + + # # Test the email sent out for eligibility_unclear + # eligibility_unclear = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR + # self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=eligibility_unclear) + # self.assert_email_is_accurate( + # "ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", 2, EMAIL, bcc_email_address=BCC_EMAIL + # ) + # self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + + # # Test the email sent out for questionable_so + # questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL + # self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=questionable_so) + # self.assert_email_is_accurate( + # "SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, EMAIL, bcc_email_address=BCC_EMAIL + # ) + # self.assertEqual(len(self.mock_client.EMAILS_SENT), 4) + + # # Assert that no other emails are sent on OTHER + # other = DomainRequest.ActionNeededReasons.OTHER + # self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=other) + + # # Should be unchanged from before + # self.assertEqual(len(self.mock_client.EMAILS_SENT), 4) + + @less_console_noise_decorator + def test_save_model_sends_submitted_email(self): + """When transitioning to submitted from started or withdrawn on a domain request, + an email is sent out. + + When transitioning to submitted from dns needed or in review on a domain request, + no email is sent out. + + Also test that the default email set in settings is NOT BCCd on non-prod whenever + an email does go out.""" + + # Ensure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + # Create a sample domain request + domain_request = completed_domain_request() + + # Test Submitted Status from started + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) + self.assert_email_is_accurate("We received your .gov domain request.", 0, EMAIL, True) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) + + # Test Withdrawn Status + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.WITHDRAWN) + self.assert_email_is_accurate( + "Your .gov domain request has been withdrawn and will not be reviewed by our team.", 1, EMAIL, True + ) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) + + # Test Submitted Status Again (from withdrawn) + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + + # Move it to IN_REVIEW + other = DomainRequest.ActionNeededReasons.OTHER + in_review = DomainRequest.DomainRequestStatus.IN_REVIEW + self.transition_state_and_send_email(domain_request, in_review, action_needed_reason=other) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + + # Test Submitted Status Again from in IN_REVIEW, no new email should be sent + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + + # Move it to IN_REVIEW + self.transition_state_and_send_email(domain_request, in_review, action_needed_reason=other) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + + # Move it to ACTION_NEEDED + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.ACTION_NEEDED) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + + # Test Submitted Status Again from in ACTION_NEEDED, no new email should be sent + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + + @less_console_noise_decorator + def test_model_displays_action_needed_email(self): + """Tests if the action needed email is visible for Domain Requests""" + + _domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.ACTION_NEEDED, + action_needed_reason=DomainRequest.ActionNeededReasons.BAD_NAME, + ) + + self.client.force_login(self.staffuser) + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(_domain_request.pk), + follow=True, + ) + + self.assertContains(response, "DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS") + + @override_settings(IS_PRODUCTION=True) + @less_console_noise_decorator + def test_save_model_sends_submitted_email_with_bcc_on_prod(self): + """When transitioning to submitted from started or withdrawn on a domain request, + an email is sent out. + + When transitioning to submitted from dns needed or in review on a domain request, + no email is sent out. + + Also test that the default email set in settings IS BCCd on prod whenever + an email does go out.""" + + # Ensure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + BCC_EMAIL = settings.DEFAULT_FROM_EMAIL + + # Create a sample domain request + domain_request = completed_domain_request() + + # Test Submitted Status from started + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) + self.assert_email_is_accurate("We received your .gov domain request.", 0, EMAIL, False, BCC_EMAIL) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) + + # Test Withdrawn Status + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.WITHDRAWN) + self.assert_email_is_accurate( + "Your .gov domain request has been withdrawn and will not be reviewed by our team.", 1, EMAIL + ) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) + + # Test Submitted Status Again (from withdrawn) + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) + self.assert_email_is_accurate("We received your .gov domain request.", 0, EMAIL, False, BCC_EMAIL) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + + # Move it to IN_REVIEW + other = domain_request.ActionNeededReasons.OTHER + in_review = DomainRequest.DomainRequestStatus.IN_REVIEW + self.transition_state_and_send_email(domain_request, in_review, action_needed_reason=other) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + + # Test Submitted Status Again from in IN_REVIEW, no new email should be sent + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + + # Move it to IN_REVIEW + self.transition_state_and_send_email(domain_request, in_review, action_needed_reason=other) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + + # Move it to ACTION_NEEDED + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.ACTION_NEEDED) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + + # Test Submitted Status Again from in ACTION_NEEDED, no new email should be sent + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + + @less_console_noise_decorator + def test_save_model_sends_approved_email(self): + """When transitioning to approved on a domain request, + an email is sent out every time.""" + + # Ensure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + # Test Submitted Status + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) + self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 0, EMAIL) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) + + # Test Withdrawn Status + self.transition_state_and_send_email( + domain_request, + DomainRequest.DomainRequestStatus.REJECTED, + DomainRequest.RejectionReasons.DOMAIN_PURPOSE, + ) + self.assert_email_is_accurate("Your .gov domain request has been rejected.", 1, EMAIL) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) + + # Test Submitted Status Again (No new email should be sent) + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + + @less_console_noise_decorator + def test_save_model_sends_rejected_email_purpose_not_met(self): + """When transitioning to rejected on a domain request, an email is sent + explaining why when the reason is domain purpose.""" + + # Ensure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + # Reject for reason DOMAIN_PURPOSE and test email + self.transition_state_and_send_email( + domain_request, + DomainRequest.DomainRequestStatus.REJECTED, + DomainRequest.RejectionReasons.DOMAIN_PURPOSE, + ) + self.assert_email_is_accurate( + "Your domain request was rejected because the purpose you provided did not meet our \nrequirements.", + 0, + EMAIL, + ) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) + + # Approve + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) + self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) + + @less_console_noise_decorator + def test_save_model_sends_rejected_email_requestor(self): + """When transitioning to rejected on a domain request, an email is sent + explaining why when the reason is requestor.""" + + # Ensure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + # Reject for reason REQUESTOR and test email including dynamic organization name + self.transition_state_and_send_email( + domain_request, DomainRequest.DomainRequestStatus.REJECTED, DomainRequest.RejectionReasons.REQUESTOR + ) + self.assert_email_is_accurate( + "Your domain request was rejected because we don’t believe you’re eligible to request a \n.gov " + "domain on behalf of Testorg", + 0, + EMAIL, + ) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) + + # Approve + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) + self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) + + @less_console_noise_decorator + def test_save_model_sends_rejected_email_org_has_domain(self): + """When transitioning to rejected on a domain request, an email is sent + explaining why when the reason is second domain.""" + + # Ensure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + # Reject for reason SECOND_DOMAIN_REASONING and test email including dynamic organization name + self.transition_state_and_send_email( + domain_request, + DomainRequest.DomainRequestStatus.REJECTED, + DomainRequest.RejectionReasons.SECOND_DOMAIN_REASONING, + ) + self.assert_email_is_accurate("Your domain request was rejected because Testorg has a .gov domain.", 0, EMAIL) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) + + # Approve + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) + self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) + + @less_console_noise_decorator + def test_save_model_sends_rejected_email_contacts_or_org_legitimacy(self): + """When transitioning to rejected on a domain request, an email is sent + explaining why when the reason is contacts or org legitimacy.""" + + # Ensure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + # Reject for reason CONTACTS_OR_ORGANIZATION_LEGITIMACY and test email including dynamic organization name + self.transition_state_and_send_email( + domain_request, + DomainRequest.DomainRequestStatus.REJECTED, + DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY, + ) + self.assert_email_is_accurate( + "Your domain request was rejected because we could not verify the organizational \n" + "contacts you provided. If you have questions or comments, reply to this email.", + 0, + EMAIL, + ) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) + + # Approve + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) + self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) + + @less_console_noise_decorator + def test_save_model_sends_rejected_email_org_eligibility(self): + """When transitioning to rejected on a domain request, an email is sent + explaining why when the reason is org eligibility.""" + + # Ensure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + # Reject for reason ORGANIZATION_ELIGIBILITY and test email including dynamic organization name + self.transition_state_and_send_email( + domain_request, + DomainRequest.DomainRequestStatus.REJECTED, + DomainRequest.RejectionReasons.ORGANIZATION_ELIGIBILITY, + ) + self.assert_email_is_accurate( + "Your domain request was rejected because we determined that Testorg is not \neligible for " + "a .gov domain.", + 0, + EMAIL, + ) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) + + # Approve + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) + self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) + + @less_console_noise_decorator + def test_save_model_sends_rejected_email_naming(self): + """When transitioning to rejected on a domain request, an email is sent + explaining why when the reason is naming.""" + + # Ensure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + # Reject for reason NAMING_REQUIREMENTS and test email including dynamic organization name + self.transition_state_and_send_email( + domain_request, + DomainRequest.DomainRequestStatus.REJECTED, + DomainRequest.RejectionReasons.NAMING_REQUIREMENTS, + ) + self.assert_email_is_accurate( + "Your domain request was rejected because it does not meet our naming requirements.", 0, EMAIL + ) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) + + # Approve + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) + self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) + + @less_console_noise_decorator + def test_save_model_sends_rejected_email_other(self): + """When transitioning to rejected on a domain request, an email is sent + explaining why when the reason is other.""" + + # Ensure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + # Reject for reason NAMING_REQUIREMENTS and test email including dynamic organization name + self.transition_state_and_send_email( + domain_request, + DomainRequest.DomainRequestStatus.REJECTED, + DomainRequest.RejectionReasons.OTHER, + ) + self.assert_email_is_accurate("Choosing a .gov domain name", 0, EMAIL) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) + + # Approve + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.APPROVED) + self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) + + @less_console_noise_decorator + def test_transition_to_rejected_without_rejection_reason_does_trigger_error(self): + """ + When transitioning to rejected without a rejection reason, admin throws a user friendly message. + + The transition fails. + """ + + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED) + + # Create a request object with a superuser + request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) + request.user = self.superuser + + with ExitStack() as stack: + stack.enter_context(patch.object(messages, "error")) + domain_request.status = DomainRequest.DomainRequestStatus.REJECTED + + self.admin.save_model(request, domain_request, None, True) + + messages.error.assert_called_once_with( + request, + "A reason is required for this status.", + ) + + domain_request.refresh_from_db() + self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.APPROVED) + + @less_console_noise_decorator + def test_transition_to_rejected_with_rejection_reason_does_not_trigger_error(self): + """ + When transitioning to rejected with a rejection reason, admin does not throw an error alert. + + The transition is successful. + """ + + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED) + + # Create a request object with a superuser + request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) + request.user = self.superuser + + with ExitStack() as stack: + stack.enter_context(patch.object(messages, "error")) + domain_request.status = DomainRequest.DomainRequestStatus.REJECTED + domain_request.rejection_reason = DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY + + self.admin.save_model(request, domain_request, None, True) + + messages.error.assert_not_called() + + domain_request.refresh_from_db() + self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.REJECTED) + + @less_console_noise_decorator + def test_save_model_sends_withdrawn_email(self): + """When transitioning to withdrawn on a domain request, + an email is sent out every time.""" + + # Ensure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + # Test Submitted Status + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.WITHDRAWN) + self.assert_email_is_accurate( + "Your .gov domain request has been withdrawn and will not be reviewed by our team.", 0, EMAIL + ) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) + + # Test Withdrawn Status + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED) + self.assert_email_is_accurate("We received your .gov domain request.", 1, EMAIL) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) + + # Test Submitted Status Again (No new email should be sent) + self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.WITHDRAWN) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + + @less_console_noise_decorator + def test_save_model_sets_approved_domain(self): + # make sure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + # Create a mock request + request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) + + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + # Modify the domain request's property + domain_request.status = DomainRequest.DomainRequestStatus.APPROVED + + # Use the model admin's save_model method + self.admin.save_model(request, domain_request, form=None, change=True) + + # Test that approved domain exists and equals requested domain + self.assertEqual(domain_request.requested_domain.name, domain_request.approved_domain.name) + + @less_console_noise_decorator + def test_sticky_submit_row(self): + """Test that the change_form template contains strings indicative of the customization + of the sticky submit bar. + + Also test that it does NOT contain a CSS class meant for analysts only when logged in as superuser.""" + + # make sure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + self.client.force_login(self.superuser) + + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + # Create a mock request + request = self.client.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) + + # Since we're using client to mock the request, we can only test against + # non-interpolated values + expected_content = "Requested domain:" + expected_content2 = '' + expected_content3 = '
    ' + not_expected_content = "submit-row-wrapper--analyst-view>" + self.assertContains(request, expected_content) + self.assertContains(request, expected_content2) + self.assertContains(request, expected_content3) + self.assertNotContains(request, not_expected_content) + + @less_console_noise_decorator + def test_sticky_submit_row_has_extra_class_for_analysts(self): + """Test that the change_form template contains strings indicative of the customization + of the sticky submit bar. + + Also test that it DOES contain a CSS class meant for analysts only when logged in as analyst.""" + + # make sure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + self.client.force_login(self.staffuser) + + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + # Create a mock request + request = self.client.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) + + # Since we're using client to mock the request, we can only test against + # non-interpolated values + expected_content = "Requested domain:" + expected_content2 = '' + expected_content3 = '
    ' + self.assertContains(request, expected_content) + self.assertContains(request, expected_content2) + self.assertContains(request, expected_content3) + + @less_console_noise_decorator + def test_other_contacts_has_readonly_link(self): + """Tests if the readonly other_contacts field has links""" + + # Create a fake domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + # Get the other contact + other_contact = domain_request.other_contacts.all().first() + + self.client.force_login(self.staffuser) + 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) + + # Check that the page contains the url we expect + expected_href = reverse("admin:registrar_contact_change", args=[other_contact.id]) + self.assertContains(response, expected_href) + + # Check that the page contains the link we expect. + # Since the url is dynamic (populated by JS), we can test for its existence + # by checking for the end tag. + expected_url = "Testy Tester" + self.assertContains(response, expected_url) + + @less_console_noise_decorator + def test_other_websites_has_readonly_link(self): + """Tests if the readonly other_websites field has links""" + + # Create a fake domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + self.client.force_login(self.staffuser) + 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) + + # Check that the page contains the link we expect. + expected_url = 'city.com' + self.assertContains(response, expected_url) + + @less_console_noise_decorator + def test_contact_fields_have_detail_table(self): + """Tests if the contact fields have the detail table which displays title, email, and phone""" + + # Create fake creator + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + email="meoward.jones@igorville.gov", + phone="(555) 123 12345", + title="Treat inspector", + ) + + # Create a fake domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator) + + self.client.force_login(self.staffuser) + 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) + + # == Check for the creator == # + + # Check for the right title, email, and phone number in the response. + expected_creator_fields = [ + # Field, expected value + ("title", "Treat inspector"), + ("phone", "(555) 123 12345"), + ] + self.test_helper.assert_response_contains_distinct_values(response, expected_creator_fields) + self.assertContains(response, "meoward.jones@igorville.gov") + + # Check for the field itself + self.assertContains(response, "Meoward Jones") + + # == Check for the submitter == # + self.assertContains(response, "mayor@igorville.gov", count=2) + expected_submitter_fields = [ + # Field, expected value + ("title", "Admin Tester"), + ("phone", "(555) 555 5556"), + ] + self.test_helper.assert_response_contains_distinct_values(response, expected_submitter_fields) + self.assertContains(response, "Testy2 Tester2") + + # == Check for the senior_official == # + self.assertContains(response, "testy@town.com", count=2) + expected_so_fields = [ + # Field, expected value + ("phone", "(555) 555 5555"), + ] + + self.test_helper.assert_response_contains_distinct_values(response, expected_so_fields) + self.assertContains(response, "Chief Tester") + + # == Test the other_employees field == # + self.assertContains(response, "testy2@town.com") + expected_other_employees_fields = [ + # Field, expected value + ("title", "Another Tester"), + ("phone", "(555) 555 5557"), + ] + self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) + + # Test for the copy link + self.assertContains(response, "button--clipboard", count=5) + + # Test that Creator counts display properly + self.assertNotContains(response, "Approved domains") + self.assertContains(response, "Active requests") + + # cleanup objects from this test + domain_request.delete() + _creator.delete() + + @less_console_noise_decorator + def test_save_model_sets_restricted_status_on_user(self): + # make sure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + # Create a mock request + request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), follow=True) + + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + # Modify the domain request's property + domain_request.status = DomainRequest.DomainRequestStatus.INELIGIBLE + + # Use the model admin's save_model method + self.admin.save_model(request, domain_request, form=None, change=True) + + # Test that approved domain exists and equals requested domain + self.assertEqual(domain_request.creator.status, "restricted") + + @less_console_noise_decorator + def test_user_sets_restricted_status_modal(self): + """Tests the modal for when a user sets the status to restricted""" + # make sure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + self.client.force_login(self.staffuser) + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain_request.requested_domain.name) + + # Check that the modal has the right content + # Check for the header + self.assertContains(response, "Are you sure you want to select ineligible status?") + + # Check for some of its body + self.assertContains(response, "When a domain request is in ineligible status") + + # Check for some of the button content + self.assertContains(response, "Yes, select ineligible status") + + # Create a mock request + request = self.factory.post("/admin/registrar/domainrequest{}/change/".format(domain_request.pk), follow=True) + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + # Modify the domain request's property + domain_request.status = DomainRequest.DomainRequestStatus.INELIGIBLE + + # Use the model admin's save_model method + self.admin.save_model(request, domain_request, form=None, change=True) + + # Test that approved domain exists and equals requested domain + self.assertEqual(domain_request.creator.status, "restricted") + + # 'Get' to the domain request again + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain_request.requested_domain.name) + + # The modal should be unchanged + self.assertContains(response, "Are you sure you want to select ineligible status?") + self.assertContains(response, "When a domain request is in ineligible status") + self.assertContains(response, "Yes, select ineligible status") + + @less_console_noise_decorator + def test_readonly_when_restricted_creator(self): + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + domain_request.creator.status = User.RESTRICTED + domain_request.creator.save() + + request = self.factory.get("/") + request.user = self.superuser + + readonly_fields = self.admin.get_readonly_fields(request, domain_request) + + expected_fields = [ + "other_contacts", + "current_websites", + "alternative_domains", + "is_election_board", + "status_history", + "id", + "created_at", + "updated_at", + "status", + "rejection_reason", + "action_needed_reason", + "action_needed_reason_email", + "federal_agency", + "portfolio", + "sub_organization", + "creator", + "investigator", + "generic_org_type", + "is_election_board", + "organization_type", + "federally_recognized_tribe", + "state_recognized_tribe", + "tribe_name", + "federal_type", + "organization_name", + "address_line1", + "address_line2", + "city", + "state_territory", + "zipcode", + "urbanization", + "about_your_organization", + "senior_official", + "approved_domain", + "requested_domain", + "submitter", + "purpose", + "no_other_contacts_rationale", + "anything_else", + "has_anything_else_text", + "cisa_representative_email", + "cisa_representative_first_name", + "cisa_representative_last_name", + "has_cisa_representative", + "is_policy_acknowledged", + "submission_date", + "notes", + "alternative_domains", + ] + self.maxDiff = None + self.assertEqual(readonly_fields, expected_fields) + + def test_readonly_fields_for_analyst(self): + with less_console_noise(): + request = self.factory.get("/") # Use the correct method and path + request.user = self.staffuser + + readonly_fields = self.admin.get_readonly_fields(request) + + expected_fields = [ + "other_contacts", + "current_websites", + "alternative_domains", + "is_election_board", + "status_history", + "federal_agency", + "creator", + "about_your_organization", + "requested_domain", + "approved_domain", + "alternative_domains", + "purpose", + "submitter", + "no_other_contacts_rationale", + "anything_else", + "is_policy_acknowledged", + "cisa_representative_first_name", + "cisa_representative_last_name", + "cisa_representative_email", + ] + self.assertEqual(readonly_fields, expected_fields) + + def test_readonly_fields_for_superuser(self): + with less_console_noise(): + request = self.factory.get("/") # Use the correct method and path + request.user = self.superuser + + readonly_fields = self.admin.get_readonly_fields(request) + + expected_fields = [ + "other_contacts", + "current_websites", + "alternative_domains", + "is_election_board", + "status_history", + ] + + self.assertEqual(readonly_fields, expected_fields) + + def test_saving_when_restricted_creator(self): + with less_console_noise(): + # Create an instance of the model + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + domain_request.creator.status = User.RESTRICTED + domain_request.creator.save() + + # Create a request object with a superuser + request = self.factory.get("/") + request.user = self.superuser + + with patch("django.contrib.messages.error") as mock_error: + # Simulate saving the model + self.admin.save_model(request, domain_request, None, False) + + # Assert that the error message was called with the correct argument + mock_error.assert_called_once_with( + request, + "This action is not permitted for domain requests with a restricted creator.", + ) + + # Assert that the status has not changed + self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.IN_REVIEW) + + def test_change_view_with_restricted_creator(self): + with less_console_noise(): + # Create an instance of the model + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + domain_request.creator.status = User.RESTRICTED + domain_request.creator.save() + + with patch("django.contrib.messages.warning") as mock_warning: + # Create a request object with a superuser + request = self.factory.get("/admin/your_app/domainrequest/{}/change/".format(domain_request.pk)) + request.user = self.superuser + + self.admin.display_restricted_warning(request, domain_request) + + # Assert that the error message was called with the correct argument + mock_warning.assert_called_once_with( + request, + "Cannot edit a domain request with a restricted creator.", + ) + + def trigger_saving_approved_to_another_state(self, domain_is_active, another_state, rejection_reason=None): + """Helper method that triggers domain request state changes from approved to another state, + with an associated domain that can be either active (READY) or not. + + Used to test errors when saving a change with an active domain, also used to test side effects + when saving a change goes through.""" + + with less_console_noise(): + # Create an instance of the model + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED) + domain = Domain.objects.create(name=domain_request.requested_domain.name) + domain_information = DomainInformation.objects.create(creator=self.superuser, domain=domain) + domain_request.approved_domain = domain + domain_request.save() + + # Create a request object with a superuser + request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) + request.user = self.superuser + + request.session = {} + + # Define a custom implementation for is_active + def custom_is_active(self): + return domain_is_active # Override to return True + + # Use ExitStack to combine patch contexts + with ExitStack() as stack: + # Patch Domain.is_active and django.contrib.messages.error simultaneously + stack.enter_context(patch.object(Domain, "is_active", custom_is_active)) + stack.enter_context(patch.object(messages, "error")) + + domain_request.status = another_state + + if another_state == DomainRequest.DomainRequestStatus.ACTION_NEEDED: + domain_request.action_needed_reason = domain_request.ActionNeededReasons.OTHER + + domain_request.rejection_reason = rejection_reason + + self.admin.save_model(request, domain_request, None, True) + + # Assert that the error message was called with the correct argument + if domain_is_active: + messages.error.assert_called_once_with( + request, + "This action is not permitted. The domain " + "is already active.", + ) + else: + # Assert that the error message was never called + messages.error.assert_not_called() + + self.assertEqual(domain_request.approved_domain, None) + + # Assert that Domain got Deleted + with self.assertRaises(Domain.DoesNotExist): + domain.refresh_from_db() + + # Assert that DomainInformation got Deleted + with self.assertRaises(DomainInformation.DoesNotExist): + domain_information.refresh_from_db() + + def test_error_when_saving_approved_to_in_review_and_domain_is_active(self): + self.trigger_saving_approved_to_another_state(True, DomainRequest.DomainRequestStatus.IN_REVIEW) + + def test_error_when_saving_approved_to_action_needed_and_domain_is_active(self): + self.trigger_saving_approved_to_another_state(True, DomainRequest.DomainRequestStatus.ACTION_NEEDED) + + def test_error_when_saving_approved_to_rejected_and_domain_is_active(self): + self.trigger_saving_approved_to_another_state(True, DomainRequest.DomainRequestStatus.REJECTED) + + def test_error_when_saving_approved_to_ineligible_and_domain_is_active(self): + self.trigger_saving_approved_to_another_state(True, DomainRequest.DomainRequestStatus.INELIGIBLE) + + def test_side_effects_when_saving_approved_to_in_review(self): + self.trigger_saving_approved_to_another_state(False, DomainRequest.DomainRequestStatus.IN_REVIEW) + + def test_side_effects_when_saving_approved_to_action_needed(self): + self.trigger_saving_approved_to_another_state(False, DomainRequest.DomainRequestStatus.ACTION_NEEDED) + + def test_side_effects_when_saving_approved_to_rejected(self): + self.trigger_saving_approved_to_another_state( + False, + DomainRequest.DomainRequestStatus.REJECTED, + DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY, + ) + + def test_side_effects_when_saving_approved_to_ineligible(self): + self.trigger_saving_approved_to_another_state(False, DomainRequest.DomainRequestStatus.INELIGIBLE) + + def test_has_correct_filters(self): + """ + This test verifies that DomainRequestAdmin has the correct filters set up. + + It retrieves the current list of filters from DomainRequestAdmin + and checks that it matches the expected list of filters. + """ + with less_console_noise(): + request = self.factory.get("/") + request.user = self.superuser + + # Grab the current list of table filters + readonly_fields = self.admin.get_list_filter(request) + expected_fields = ( + DomainRequestAdmin.StatusListFilter, + "generic_org_type", + "federal_type", + DomainRequestAdmin.ElectionOfficeFilter, + "rejection_reason", + DomainRequestAdmin.InvestigatorFilter, + ) + + self.assertEqual(readonly_fields, expected_fields) + + def test_table_sorted_alphabetically(self): + """ + This test verifies that the DomainRequestAdmin table is sorted alphabetically + by the 'requested_domain__name' field. + + It creates a list of DomainRequest instances in a non-alphabetical order, + then retrieves the queryset from the DomainRequestAdmin and checks + that it matches the expected queryset, + which is sorted alphabetically by the 'requested_domain__name' field. + """ + with less_console_noise(): + # Creates a list of DomainRequests in scrambled order + multiple_unalphabetical_domain_objects("domain_request") + + request = self.factory.get("/") + request.user = self.superuser + + # Get the expected list of alphabetically sorted DomainRequests + expected_order = DomainRequest.objects.order_by("requested_domain__name") + + # Get the returned queryset + queryset = self.admin.get_queryset(request) + + # Check the order + self.assertEqual( + list(queryset), + list(expected_order), + ) + + def test_displays_investigator_filter(self): + """ + This test verifies that the investigator filter in the admin interface for + the DomainRequest model displays correctly. + + It creates two DomainRequest instances, each with a different investigator. + It then simulates a staff user logging in and applying the investigator filter + on the DomainRequest admin page. + + We then test if the page displays the filter we expect, but we do not test + if we get back the correct response in the table. This is to isolate if + the filter displays correctly, when the filter isn't filtering correctly. + """ + + with less_console_noise(): + # Create a mock DomainRequest object, with a fake investigator + domain_request: DomainRequest = generic_domain_object("domain_request", "SomeGuy") + investigator_user = User.objects.filter(username=domain_request.investigator.username).get() + investigator_user.is_staff = True + investigator_user.save() + + self.client.force_login(self.staffuser) + response = self.client.get( + "/admin/registrar/domainrequest/", + { + "investigator__id__exact": investigator_user.id, + }, + follow=True, + ) + + # Then, test if the filter actually exists + self.assertIn("filters", response.context) + + # Assert the content of filters and search_query + filters = response.context["filters"] + + self.assertEqual( + filters, + [ + { + "parameter_name": "investigator", + "parameter_value": "SomeGuy first_name:investigator SomeGuy last_name:investigator", + }, + ], + ) + + def test_investigator_dropdown_displays_only_staff(self): + """ + This test verifies that the dropdown for the 'investigator' field in the DomainRequestAdmin + interface only displays users who are marked as staff. + + It creates two DomainRequest instances, one with an investigator + who is a staff user and another with an investigator who is not a staff user. + + It then retrieves the queryset for the 'investigator' dropdown from DomainRequestAdmin + and checks that it matches the expected queryset, which only includes staff users. + """ + + with less_console_noise(): + # Create a mock DomainRequest object, with a fake investigator + domain_request: DomainRequest = generic_domain_object("domain_request", "SomeGuy") + investigator_user = User.objects.filter(username=domain_request.investigator.username).get() + investigator_user.is_staff = True + investigator_user.save() + + # Create a mock DomainRequest object, with a user that is not staff + domain_request_2: DomainRequest = generic_domain_object("domain_request", "SomeOtherGuy") + investigator_user_2 = User.objects.filter(username=domain_request_2.investigator.username).get() + investigator_user_2.is_staff = False + investigator_user_2.save() + + self.client.force_login(self.staffuser) + + request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) + + # Get the actual field from the model's meta information + investigator_field = DomainRequest._meta.get_field("investigator") + + # We should only be displaying staff users, in alphabetical order + sorted_fields = ["first_name", "last_name", "email"] + expected_dropdown = list(User.objects.filter(is_staff=True).order_by(*sorted_fields)) + + # Grab the current dropdown. We do an API call to autocomplete to get this info. + domain_request_queryset = self.admin.formfield_for_foreignkey(investigator_field, request).queryset + user_request = self.factory.post( + "/admin/autocomplete/?app_label=registrar&model_name=domainrequest&field_name=investigator" + ) + user_admin = MyUserAdmin(User, self.site) + user_queryset = user_admin.get_search_results(user_request, domain_request_queryset, None)[0] + current_dropdown = list(user_queryset) + + self.assertEqual(expected_dropdown, current_dropdown) + + # Non staff users should not be in the list + self.assertNotIn(domain_request_2, current_dropdown) + + def test_investigator_list_is_alphabetically_sorted(self): + """ + This test verifies that filter list for the 'investigator' + is displayed alphabetically + """ + with less_console_noise(): + # Create a mock DomainRequest object, with a fake investigator + domain_request: DomainRequest = generic_domain_object("domain_request", "SomeGuy") + investigator_user = User.objects.filter(username=domain_request.investigator.username).get() + investigator_user.is_staff = True + investigator_user.save() + + domain_request_2: DomainRequest = generic_domain_object("domain_request", "AGuy") + investigator_user_2 = User.objects.filter(username=domain_request_2.investigator.username).get() + investigator_user_2.first_name = "AGuy" + investigator_user_2.is_staff = True + investigator_user_2.save() + + domain_request_3: DomainRequest = generic_domain_object("domain_request", "FinalGuy") + investigator_user_3 = User.objects.filter(username=domain_request_3.investigator.username).get() + investigator_user_3.first_name = "FinalGuy" + investigator_user_3.is_staff = True + investigator_user_3.save() + + self.client.force_login(self.staffuser) + request = RequestFactory().get("/") + + # These names have metadata embedded in them. :investigator implicitly tests if + # these are actually from the attribute "investigator". + expected_list = [ + "AGuy AGuy last_name:investigator", + "FinalGuy FinalGuy last_name:investigator", + "SomeGuy first_name:investigator SomeGuy last_name:investigator", + ] + + # Get the actual sorted list of investigators from the lookups method + actual_list = [item for _, item in self.admin.InvestigatorFilter.lookups(self, request, self.admin)] + + self.assertEqual(expected_list, actual_list) + + @less_console_noise_decorator + def test_staff_can_see_cisa_region_federal(self): + """Tests if staff can see CISA Region: N/A""" + + # Create a fake domain request + _domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + self.client.force_login(self.staffuser) + 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) + + # Test if the page has the right CISA region + expected_html = '
    CISA region: N/A
    ' + # Remove whitespace from expected_html + expected_html = "".join(expected_html.split()) + + # Remove whitespace from response content + response_content = "".join(response.content.decode().split()) + + # Check if response contains expected_html + self.assertIn(expected_html, response_content) + + @less_console_noise_decorator + def test_staff_can_see_cisa_region_non_federal(self): + """Tests if staff can see the correct CISA region""" + + # Create a fake domain request. State will be NY (2). + _domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.IN_REVIEW, generic_org_type="interstate" + ) + + self.client.force_login(self.staffuser) + 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) + + # Test if the page has the right CISA region + expected_html = '
    CISA region: 2
    ' + # Remove whitespace from expected_html + expected_html = "".join(expected_html.split()) + + # Remove whitespace from response content + response_content = "".join(response.content.decode().split()) + + # Check if response contains expected_html + self.assertIn(expected_html, response_content) + + +class TestDomainRequestAdminForm(TestCase): + + def test_form_choices(self): + with less_console_noise(): + # Create a test domain request with an initial state of started + domain_request = completed_domain_request() + + # Create a form instance with the test domain request + form = DomainRequestAdminForm(instance=domain_request) + + # Verify that the form choices match the available transitions for started + expected_choices = [("started", "Started"), ("submitted", "Submitted")] + self.assertEqual(form.fields["status"].widget.choices, expected_choices) + + # cleanup + domain_request.delete() + + def test_form_no_rejection_reason(self): + with less_console_noise(): + # Create a test domain request with an initial state of started + domain_request = completed_domain_request() + + # Create a form instance with the test domain request + form = DomainRequestAdminForm(instance=domain_request) + + form = DomainRequestAdminForm( + instance=domain_request, + data={ + "status": DomainRequest.DomainRequestStatus.REJECTED, + "rejection_reason": None, + }, + ) + self.assertFalse(form.is_valid()) + self.assertIn("rejection_reason", form.errors) + + rejection_reason = form.errors.get("rejection_reason") + self.assertEqual(rejection_reason, ["A reason is required for this status."]) + + # cleanup + domain_request.delete() + + def test_form_choices_when_no_instance(self): + with less_console_noise(): + # Create a form instance without an instance + form = DomainRequestAdminForm() + + # Verify that the form choices show all choices when no instance is provided; + # this is necessary to show all choices when creating a new domain + # request in django admin; + # note that FSM ensures that no domain request exists with invalid status, + # so don't need to test for invalid status + self.assertEqual( + form.fields["status"].widget.choices, + DomainRequest._meta.get_field("status").choices, + ) + + def test_form_choices_when_ineligible(self): + with less_console_noise(): + # Create a form instance with a domain request with ineligible status + ineligible_domain_request = DomainRequest(status="ineligible") + + # Attempt to create a form with the ineligible domain request + # The form should not raise an error, but choices should be the + # full list of possible choices + form = DomainRequestAdminForm(instance=ineligible_domain_request) + + self.assertEqual( + form.fields["status"].widget.choices, + DomainRequest._meta.get_field("status").choices, + ) diff --git a/src/registrar/tests/test_admin_views.py b/src/registrar/tests/test_admin_views.py index cc4b3f1c7..e88c070df 100644 --- a/src/registrar/tests/test_admin_views.py +++ b/src/registrar/tests/test_admin_views.py @@ -1,6 +1,7 @@ from django.test import TestCase, Client from django.urls import reverse from registrar.tests.common import create_superuser +from api.tests.common import less_console_noise_decorator class TestAdminViews(TestCase): @@ -8,6 +9,7 @@ class TestAdminViews(TestCase): self.client = Client(HTTP_HOST="localhost:8080") self.superuser = create_superuser() + @less_console_noise_decorator def test_export_data_view(self): self.client.force_login(self.superuser) diff --git a/src/registrar/tests/test_emails.py b/src/registrar/tests/test_emails.py index b01272e64..8cf707004 100644 --- a/src/registrar/tests/test_emails.py +++ b/src/registrar/tests/test_emails.py @@ -6,8 +6,9 @@ from django.test import TestCase from waffle.testutils import override_flag from registrar.utility import email from registrar.utility.email import send_templated_email -from .common import completed_domain_request, less_console_noise +from .common import completed_domain_request +from api.tests.common import less_console_noise_decorator from datetime import datetime import boto3_mocking # type: ignore @@ -19,6 +20,7 @@ class TestEmails(TestCase): @boto3_mocking.patching @override_flag("disable_email_sending", active=True) + @less_console_noise_decorator def test_disable_email_flag(self): """Test if the 'disable_email_sending' stops emails from being sent""" with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): @@ -36,13 +38,13 @@ class TestEmails(TestCase): self.assertFalse(self.mock_client.send_email.called) @boto3_mocking.patching + @less_console_noise_decorator def test_submission_confirmation(self): """Submission confirmation email works.""" domain_request = completed_domain_request() with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): - with less_console_noise(): - domain_request.submit() + domain_request.submit() # check that an email was sent self.assertTrue(self.mock_client.send_email.called) @@ -74,12 +76,12 @@ class TestEmails(TestCase): self.assertIn("Anything else", body) @boto3_mocking.patching + @less_console_noise_decorator def test_submission_confirmation_no_current_website_spacing(self): """Test line spacing without current_website.""" domain_request = completed_domain_request(has_current_website=False) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): - with less_console_noise(): - domain_request.submit() + domain_request.submit() _, kwargs = self.mock_client.send_email.call_args body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] self.assertNotIn("Current websites:", body) @@ -87,12 +89,12 @@ class TestEmails(TestCase): self.assertRegex(body, r"5555\n\n.gov domain:") @boto3_mocking.patching + @less_console_noise_decorator def test_submission_confirmation_current_website_spacing(self): """Test line spacing with current_website.""" domain_request = completed_domain_request(has_current_website=True) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): - with less_console_noise(): - domain_request.submit() + domain_request.submit() _, kwargs = self.mock_client.send_email.call_args body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] self.assertIn("Current websites:", body) @@ -101,12 +103,12 @@ class TestEmails(TestCase): self.assertRegex(body, r"city.com\n\n.gov domain:") @boto3_mocking.patching + @less_console_noise_decorator def test_submission_confirmation_other_contacts_spacing(self): """Test line spacing with other contacts.""" domain_request = completed_domain_request(has_other_contacts=True) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): - with less_console_noise(): - domain_request.submit() + domain_request.submit() _, kwargs = self.mock_client.send_email.call_args body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] self.assertIn("Other employees from your organization:", body) @@ -115,12 +117,12 @@ class TestEmails(TestCase): self.assertRegex(body, r"5557\n\nAnything else") @boto3_mocking.patching + @less_console_noise_decorator def test_submission_confirmation_no_other_contacts_spacing(self): """Test line spacing without other contacts.""" domain_request = completed_domain_request(has_other_contacts=False) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): - with less_console_noise(): - domain_request.submit() + domain_request.submit() _, kwargs = self.mock_client.send_email.call_args body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] # spacing should be right between adjacent elements @@ -128,12 +130,12 @@ class TestEmails(TestCase): self.assertRegex(body, r"None\n\nAnything else") @boto3_mocking.patching + @less_console_noise_decorator def test_submission_confirmation_alternative_govdomain_spacing(self): """Test line spacing with alternative .gov domain.""" domain_request = completed_domain_request(has_alternative_gov_domain=True) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): - with less_console_noise(): - domain_request.submit() + domain_request.submit() _, kwargs = self.mock_client.send_email.call_args body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] self.assertIn("city1.gov", body) @@ -141,12 +143,12 @@ class TestEmails(TestCase): self.assertRegex(body, r"city.gov\n\nAlternative domains:\ncity1.gov\n\nPurpose of your domain:") @boto3_mocking.patching + @less_console_noise_decorator def test_submission_confirmation_no_alternative_govdomain_spacing(self): """Test line spacing without alternative .gov domain.""" domain_request = completed_domain_request(has_alternative_gov_domain=False) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): - with less_console_noise(): - domain_request.submit() + domain_request.submit() _, kwargs = self.mock_client.send_email.call_args body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] self.assertNotIn("city1.gov", body) @@ -154,12 +156,12 @@ class TestEmails(TestCase): self.assertRegex(body, r"city.gov\n\nPurpose of your domain:") @boto3_mocking.patching + @less_console_noise_decorator def test_submission_confirmation_about_your_organization_spacing(self): """Test line spacing with about your organization.""" domain_request = completed_domain_request(has_about_your_organization=True) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): - with less_console_noise(): - domain_request.submit() + domain_request.submit() _, kwargs = self.mock_client.send_email.call_args body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] self.assertIn("About your organization:", body) @@ -167,12 +169,12 @@ class TestEmails(TestCase): self.assertRegex(body, r"10002\n\nAbout your organization:") @boto3_mocking.patching + @less_console_noise_decorator def test_submission_confirmation_no_about_your_organization_spacing(self): """Test line spacing without about your organization.""" domain_request = completed_domain_request(has_about_your_organization=False) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): - with less_console_noise(): - domain_request.submit() + domain_request.submit() _, kwargs = self.mock_client.send_email.call_args body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] self.assertNotIn("About your organization:", body) @@ -180,24 +182,24 @@ class TestEmails(TestCase): self.assertRegex(body, r"10002\n\nSenior official:") @boto3_mocking.patching + @less_console_noise_decorator def test_submission_confirmation_anything_else_spacing(self): """Test line spacing with anything else.""" domain_request = completed_domain_request(has_anything_else=True) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): - with less_console_noise(): - domain_request.submit() + domain_request.submit() _, kwargs = self.mock_client.send_email.call_args body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] # spacing should be right between adjacent elements self.assertRegex(body, r"5557\n\nAnything else?") @boto3_mocking.patching + @less_console_noise_decorator def test_submission_confirmation_no_anything_else_spacing(self): """Test line spacing without anything else.""" domain_request = completed_domain_request(has_anything_else=False) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): - with less_console_noise(): - domain_request.submit() + domain_request.submit() _, kwargs = self.mock_client.send_email.call_args body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] self.assertNotIn("Anything else", body) @@ -205,6 +207,7 @@ class TestEmails(TestCase): self.assertRegex(body, r"5557\n\n----") @boto3_mocking.patching + @less_console_noise_decorator def test_send_email_with_attachment(self): with boto3_mocking.clients.handler_for("ses", self.mock_client_class): sender_email = "sender@example.com" diff --git a/src/registrar/tests/test_management_scripts.py b/src/registrar/tests/test_management_scripts.py index cfe19b091..1958454f5 100644 --- a/src/registrar/tests/test_management_scripts.py +++ b/src/registrar/tests/test_management_scripts.py @@ -2,6 +2,7 @@ import copy from datetime import date, datetime, time from django.core.management import call_command from django.test import TestCase, override_settings +from registrar.models.senior_official import SeniorOfficial from registrar.utility.constants import BranchChoices from django.utils import timezone from django.utils.module_loading import import_string @@ -36,6 +37,7 @@ logger = logging.getLogger(__name__) class TestPopulateVerificationType(MockEppLib): """Tests for the populate_organization_type script""" + @less_console_noise_decorator def setUp(self): """Creates a fake domain object""" super().setUp() @@ -133,6 +135,7 @@ class TestPopulateVerificationType(MockEppLib): class TestPopulateOrganizationType(MockEppLib): """Tests for the populate_organization_type script""" + @less_console_noise_decorator def setUp(self): """Creates a fake domain object""" super().setUp() @@ -205,6 +208,7 @@ class TestPopulateOrganizationType(MockEppLib): ): call_command("populate_organization_type", "registrar/tests/data/fake_election_domains.csv") + @less_console_noise_decorator def assert_expected_org_values_on_request_and_info( self, domain_request: DomainRequest, @@ -247,6 +251,7 @@ class TestPopulateOrganizationType(MockEppLib): """Does nothing for mocking purposes""" pass + @less_console_noise_decorator def test_request_and_info_city_not_in_csv(self): """ Tests what happens to a city domain that is not defined in the CSV. @@ -282,6 +287,7 @@ class TestPopulateOrganizationType(MockEppLib): # All values should be the same self.assert_expected_org_values_on_request_and_info(city_request, city_info, expected_values) + @less_console_noise_decorator def test_request_and_info_federal(self): """ Tests what happens to a federal domain after the script is run (should be unchanged). @@ -316,6 +322,7 @@ class TestPopulateOrganizationType(MockEppLib): # All values should be the same self.assert_expected_org_values_on_request_and_info(federal_request, federal_info, expected_values) + @less_console_noise_decorator def test_request_and_info_tribal_add_election_office(self): """ Tests if a tribal domain in the election csv changes organization_type to TRIBAL - ELECTION @@ -356,6 +363,7 @@ class TestPopulateOrganizationType(MockEppLib): self.assert_expected_org_values_on_request_and_info(tribal_request, tribal_info, expected_values) + @less_console_noise_decorator def test_request_and_info_tribal_doesnt_remove_election_office(self): """ Tests if a tribal domain in the election csv changes organization_type to TRIBAL_ELECTION @@ -409,6 +417,7 @@ class TestPopulateOrganizationType(MockEppLib): class TestPopulateFirstReady(TestCase): """Tests for the populate_first_ready script""" + @less_console_noise_decorator def setUp(self): """Creates a fake domain object""" super().setUp() @@ -537,6 +546,7 @@ class TestPopulateFirstReady(TestCase): class TestPatchAgencyInfo(TestCase): + @less_console_noise_decorator def setUp(self): self.user, _ = User.objects.get_or_create(username="testuser") self.domain, _ = Domain.objects.get_or_create(name="testdomain.gov") @@ -560,6 +570,7 @@ class TestPatchAgencyInfo(TestCase): class TestExtendExpirationDates(MockEppLib): + @less_console_noise_decorator def setUp(self): """Defines the file name of migration_json and the folder its contained in""" super().setUp() @@ -800,36 +811,69 @@ class TestCleanTables(TestCase): @override_settings(IS_PRODUCTION=False) def test_command_cleans_tables(self): """test that the handle method functions properly to clean tables""" - with less_console_noise(): - with patch("django.apps.apps.get_model") as get_model_mock: - model_mock = MagicMock() - get_model_mock.return_value = model_mock - with patch( - "registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", # noqa - return_value=True, - ): - call_command("clean_tables") + with patch("django.apps.apps.get_model") as get_model_mock: + model_mock = MagicMock() + get_model_mock.return_value = model_mock - table_names = [ - "DomainInformation", - "DomainRequest", - "PublicContact", - "Domain", - "User", - "Contact", - "Website", - "DraftDomain", - "HostIp", - "Host", - ] + with patch( + "registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", # noqa + return_value=True, + ): + + # List of pks to be returned in batches, one list for each of 11 tables + pk_batch = [1, 2, 3, 4, 5, 6] + # Create a list of batches with alternating non-empty and empty lists + pk_batches = [pk_batch, []] * 11 + + # Set the side effect of values_list to return different pk batches + # First time values_list is called it returns list of 6 objects to delete; + # Next time values_list is called it returns empty list + def values_list_side_effect(*args, **kwargs): + if args == ("pk",) and kwargs.get("flat", False): + return pk_batches.pop(0) + return [] + + model_mock.objects.values_list.side_effect = values_list_side_effect + # Mock the return value of `delete()` to be (6, ...) + model_mock.objects.filter.return_value.delete.return_value = (6, None) + + call_command("clean_tables") + + table_names = [ + "DomainInformation", + "DomainRequest", + "FederalAgency", + "PublicContact", + "HostIp", + "Host", + "Domain", + "User", + "Contact", + "Website", + "DraftDomain", + ] + + expected_filter_calls = [call(pk__in=[1, 2, 3, 4, 5, 6]) for _ in range(11)] + + actual_filter_calls = [c for c in model_mock.objects.filter.call_args_list if "pk__in" in c[1]] + + try: + # Assert that filter(pk__in=...) was called with expected arguments + self.assertEqual(actual_filter_calls, expected_filter_calls) + + # Check that delete() was called for each batch + for batch in [[1, 2, 3, 4, 5, 6]]: + model_mock.objects.filter(pk__in=batch).delete.assert_called() - # Check that each model's delete method was called for table_name in table_names: get_model_mock.assert_any_call("registrar", table_name) - model_mock.objects.all().delete.assert_called() - - self.logger_mock.info.assert_any_call("Successfully cleaned table DomainInformation") + self.logger_mock.info.assert_any_call( + f"Successfully cleaned table {table_name}, deleted 6 rows" + ) + except AssertionError as e: + print(f"AssertionError: {e}") + raise @override_settings(IS_PRODUCTION=False) def test_command_handles_nonexistent_model(self): @@ -860,15 +904,33 @@ class TestCleanTables(TestCase): with patch("django.apps.apps.get_model") as get_model_mock: model_mock = MagicMock() get_model_mock.return_value = model_mock - model_mock.objects.all().delete.side_effect = Exception("Some error") + + # Mock the values_list so that DomainInformation attempts a delete + pk_batches = [[1, 2, 3, 4, 5, 6], []] + + def values_list_side_effect(*args, **kwargs): + if args == ("pk",) and kwargs.get("flat", False): + return pk_batches.pop(0) + return [] + + model_mock.objects.values_list.side_effect = values_list_side_effect + + # Mock delete to raise a generic exception + model_mock.objects.filter.return_value.delete.side_effect = Exception("Mocked delete exception") with patch( - "registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", # noqa + "registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", return_value=True, ): - call_command("clean_tables") + with self.assertRaises(Exception) as context: + # Execute the command + call_command("clean_tables") - self.logger_mock.error.assert_any_call("Error cleaning table DomainInformation: Some error") + # Check the exception message + self.assertEqual(str(context.exception), "Custom delete error") + + # Assert that delete was called + model_mock.objects.filter.return_value.delete.assert_called() class TestExportTables(MockEppLib): @@ -882,6 +944,7 @@ class TestExportTables(MockEppLib): def tearDown(self): self.logger_patcher.stop() + @less_console_noise_decorator @patch("os.makedirs") @patch("os.path.exists") @patch("os.remove") @@ -1113,6 +1176,7 @@ class TestImportTables(TestCase): class TestTransferFederalAgencyType(TestCase): """Tests for the transfer_federal_agency_type script""" + @less_console_noise_decorator def setUp(self): """Creates a fake domain object""" super().setUp() @@ -1172,7 +1236,9 @@ class TestTransferFederalAgencyType(TestCase): User.objects.all().delete() Contact.objects.all().delete() Website.objects.all().delete() - FederalAgency.objects.all().delete() + FederalAgency.objects.filter( + id__in=[self.amtrak.id, self.legislative_branch.id, self.library_of_congress.id, self.gov_admin.id] + ).delete() def run_transfer_federal_agency_type(self): """ @@ -1220,3 +1286,125 @@ class TestTransferFederalAgencyType(TestCase): # We don't expect this field to be updated (as it has duplicate data) self.assertEqual(self.gov_admin.federal_type, None) + + +class TestLoadSeniorOfficialTable(TestCase): + def setUp(self): + super().setUp() + self.csv_path = "registrar/tests/data/fake_federal_cio.csv" + + def tearDown(self): + super().tearDown() + SeniorOfficial.objects.all().delete() + FederalAgency.objects.all().delete() + + @less_console_noise_decorator + def run_load_senior_official_table(self): + with patch( + "registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", + return_value=True, + ): + call_command("load_senior_official_table", self.csv_path) + + @less_console_noise_decorator + def test_load_senior_official_table(self): + """Ensures that running the senior official script creates the data we expect""" + # Get test FederalAgency objects + abmc, _ = FederalAgency.objects.get_or_create(agency="American Battle Monuments Commission") + achp, _ = FederalAgency.objects.get_or_create(agency="Advisory Council on Historic Preservation") + + # run the script + self.run_load_senior_official_table() + + # Check the data returned by the script + jan_uary = SeniorOfficial.objects.get(first_name="Jan", last_name="Uary") + self.assertEqual(jan_uary.title, "CIO") + self.assertEqual(jan_uary.email, "fakemrfake@igorville.gov") + self.assertEqual(jan_uary.federal_agency, abmc) + + reggie_ronald = SeniorOfficial.objects.get(first_name="Reggie", last_name="Ronald") + self.assertEqual(reggie_ronald.title, "CIO") + self.assertEqual(reggie_ronald.email, "reggie.ronald@igorville.gov") + self.assertEqual(reggie_ronald.federal_agency, achp) + + # Two should be created in total + self.assertEqual(SeniorOfficial.objects.count(), 2) + + @less_console_noise_decorator + def test_load_senior_official_table_duplicate_entry(self): + """Ensures that duplicate data won't be created""" + # Create a SeniorOfficial that matches one in the CSV + abmc, _ = FederalAgency.objects.get_or_create(agency="American Battle Monuments Commission") + SeniorOfficial.objects.create( + first_name="Jan", last_name="Uary", title="CIO", email="fakemrfake@igorville.gov", federal_agency=abmc + ) + + self.assertEqual(SeniorOfficial.objects.count(), 1) + + # run the script + self.run_load_senior_official_table() + + # Check if only one new SeniorOfficial object was created + self.assertEqual(SeniorOfficial.objects.count(), 2) + + +class TestPopulateFederalAgencyInitialsAndFceb(TestCase): + def setUp(self): + self.csv_path = "registrar/tests/data/fake_federal_cio.csv" + + # Create test FederalAgency objects + self.agency1, _ = FederalAgency.objects.get_or_create(agency="American Battle Monuments Commission") + self.agency2, _ = FederalAgency.objects.get_or_create(agency="Advisory Council on Historic Preservation") + self.agency3, _ = FederalAgency.objects.get_or_create(agency="AMTRAK") + self.agency4, _ = FederalAgency.objects.get_or_create(agency="John F. Kennedy Center for Performing Arts") + + def tearDown(self): + SeniorOfficial.objects.all().delete() + FederalAgency.objects.all().delete() + + @less_console_noise_decorator + def run_populate_federal_agency_initials_and_fceb(self): + with patch( + "registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", + return_value=True, + ): + call_command("populate_federal_agency_initials_and_fceb", self.csv_path) + + @less_console_noise_decorator + def test_populate_federal_agency_initials_and_fceb(self): + """Ensures that the script generates the data we want""" + self.run_populate_federal_agency_initials_and_fceb() + + # Refresh the objects from the database + self.agency1.refresh_from_db() + self.agency2.refresh_from_db() + self.agency3.refresh_from_db() + self.agency4.refresh_from_db() + + # Check if FederalAgency objects were updated correctly + self.assertEqual(self.agency1.initials, "ABMC") + self.assertTrue(self.agency1.is_fceb) + + self.assertEqual(self.agency2.initials, "ACHP") + self.assertTrue(self.agency2.is_fceb) + + # We expect that this field doesn't have any data, + # as none is specified in the CSV + self.assertIsNone(self.agency3.initials) + self.assertIsNone(self.agency3.is_fceb) + + self.assertEqual(self.agency4.initials, "KC") + self.assertFalse(self.agency4.is_fceb) + + @less_console_noise_decorator + def test_populate_federal_agency_initials_and_fceb_missing_agency(self): + """A test to ensure that the script doesn't modify unrelated fields""" + # Add a FederalAgency that's not in the CSV + missing_agency = FederalAgency.objects.create(agency="Missing Agency") + + self.run_populate_federal_agency_initials_and_fceb() + + # Verify that the missing agency was not updated + missing_agency.refresh_from_db() + self.assertIsNone(missing_agency.initials) + self.assertIsNone(missing_agency.is_fceb) diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 8daf15933..b50525e27 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -1,7 +1,7 @@ from django.test import TestCase from django.db.utils import IntegrityError +from django.db import transaction from unittest.mock import patch -from django.contrib.auth import get_user_model from django.test import RequestFactory @@ -20,20 +20,28 @@ from registrar.models import ( import boto3_mocking from registrar.models.portfolio import Portfolio +from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.transition_domain import TransitionDomain +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore from registrar.utility.constants import BranchChoices -from .common import MockSESClient, less_console_noise, completed_domain_request, set_domain_request_investigators +from .common import ( + MockSESClient, + less_console_noise, + completed_domain_request, + set_domain_request_investigators, + create_test_user, +) from django_fsm import TransitionNotAllowed from waffle.testutils import override_flag +from api.tests.common import less_console_noise_decorator + -# Test comment for push -- will remove -# The DomainRequest submit method has a side effect of sending an email -# with AWS SES, so mock that out in all of these test cases @boto3_mocking.patching class TestDomainRequest(TestCase): + @less_console_noise_decorator def setUp(self): self.dummy_user, _ = Contact.objects.get_or_create( @@ -91,6 +99,11 @@ class TestDomainRequest(TestCase): def tearDown(self): super().tearDown() + DomainInformation.objects.all().delete() + DomainRequest.objects.all().delete() + DraftDomain.objects.all().delete() + Domain.objects.all().delete() + User.objects.all().delete() self.mock_client.EMAILS_SENT.clear() def assertNotRaises(self, exception_type): @@ -98,6 +111,7 @@ class TestDomainRequest(TestCase): with less_console_noise(): return self.assertRaises(Exception, None, exception_type) + @less_console_noise_decorator def test_federal_agency_set_to_non_federal_on_approve(self): """Ensures that when the federal_agency field is 'none' when .approve() is called, the field is set to the 'Non-Federal Agency' record""" @@ -119,103 +133,103 @@ class TestDomainRequest(TestCase): self.assertEqual(domain_request.federal_agency, expected_federal_agency) def test_empty_create_fails(self): - """Can't create a completely empty domain request. - NOTE: something about theexception this test raises messes up with the - atomic block in a custom tearDown method for the parent test class.""" + """Can't create a completely empty domain request.""" with less_console_noise(): - with self.assertRaisesRegex(IntegrityError, "creator"): - DomainRequest.objects.create() + with transaction.atomic(): + with self.assertRaisesRegex(IntegrityError, "creator"): + DomainRequest.objects.create() + @less_console_noise_decorator def test_minimal_create(self): """Can create with just a creator.""" - with less_console_noise(): - user, _ = User.objects.get_or_create(username="testy") - domain_request = DomainRequest.objects.create(creator=user) - self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.STARTED) + user, _ = User.objects.get_or_create(username="testy") + domain_request = DomainRequest.objects.create(creator=user) + self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.STARTED) + @less_console_noise_decorator def test_full_create(self): """Can create with all fields.""" - with less_console_noise(): - user, _ = User.objects.get_or_create(username="testy") - contact = Contact.objects.create() - com_website, _ = Website.objects.get_or_create(website="igorville.com") - gov_website, _ = Website.objects.get_or_create(website="igorville.gov") - domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov") - domain_request = DomainRequest.objects.create( - creator=user, - investigator=user, - generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, - federal_type=BranchChoices.EXECUTIVE, - is_election_board=False, - organization_name="Test", - address_line1="100 Main St.", - address_line2="APT 1A", - state_territory="CA", - zipcode="12345-6789", - senior_official=contact, - requested_domain=domain, - submitter=contact, - purpose="Igorville rules!", - anything_else="All of Igorville loves the dotgov program.", - is_policy_acknowledged=True, - ) - domain_request.current_websites.add(com_website) - domain_request.alternative_domains.add(gov_website) - domain_request.other_contacts.add(contact) - domain_request.save() + user, _ = User.objects.get_or_create(username="testy") + contact = Contact.objects.create() + com_website, _ = Website.objects.get_or_create(website="igorville.com") + gov_website, _ = Website.objects.get_or_create(website="igorville.gov") + domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov") + domain_request = DomainRequest.objects.create( + creator=user, + investigator=user, + generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + federal_type=BranchChoices.EXECUTIVE, + is_election_board=False, + organization_name="Test", + address_line1="100 Main St.", + address_line2="APT 1A", + state_territory="CA", + zipcode="12345-6789", + senior_official=contact, + requested_domain=domain, + submitter=contact, + purpose="Igorville rules!", + anything_else="All of Igorville loves the dotgov program.", + is_policy_acknowledged=True, + ) + domain_request.current_websites.add(com_website) + domain_request.alternative_domains.add(gov_website) + domain_request.other_contacts.add(contact) + domain_request.save() + @less_console_noise_decorator def test_domain_info(self): """Can create domain info with all fields.""" - with less_console_noise(): - user, _ = User.objects.get_or_create(username="testy") - contact = Contact.objects.create() - domain, _ = Domain.objects.get_or_create(name="igorville.gov") - information = DomainInformation.objects.create( - creator=user, - generic_org_type=DomainInformation.OrganizationChoices.FEDERAL, - federal_type=BranchChoices.EXECUTIVE, - is_election_board=False, - organization_name="Test", - address_line1="100 Main St.", - address_line2="APT 1A", - state_territory="CA", - zipcode="12345-6789", - senior_official=contact, - submitter=contact, - purpose="Igorville rules!", - anything_else="All of Igorville loves the dotgov program.", - is_policy_acknowledged=True, - domain=domain, - ) - information.other_contacts.add(contact) - information.save() - self.assertEqual(information.domain.id, domain.id) - self.assertEqual(information.id, domain.domain_info.id) + user, _ = User.objects.get_or_create(username="testy") + contact = Contact.objects.create() + domain, _ = Domain.objects.get_or_create(name="igorville.gov") + information = DomainInformation.objects.create( + creator=user, + generic_org_type=DomainInformation.OrganizationChoices.FEDERAL, + federal_type=BranchChoices.EXECUTIVE, + is_election_board=False, + organization_name="Test", + address_line1="100 Main St.", + address_line2="APT 1A", + state_territory="CA", + zipcode="12345-6789", + senior_official=contact, + submitter=contact, + purpose="Igorville rules!", + anything_else="All of Igorville loves the dotgov program.", + is_policy_acknowledged=True, + domain=domain, + ) + information.other_contacts.add(contact) + information.save() + self.assertEqual(information.domain.id, domain.id) + self.assertEqual(information.id, domain.domain_info.id) + @less_console_noise_decorator def test_status_fsm_submit_fail(self): - with less_console_noise(): - user, _ = User.objects.get_or_create(username="testy") - domain_request = DomainRequest.objects.create(creator=user) + user, _ = User.objects.get_or_create(username="testy") + domain_request = DomainRequest.objects.create(creator=user) - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - with self.assertRaises(ValueError): - # can't submit a domain request with a null domain name - domain_request.submit() - - def test_status_fsm_submit_succeed(self): - with less_console_noise(): - user, _ = User.objects.get_or_create(username="testy") - site = DraftDomain.objects.create(name="igorville.gov") - domain_request = DomainRequest.objects.create(creator=user, requested_domain=site) - - # no submitter email so this emits a log warning - - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + with less_console_noise(): + with self.assertRaises(ValueError): + # can't submit a domain request with a null domain name domain_request.submit() - self.assertEqual(domain_request.status, domain_request.DomainRequestStatus.SUBMITTED) + @less_console_noise_decorator + def test_status_fsm_submit_succeed(self): + user, _ = User.objects.get_or_create(username="testy") + site = DraftDomain.objects.create(name="igorville.gov") + domain_request = DomainRequest.objects.create(creator=user, requested_domain=site) + + # no submitter email so this emits a log warning + + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + with less_console_noise(): + domain_request.submit() + self.assertEqual(domain_request.status, domain_request.DomainRequestStatus.SUBMITTED) + + @less_console_noise_decorator def check_email_sent( self, domain_request, msg, action, expected_count, expected_content=None, expected_email="mayor@igorville.com" ): @@ -223,10 +237,9 @@ class TestDomainRequest(TestCase): with self.subTest(msg=msg, action=action): with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - # Perform the specified action - action_method = getattr(domain_request, action) - action_method() + # Perform the specified action + action_method = getattr(domain_request, action) + action_method() # Check if an email was sent sent_emails = [ @@ -241,12 +254,14 @@ class TestDomainRequest(TestCase): self.assertIn(expected_content, email_content) @override_flag("profile_feature", active=False) + @less_console_noise_decorator def test_submit_from_started_sends_email(self): msg = "Create a domain request and submit it and see if email was sent." domain_request = completed_domain_request(submitter=self.dummy_user, user=self.dummy_user_2) self.check_email_sent(domain_request, msg, "submit", 1, expected_content="Hello") @override_flag("profile_feature", active=True) + @less_console_noise_decorator def test_submit_from_started_sends_email_to_creator(self): """Tests if, when the profile feature flag is on, we send an email to the creator""" msg = "Create a domain request and submit it and see if email was sent when the feature flag is on." @@ -255,6 +270,7 @@ class TestDomainRequest(TestCase): domain_request, msg, "submit", 1, expected_content="Lava", expected_email="intern@igorville.com" ) + @less_console_noise_decorator def test_submit_from_withdrawn_sends_email(self): msg = "Create a withdrawn domain request and submit it and see if email was sent." domain_request = completed_domain_request( @@ -262,16 +278,19 @@ class TestDomainRequest(TestCase): ) self.check_email_sent(domain_request, msg, "submit", 1, expected_content="Hello") + @less_console_noise_decorator def test_submit_from_action_needed_does_not_send_email(self): msg = "Create a domain request with ACTION_NEEDED status and submit it, check if email was not sent." domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.ACTION_NEEDED) self.check_email_sent(domain_request, msg, "submit", 0) + @less_console_noise_decorator def test_submit_from_in_review_does_not_send_email(self): msg = "Create a withdrawn domain request and submit it and see if email was sent." domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) self.check_email_sent(domain_request, msg, "submit", 0) + @less_console_noise_decorator def test_approve_sends_email(self): msg = "Create a domain request and approve it and see if email was sent." domain_request = completed_domain_request( @@ -279,6 +298,7 @@ class TestDomainRequest(TestCase): ) self.check_email_sent(domain_request, msg, "approve", 1, expected_content="Hello") + @less_console_noise_decorator def test_withdraw_sends_email(self): msg = "Create a domain request and withdraw it and see if email was sent." domain_request = completed_domain_request( @@ -286,6 +306,7 @@ class TestDomainRequest(TestCase): ) self.check_email_sent(domain_request, msg, "withdraw", 1, expected_content="Hello") + @less_console_noise_decorator def test_reject_sends_email(self): msg = "Create a domain request and reject it and see if email was sent." domain_request = completed_domain_request( @@ -293,11 +314,13 @@ class TestDomainRequest(TestCase): ) self.check_email_sent(domain_request, msg, "reject", 1, expected_content="Hello") + @less_console_noise_decorator def test_reject_with_prejudice_does_not_send_email(self): msg = "Create a domain request and reject it with prejudice and see if email was sent." domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED) self.check_email_sent(domain_request, msg, "reject_with_prejudice", 0) + @less_console_noise_decorator def assert_fsm_transition_raises_error(self, test_cases, method_to_run): """Given a list of test cases, check if each transition throws the intended error""" with boto3_mocking.clients.handler_for("sesv2", self.mock_client), less_console_noise(): @@ -309,6 +332,7 @@ class TestDomainRequest(TestCase): # Call the method method() + @less_console_noise_decorator def assert_fsm_transition_does_not_raise_error(self, test_cases, method_to_run): """Given a list of test cases, ensure that none of them throw transition errors""" with boto3_mocking.clients.handler_for("sesv2", self.mock_client), less_console_noise(): @@ -322,6 +346,7 @@ class TestDomainRequest(TestCase): except exception_type: self.fail(f"{exception_type} was raised, but it was not expected.") + @less_console_noise_decorator def test_submit_transition_allowed_with_no_investigator(self): """ Tests for attempting to transition without an investigator. @@ -340,6 +365,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_does_not_raise_error(test_cases, "submit") + @less_console_noise_decorator def test_submit_transition_allowed_with_investigator_not_staff(self): """ Tests for attempting to transition with an investigator user that is not staff. @@ -357,6 +383,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_does_not_raise_error(test_cases, "submit") + @less_console_noise_decorator def test_submit_transition_allowed(self): """ Test that calling submit from allowable statuses does raises TransitionNotAllowed. @@ -370,26 +397,27 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_does_not_raise_error(test_cases, "submit") + @less_console_noise_decorator def test_submit_transition_allowed_twice(self): """ Test that rotating between submit and in_review doesn't throw an error """ with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - try: - # Make a submission - self.in_review_domain_request.submit() + try: + # Make a submission + self.in_review_domain_request.submit() - # Rerun the old method to get back to the original state - self.in_review_domain_request.in_review() + # Rerun the old method to get back to the original state + self.in_review_domain_request.in_review() - # Make another submission - self.in_review_domain_request.submit() - except TransitionNotAllowed: - self.fail("TransitionNotAllowed was raised, but it was not expected.") + # Make another submission + self.in_review_domain_request.submit() + except TransitionNotAllowed: + self.fail("TransitionNotAllowed was raised, but it was not expected.") self.assertEqual(self.in_review_domain_request.status, DomainRequest.DomainRequestStatus.SUBMITTED) + @less_console_noise_decorator def test_submit_transition_not_allowed(self): """ Test that calling submit against transition rules raises TransitionNotAllowed. @@ -403,6 +431,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_raises_error(test_cases, "submit") + @less_console_noise_decorator def test_in_review_transition_allowed(self): """ Test that calling in_review from allowable statuses does raises TransitionNotAllowed. @@ -417,6 +446,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_does_not_raise_error(test_cases, "in_review") + @less_console_noise_decorator def test_in_review_transition_not_allowed_with_no_investigator(self): """ Tests for attempting to transition without an investigator @@ -434,6 +464,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_raises_error(test_cases, "in_review") + @less_console_noise_decorator def test_in_review_transition_not_allowed_with_investigator_not_staff(self): """ Tests for attempting to transition with an investigator that is not staff. @@ -453,6 +484,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_raises_error(test_cases, "in_review") + @less_console_noise_decorator def test_in_review_transition_not_allowed(self): """ Test that calling in_review against transition rules raises TransitionNotAllowed. @@ -465,6 +497,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_raises_error(test_cases, "in_review") + @less_console_noise_decorator def test_action_needed_transition_allowed(self): """ Test that calling action_needed from allowable statuses does raises TransitionNotAllowed. @@ -478,6 +511,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_does_not_raise_error(test_cases, "action_needed") + @less_console_noise_decorator def test_action_needed_transition_not_allowed_with_no_investigator(self): """ Tests for attempting to transition without an investigator @@ -495,6 +529,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_raises_error(test_cases, "action_needed") + @less_console_noise_decorator def test_action_needed_transition_not_allowed_with_investigator_not_staff(self): """ Tests for attempting to transition with an investigator that is not staff @@ -513,6 +548,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_raises_error(test_cases, "action_needed") + @less_console_noise_decorator def test_action_needed_transition_not_allowed(self): """ Test that calling action_needed against transition rules raises TransitionNotAllowed. @@ -526,6 +562,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_raises_error(test_cases, "action_needed") + @less_console_noise_decorator def test_approved_transition_allowed(self): """ Test that calling action_needed from allowable statuses does raises TransitionNotAllowed. @@ -539,6 +576,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_does_not_raise_error(test_cases, "approve") + @less_console_noise_decorator def test_approved_transition_not_allowed_with_no_investigator(self): """ Tests for attempting to transition without an investigator @@ -555,6 +593,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_raises_error(test_cases, "approve") + @less_console_noise_decorator def test_approved_transition_not_allowed_with_investigator_not_staff(self): """ Tests for attempting to transition with an investigator that is not staff @@ -572,6 +611,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_raises_error(test_cases, "approve") + @less_console_noise_decorator def test_approved_skips_sending_email(self): """ Test that calling .approve with send_email=False doesn't actually send @@ -579,12 +619,12 @@ class TestDomainRequest(TestCase): """ with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - self.submitted_domain_request.approve(send_email=False) + self.submitted_domain_request.approve(send_email=False) # Assert that no emails were sent self.assertEqual(len(self.mock_client.EMAILS_SENT), 0) + @less_console_noise_decorator def test_approved_transition_not_allowed(self): """ Test that calling action_needed against transition rules raises TransitionNotAllowed. @@ -597,6 +637,7 @@ class TestDomainRequest(TestCase): ] self.assert_fsm_transition_raises_error(test_cases, "approve") + @less_console_noise_decorator def test_withdraw_transition_allowed(self): """ Test that calling action_needed from allowable statuses does raises TransitionNotAllowed. @@ -609,6 +650,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_does_not_raise_error(test_cases, "withdraw") + @less_console_noise_decorator def test_withdraw_transition_allowed_with_no_investigator(self): """ Tests for attempting to transition without an investigator. @@ -626,6 +668,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_does_not_raise_error(test_cases, "withdraw") + @less_console_noise_decorator def test_withdraw_transition_allowed_with_investigator_not_staff(self): """ Tests for attempting to transition when investigator is not staff. @@ -644,6 +687,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_does_not_raise_error(test_cases, "withdraw") + @less_console_noise_decorator def test_withdraw_transition_not_allowed(self): """ Test that calling action_needed against transition rules raises TransitionNotAllowed. @@ -658,6 +702,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_raises_error(test_cases, "withdraw") + @less_console_noise_decorator def test_reject_transition_allowed(self): """ Test that calling action_needed from allowable statuses does raises TransitionNotAllowed. @@ -670,6 +715,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_does_not_raise_error(test_cases, "reject") + @less_console_noise_decorator def test_reject_transition_not_allowed_with_no_investigator(self): """ Tests for attempting to transition without an investigator @@ -686,6 +732,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_raises_error(test_cases, "reject") + @less_console_noise_decorator def test_reject_transition_not_allowed_with_investigator_not_staff(self): """ Tests for attempting to transition when investigator is not staff @@ -703,6 +750,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_raises_error(test_cases, "reject") + @less_console_noise_decorator def test_reject_transition_not_allowed(self): """ Test that calling action_needed against transition rules raises TransitionNotAllowed. @@ -717,6 +765,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_raises_error(test_cases, "reject") + @less_console_noise_decorator def test_reject_with_prejudice_transition_allowed(self): """ Test that calling action_needed from allowable statuses does raises TransitionNotAllowed. @@ -730,6 +779,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_does_not_raise_error(test_cases, "reject_with_prejudice") + @less_console_noise_decorator def test_reject_with_prejudice_transition_not_allowed_with_no_investigator(self): """ Tests for attempting to transition without an investigator @@ -747,6 +797,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_raises_error(test_cases, "reject_with_prejudice") + @less_console_noise_decorator def test_reject_with_prejudice_not_allowed_with_investigator_not_staff(self): """ Tests for attempting to transition when investigator is not staff @@ -765,6 +816,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_raises_error(test_cases, "reject_with_prejudice") + @less_console_noise_decorator def test_reject_with_prejudice_transition_not_allowed(self): """ Test that calling action_needed against transition rules raises TransitionNotAllowed. @@ -778,6 +830,7 @@ class TestDomainRequest(TestCase): self.assert_fsm_transition_raises_error(test_cases, "reject_with_prejudice") + @less_console_noise_decorator def test_transition_not_allowed_approved_in_review_when_domain_is_active(self): """Create a domain request with status approved, create a matching domain that is active, and call in_review against transition rules""" @@ -791,13 +844,13 @@ class TestDomainRequest(TestCase): return True # Override to return True with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - # Use patch to temporarily replace is_active with the custom implementation - with patch.object(Domain, "is_active", custom_is_active): - # Now, when you call is_active on Domain, it will return True - with self.assertRaises(TransitionNotAllowed): - self.approved_domain_request.in_review() + # Use patch to temporarily replace is_active with the custom implementation + with patch.object(Domain, "is_active", custom_is_active): + # Now, when you call is_active on Domain, it will return True + with self.assertRaises(TransitionNotAllowed): + self.approved_domain_request.in_review() + @less_console_noise_decorator def test_transition_not_allowed_approved_action_needed_when_domain_is_active(self): """Create a domain request with status approved, create a matching domain that is active, and call action_needed against transition rules""" @@ -811,13 +864,13 @@ class TestDomainRequest(TestCase): return True # Override to return True with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - # Use patch to temporarily replace is_active with the custom implementation - with patch.object(Domain, "is_active", custom_is_active): - # Now, when you call is_active on Domain, it will return True - with self.assertRaises(TransitionNotAllowed): - self.approved_domain_request.action_needed() + # Use patch to temporarily replace is_active with the custom implementation + with patch.object(Domain, "is_active", custom_is_active): + # Now, when you call is_active on Domain, it will return True + with self.assertRaises(TransitionNotAllowed): + self.approved_domain_request.action_needed() + @less_console_noise_decorator def test_transition_not_allowed_approved_rejected_when_domain_is_active(self): """Create a domain request with status approved, create a matching domain that is active, and call reject against transition rules""" @@ -831,13 +884,13 @@ class TestDomainRequest(TestCase): return True # Override to return True with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - # Use patch to temporarily replace is_active with the custom implementation - with patch.object(Domain, "is_active", custom_is_active): - # Now, when you call is_active on Domain, it will return True - with self.assertRaises(TransitionNotAllowed): - self.approved_domain_request.reject() + # Use patch to temporarily replace is_active with the custom implementation + with patch.object(Domain, "is_active", custom_is_active): + # Now, when you call is_active on Domain, it will return True + with self.assertRaises(TransitionNotAllowed): + self.approved_domain_request.reject() + @less_console_noise_decorator def test_transition_not_allowed_approved_ineligible_when_domain_is_active(self): """Create a domain request with status approved, create a matching domain that is active, and call reject_with_prejudice against transition rules""" @@ -851,88 +904,87 @@ class TestDomainRequest(TestCase): return True # Override to return True with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - # Use patch to temporarily replace is_active with the custom implementation - with patch.object(Domain, "is_active", custom_is_active): - # Now, when you call is_active on Domain, it will return True - with self.assertRaises(TransitionNotAllowed): - self.approved_domain_request.reject_with_prejudice() + # Use patch to temporarily replace is_active with the custom implementation + with patch.object(Domain, "is_active", custom_is_active): + # Now, when you call is_active on Domain, it will return True + with self.assertRaises(TransitionNotAllowed): + self.approved_domain_request.reject_with_prejudice() + @less_console_noise_decorator def test_approve_from_rejected_clears_rejection_reason(self): """When transitioning from rejected to approved on a domain request, the rejection_reason is cleared.""" - with less_console_noise(): - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.REJECTED) - domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.REJECTED) + domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE - # Approve - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - domain_request.approve() + # Approve + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + domain_request.approve() - self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.APPROVED) - self.assertEqual(domain_request.rejection_reason, None) + self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.APPROVED) + self.assertEqual(domain_request.rejection_reason, None) + @less_console_noise_decorator def test_in_review_from_rejected_clears_rejection_reason(self): """When transitioning from rejected to in_review on a domain request, the rejection_reason is cleared.""" - with less_console_noise(): - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.REJECTED) - domain_request.domain_is_not_active = True - domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.REJECTED) + domain_request.domain_is_not_active = True + domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE - # Approve - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - domain_request.in_review() + # Approve + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + domain_request.in_review() - self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.IN_REVIEW) - self.assertEqual(domain_request.rejection_reason, None) + self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.IN_REVIEW) + self.assertEqual(domain_request.rejection_reason, None) + @less_console_noise_decorator def test_action_needed_from_rejected_clears_rejection_reason(self): """When transitioning from rejected to action_needed on a domain request, the rejection_reason is cleared.""" - with less_console_noise(): - # Create a sample domain request - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.REJECTED) - domain_request.domain_is_not_active = True - domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE + # Create a sample domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.REJECTED) + domain_request.domain_is_not_active = True + domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE - # Approve - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - domain_request.action_needed() + # Approve + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + domain_request.action_needed() - self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.ACTION_NEEDED) - self.assertEqual(domain_request.rejection_reason, None) + self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.ACTION_NEEDED) + self.assertEqual(domain_request.rejection_reason, None) + @less_console_noise_decorator def test_has_rationale_returns_true(self): """has_rationale() returns true when a domain request has no_other_contacts_rationale""" - with less_console_noise(): - self.started_domain_request.no_other_contacts_rationale = "You talkin' to me?" - self.started_domain_request.save() - self.assertEquals(self.started_domain_request.has_rationale(), True) + self.started_domain_request.no_other_contacts_rationale = "You talkin' to me?" + self.started_domain_request.save() + self.assertEquals(self.started_domain_request.has_rationale(), True) + @less_console_noise_decorator def test_has_rationale_returns_false(self): """has_rationale() returns false when a domain request has no no_other_contacts_rationale""" - with less_console_noise(): - self.assertEquals(self.started_domain_request.has_rationale(), False) + self.assertEquals(self.started_domain_request.has_rationale(), False) + @less_console_noise_decorator def test_has_other_contacts_returns_true(self): """has_other_contacts() returns true when a domain request has other_contacts""" - with less_console_noise(): - # completed_domain_request has other contacts by default - self.assertEquals(self.started_domain_request.has_other_contacts(), True) + # completed_domain_request has other contacts by default + self.assertEquals(self.started_domain_request.has_other_contacts(), True) + @less_console_noise_decorator def test_has_other_contacts_returns_false(self): """has_other_contacts() returns false when a domain request has no other_contacts""" - with less_console_noise(): - domain_request = completed_domain_request( - status=DomainRequest.DomainRequestStatus.STARTED, name="no-others.gov", has_other_contacts=False - ) - self.assertEquals(domain_request.has_other_contacts(), False) + domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.STARTED, name="no-others.gov", has_other_contacts=False + ) + self.assertEquals(domain_request.has_other_contacts(), False) class TestPermissions(TestCase): @@ -947,6 +999,7 @@ class TestPermissions(TestCase): self.mock_client.EMAILS_SENT.clear() @boto3_mocking.patching + @less_console_noise_decorator def test_approval_creates_role(self): draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov") user, _ = User.objects.get_or_create() @@ -956,10 +1009,9 @@ class TestPermissions(TestCase): ) with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - # skip using the submit method - domain_request.status = DomainRequest.DomainRequestStatus.SUBMITTED - domain_request.approve() + # skip using the submit method + domain_request.status = DomainRequest.DomainRequestStatus.SUBMITTED + domain_request.approve() # should be a role for this user domain = Domain.objects.get(name="igorville.gov") @@ -983,6 +1035,7 @@ class TestDomainInformation(TestCase): DraftDomain.objects.all().delete() @boto3_mocking.patching + @less_console_noise_decorator def test_approval_creates_info(self): draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov") user, _ = User.objects.get_or_create() @@ -992,28 +1045,27 @@ class TestDomainInformation(TestCase): ) with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - with less_console_noise(): - # skip using the submit method - domain_request.status = DomainRequest.DomainRequestStatus.SUBMITTED - domain_request.approve() + # skip using the submit method + domain_request.status = DomainRequest.DomainRequestStatus.SUBMITTED + domain_request.approve() - # should be an information present for this domain - domain = Domain.objects.get(name="igorville.gov") - domain_information = DomainInformation.objects.filter(domain=domain) - self.assertTrue(domain_information.exists()) + # should be an information present for this domain + domain = Domain.objects.get(name="igorville.gov") + domain_information = DomainInformation.objects.filter(domain=domain) + self.assertTrue(domain_information.exists()) - # Test that both objects are what we expect - current_domain_information = domain_information.get().__dict__ - expected_domain_information = DomainInformation( - creator=user, - domain=domain, - notes="test notes", - domain_request=domain_request, - federal_agency=FederalAgency.objects.get(agency="Non-Federal Agency"), - ).__dict__ + # Test that both objects are what we expect + current_domain_information = domain_information.get().__dict__ + expected_domain_information = DomainInformation( + creator=user, + domain=domain, + notes="test notes", + domain_request=domain_request, + federal_agency=FederalAgency.objects.get(agency="Non-Federal Agency"), + ).__dict__ - # Test the two records for consistency - self.assertEqual(self.clean_dict(current_domain_information), self.clean_dict(expected_domain_information)) + # Test the two records for consistency + self.assertEqual(self.clean_dict(current_domain_information), self.clean_dict(expected_domain_information)) def clean_dict(self, dict_obj): """Cleans dynamic fields in a dictionary""" @@ -1021,46 +1073,115 @@ class TestDomainInformation(TestCase): return {k: v for k, v in dict_obj.items() if k not in bad_fields} -class TestInvitations(TestCase): - """Test the retrieval of invitations.""" +class TestDomainInvitations(TestCase): + """Test the retrieval of domain invitations.""" + @less_console_noise_decorator def setUp(self): self.domain, _ = Domain.objects.get_or_create(name="igorville.gov") self.email = "mayor@igorville.gov" self.invitation, _ = DomainInvitation.objects.get_or_create(email=self.email, domain=self.domain) self.user, _ = User.objects.get_or_create(email=self.email) + def tearDown(self): + super().tearDown() # clean out the roles each time UserDomainRole.objects.all().delete() + self.domain.delete() + self.invitation.delete() + User.objects.all().delete() + @less_console_noise_decorator def test_retrieval_creates_role(self): self.invitation.retrieve() self.assertTrue(UserDomainRole.objects.get(user=self.user, domain=self.domain)) + @less_console_noise_decorator def test_retrieve_missing_user_error(self): # get rid of matching users User.objects.filter(email=self.email).delete() with self.assertRaises(RuntimeError): self.invitation.retrieve() + @less_console_noise_decorator def test_retrieve_existing_role_no_error(self): # make the overlapping role UserDomainRole.objects.get_or_create(user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER) # this is not an error but does produce a console warning - with less_console_noise(): - self.invitation.retrieve() + self.invitation.retrieve() self.assertEqual(self.invitation.status, DomainInvitation.DomainInvitationStatus.RETRIEVED) + @less_console_noise_decorator def test_retrieve_on_each_login(self): """A user's authenticate on_each_login callback retrieves their invitations.""" self.user.on_each_login() self.assertTrue(UserDomainRole.objects.get(user=self.user, domain=self.domain)) +class TestPortfolioInvitations(TestCase): + """Test the retrieval of portfolio invitations.""" + + @less_console_noise_decorator + def setUp(self): + self.email = "mayor@igorville.gov" + self.email2 = "creator@igorville.gov" + self.user, _ = User.objects.get_or_create(email=self.email) + self.user2, _ = User.objects.get_or_create(email=self.email2, username="creator") + self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Hotel California") + self.portfolio_role_base = UserPortfolioRoleChoices.ORGANIZATION_MEMBER + self.portfolio_role_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN + self.portfolio_permission_1 = UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS + self.portfolio_permission_2 = UserPortfolioPermissionChoices.EDIT_REQUESTS + self.invitation, _ = PortfolioInvitation.objects.get_or_create( + email=self.email, + portfolio=self.portfolio, + portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin], + portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2], + ) + + def tearDown(self): + super().tearDown() + Portfolio.objects.all().delete() + PortfolioInvitation.objects.all().delete() + User.objects.all().delete() + + @less_console_noise_decorator + def test_retrieval(self): + self.assertFalse(self.user.portfolio) + self.invitation.retrieve() + self.user.refresh_from_db() + self.assertEqual(self.user.portfolio.organization_name, "Hotel California") + self.assertEqual(self.user.portfolio_roles, [self.portfolio_role_base, self.portfolio_role_admin]) + self.assertEqual( + self.user.portfolio_additional_permissions, [self.portfolio_permission_1, self.portfolio_permission_2] + ) + self.assertEqual(self.invitation.status, PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) + + @less_console_noise_decorator + def test_retrieve_missing_user_error(self): + # get rid of matching users + User.objects.filter(email=self.email).delete() + with self.assertRaises(RuntimeError): + self.invitation.retrieve() + + @less_console_noise_decorator + def test_retrieve_user_already_member_error(self): + self.assertFalse(self.user.portfolio) + portfolio2, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Tokyo Hotel") + self.user.portfolio = portfolio2 + self.assertEqual(self.user.portfolio.organization_name, "Tokyo Hotel") + self.user.save() + self.user.check_portfolio_invitations_on_login() + self.user.refresh_from_db() + self.assertEqual(self.user.portfolio.organization_name, "Tokyo Hotel") + self.assertEqual(self.invitation.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED) + + class TestUser(TestCase): """Test actions that occur on user login, test class method that controls how users get validated.""" + @less_console_noise_decorator def setUp(self): self.email = "mayor@igorville.gov" self.domain_name = "igorvilleInTransition.gov" @@ -1075,9 +1196,11 @@ class TestUser(TestCase): DomainRequest.objects.all().delete() DraftDomain.objects.all().delete() TransitionDomain.objects.all().delete() + Portfolio.objects.all().delete() User.objects.all().delete() UserDomainRole.objects.all().delete() + @less_console_noise_decorator def test_check_transition_domains_without_domains_on_login(self): """A user's on_each_login callback does not check transition domains. This test makes sure that in the event a domain does not exist @@ -1086,35 +1209,41 @@ class TestUser(TestCase): self.user.on_each_login() self.assertFalse(Domain.objects.filter(name=self.domain_name).exists()) + @less_console_noise_decorator def test_identity_verification_with_domain_manager(self): """A domain manager should return False when tested with class method needs_identity_verification""" UserDomainRole.objects.get_or_create(user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER) self.assertFalse(User.needs_identity_verification(self.user.email, self.user.username)) + @less_console_noise_decorator def test_identity_verification_with_transition_user(self): """A user from the Verisign transition should return False when tested with class method needs_identity_verification""" TransitionDomain.objects.get_or_create(username=self.user.email, domain_name=self.domain_name) self.assertFalse(User.needs_identity_verification(self.user.email, self.user.username)) + @less_console_noise_decorator def test_identity_verification_with_very_important_person(self): """A Very Important Person should return False when tested with class method needs_identity_verification""" VerifiedByStaff.objects.get_or_create(email=self.user.email) self.assertFalse(User.needs_identity_verification(self.user.email, self.user.username)) + @less_console_noise_decorator def test_identity_verification_with_invited_user(self): """An invited user should return False when tested with class method needs_identity_verification""" DomainInvitation.objects.get_or_create(email=self.user.email, domain=self.domain) self.assertFalse(User.needs_identity_verification(self.user.email, self.user.username)) + @less_console_noise_decorator def test_identity_verification_with_new_user(self): """A new user who's neither transitioned nor invited should return True when tested with class method needs_identity_verification""" self.assertTrue(User.needs_identity_verification(self.user.email, self.user.username)) + @less_console_noise_decorator def test_check_domain_invitations_on_login_caps_email(self): """A DomainInvitation with an email address with capital letters should match a User record whose email address is not in caps""" @@ -1123,13 +1252,13 @@ class TestUser(TestCase): caps_email = "MAYOR@igorville.gov" # mock the domain invitation save routine with patch("registrar.models.DomainInvitation.save") as save_mock: - with less_console_noise(): - DomainInvitation.objects.get_or_create(email=caps_email, domain=self.domain) - self.user.check_domain_invitations_on_login() - # if check_domain_invitations_on_login properly matches exactly one - # Domain Invitation, then save routine should be called exactly once - save_mock.assert_called_once() + DomainInvitation.objects.get_or_create(email=caps_email, domain=self.domain) + self.user.check_domain_invitations_on_login() + # if check_domain_invitations_on_login properly matches exactly one + # Domain Invitation, then save routine should be called exactly once + save_mock.assert_called_once() + @less_console_noise_decorator def test_approved_domains_count(self): """Test that the correct approved domain count is returned for a user""" # with no associated approved domains, expect this to return 0 @@ -1154,6 +1283,7 @@ class TestUser(TestCase): UserDomainRole.objects.get_or_create(user=self.user, domain=domain5, role=UserDomainRole.Roles.MANAGER) self.assertEquals(self.user.get_approved_domains_count(), 4) + @less_console_noise_decorator def test_active_requests_count(self): """Test that the correct active domain requests count is returned for a user""" # with no associated active requests, expect this to return 0 @@ -1183,6 +1313,7 @@ class TestUser(TestCase): ) self.assertEquals(self.user.get_active_requests_count(), 3) + @less_console_noise_decorator def test_rejected_requests_count(self): """Test that the correct rejected domain requests count is returned for a user""" # with no associated rejected requests, expect this to return 0 @@ -1194,6 +1325,7 @@ class TestUser(TestCase): ) self.assertEquals(self.user.get_rejected_requests_count(), 1) + @less_console_noise_decorator def test_ineligible_requests_count(self): """Test that the correct ineligible domain requests count is returned for a user""" # with no associated ineligible requests, expect this to return 0 @@ -1205,6 +1337,7 @@ class TestUser(TestCase): ) self.assertEquals(self.user.get_ineligible_requests_count(), 1) + @less_console_noise_decorator def test_has_contact_info(self): """Test that has_contact_info properly returns""" # test with a user with contact info defined @@ -1221,23 +1354,20 @@ class TestUser(TestCase): 1. Returns False when a user does not have a portfolio 2. Returns True when user has direct permission 3. Returns True when user has permission through a role - 4. Returns True EDIT_DOMAINS when user does not have the perm but has UserDomainRole Note: This tests _get_portfolio_permissions as a side effect """ portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California") - self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS] + self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS] self.user.save() self.user.refresh_from_db() user_can_view_all_domains = self.user.has_domains_portfolio_permission() user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission() - user_can_edit_domains = self.user.has_edit_domains_portfolio_permission() self.assertFalse(user_can_view_all_domains) self.assertFalse(user_can_view_all_requests) - self.assertFalse(user_can_edit_domains) self.user.portfolio = portfolio self.user.save() @@ -1245,23 +1375,19 @@ class TestUser(TestCase): user_can_view_all_domains = self.user.has_domains_portfolio_permission() user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission() - user_can_edit_domains = self.user.has_edit_domains_portfolio_permission() self.assertTrue(user_can_view_all_domains) self.assertFalse(user_can_view_all_requests) - self.assertFalse(user_can_edit_domains) - self.user.portfolio_roles = [User.UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] self.user.save() self.user.refresh_from_db() user_can_view_all_domains = self.user.has_domains_portfolio_permission() user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission() - user_can_edit_domains = self.user.has_edit_domains_portfolio_permission() self.assertTrue(user_can_view_all_domains) self.assertTrue(user_can_view_all_requests) - self.assertFalse(user_can_edit_domains) UserDomainRole.objects.all().get_or_create( user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER @@ -1269,16 +1395,15 @@ class TestUser(TestCase): user_can_view_all_domains = self.user.has_domains_portfolio_permission() user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission() - user_can_edit_domains = self.user.has_edit_domains_portfolio_permission() self.assertTrue(user_can_view_all_domains) self.assertTrue(user_can_view_all_requests) - self.assertTrue(user_can_edit_domains) Portfolio.objects.all().delete() class TestContact(TestCase): + @less_console_noise_decorator def setUp(self): self.email = "mayor@igorville.gov" self.user, _ = User.objects.get_or_create( @@ -1304,6 +1429,7 @@ class TestContact(TestCase): self.assertFalse(self.contact_as_so.has_more_than_one_join("senior_official")) self.assertTrue(self.contact_as_so.has_more_than_one_join("submitted_domain_requests")) + @less_console_noise_decorator def test_has_contact_info(self): """Test that has_contact_info properly returns""" self.contact.title = "Title" @@ -1323,6 +1449,7 @@ class TestDomainRequestCustomSave(TestCase): DomainRequest.objects.all().delete() super().tearDown() + @less_console_noise_decorator def test_create_or_update_organization_type_new_instance(self): """Test create_or_update_organization_type when creating a new instance""" domain_request = completed_domain_request( @@ -1334,6 +1461,7 @@ class TestDomainRequestCustomSave(TestCase): self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION) + @less_console_noise_decorator def test_create_or_update_organization_type_new_instance_federal_does_nothing(self): """Test if create_or_update_organization_type does nothing when creating a new instance for federal""" domain_request = completed_domain_request( @@ -1345,6 +1473,7 @@ class TestDomainRequestCustomSave(TestCase): self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.FEDERAL) self.assertEqual(domain_request.is_election_board, None) + @less_console_noise_decorator def test_create_or_update_organization_type_existing_instance_updates_election_board(self): """Test create_or_update_organization_type for an existing instance.""" domain_request = completed_domain_request( @@ -1373,6 +1502,7 @@ class TestDomainRequestCustomSave(TestCase): self.assertEqual(domain_request.is_election_board, False) self.assertEqual(domain_request.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY) + @less_console_noise_decorator def test_create_or_update_organization_type_existing_instance_updates_generic_org_type(self): """Test create_or_update_organization_type when modifying generic_org_type on an existing instance.""" domain_request = completed_domain_request( @@ -1409,6 +1539,7 @@ class TestDomainRequestCustomSave(TestCase): domain_request_tribal.organization_type, DomainRequest.OrgChoicesElectionOffice.STATE_OR_TERRITORY_ELECTION ) + @less_console_noise_decorator def test_create_or_update_organization_type_no_update(self): """Test create_or_update_organization_type when there are no values to update.""" @@ -1472,6 +1603,7 @@ class TestDomainInformationCustomSave(TestCase): Domain.objects.all().delete() super().tearDown() + @less_console_noise_decorator def test_create_or_update_organization_type_new_instance(self): """Test create_or_update_organization_type when creating a new instance""" domain_request = completed_domain_request( @@ -1484,6 +1616,7 @@ class TestDomainInformationCustomSave(TestCase): domain_information = DomainInformation.create_from_da(domain_request) self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY_ELECTION) + @less_console_noise_decorator def test_create_or_update_organization_type_new_instance_federal_does_nothing(self): """Test if create_or_update_organization_type does nothing when creating a new instance for federal""" domain_request = completed_domain_request( @@ -1497,6 +1630,7 @@ class TestDomainInformationCustomSave(TestCase): self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.FEDERAL) self.assertEqual(domain_information.is_election_board, None) + @less_console_noise_decorator def test_create_or_update_organization_type_existing_instance_updates_election_board(self): """Test create_or_update_organization_type for an existing instance.""" domain_request = completed_domain_request( @@ -1527,6 +1661,7 @@ class TestDomainInformationCustomSave(TestCase): self.assertEqual(domain_information.is_election_board, False) self.assertEqual(domain_information.organization_type, DomainRequest.OrgChoicesElectionOffice.CITY) + @less_console_noise_decorator def test_create_or_update_organization_type_existing_instance_updates_generic_org_type(self): """Test create_or_update_organization_type when modifying generic_org_type on an existing instance.""" domain_request = completed_domain_request( @@ -1566,6 +1701,7 @@ class TestDomainInformationCustomSave(TestCase): DomainRequest.OrgChoicesElectionOffice.STATE_OR_TERRITORY_ELECTION, ) + @less_console_noise_decorator def test_create_or_update_organization_type_no_update(self): """Test create_or_update_organization_type when there are no values to update.""" @@ -1623,16 +1759,16 @@ class TestDomainInformationCustomSave(TestCase): class TestDomainRequestIncomplete(TestCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.factory = RequestFactory() + cls.user = create_test_user() + + @less_console_noise_decorator def setUp(self): super().setUp() - self.factory = RequestFactory() - username = "test_user" - first_name = "First" - last_name = "Last" - email = "info@example.com" - self.user = get_user_model().objects.create( - username=username, first_name=first_name, last_name=last_name, email=email - ) so, _ = Contact.objects.get_or_create( first_name="Meowy", last_name="Meoward", @@ -1657,6 +1793,7 @@ class TestDomainRequestIncomplete(TestCase): ) alt, _ = Website.objects.get_or_create(website="MeowardMeowardMeoward1.gov") current, _ = Website.objects.get_or_create(website="MeowardMeowardMeoward.com") + self.amtrak, _ = FederalAgency.objects.get_or_create(agency="AMTRAK") self.domain_request = DomainRequest.objects.create( generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, federal_type="executive", @@ -1689,13 +1826,21 @@ class TestDomainRequestIncomplete(TestCase): super().tearDown() DomainRequest.objects.all().delete() Contact.objects.all().delete() + self.amtrak.delete() + @classmethod + def tearDownClass(cls): + super().tearDownClass() + cls.user.delete() + + @less_console_noise_decorator def test_is_federal_complete(self): self.assertTrue(self.domain_request._is_federal_complete()) self.domain_request.federal_type = None self.domain_request.save() self.assertFalse(self.domain_request._is_federal_complete()) + @less_console_noise_decorator def test_is_interstate_complete(self): self.domain_request.generic_org_type = DomainRequest.OrganizationChoices.INTERSTATE self.domain_request.about_your_organization = "Something something about your organization" @@ -1705,6 +1850,7 @@ class TestDomainRequestIncomplete(TestCase): self.domain_request.save() self.assertFalse(self.domain_request._is_interstate_complete()) + @less_console_noise_decorator def test_is_state_or_territory_complete(self): self.domain_request.generic_org_type = DomainRequest.OrganizationChoices.STATE_OR_TERRITORY self.domain_request.is_election_board = True @@ -1715,6 +1861,7 @@ class TestDomainRequestIncomplete(TestCase): # is_election_board will overwrite to False bc of _update_org_type_from_generic_org_and_election self.assertTrue(self.domain_request._is_state_or_territory_complete()) + @less_console_noise_decorator def test_is_tribal_complete(self): self.domain_request.generic_org_type = DomainRequest.OrganizationChoices.TRIBAL self.domain_request.tribe_name = "Tribe Name" @@ -1727,6 +1874,7 @@ class TestDomainRequestIncomplete(TestCase): # is_election_board will overwrite to False bc of _update_org_type_from_generic_org_and_election self.assertFalse(self.domain_request._is_tribal_complete()) + @less_console_noise_decorator def test_is_county_complete(self): self.domain_request.generic_org_type = DomainRequest.OrganizationChoices.COUNTY self.domain_request.is_election_board = False @@ -1737,6 +1885,7 @@ class TestDomainRequestIncomplete(TestCase): # is_election_board will overwrite to False bc of _update_org_type_from_generic_org_and_election self.assertTrue(self.domain_request._is_county_complete()) + @less_console_noise_decorator def test_is_city_complete(self): self.domain_request.generic_org_type = DomainRequest.OrganizationChoices.CITY self.domain_request.is_election_board = False @@ -1747,6 +1896,7 @@ class TestDomainRequestIncomplete(TestCase): # is_election_board will overwrite to False bc of _update_org_type_from_generic_org_and_election self.assertTrue(self.domain_request._is_city_complete()) + @less_console_noise_decorator def test_is_special_district_complete(self): self.domain_request.generic_org_type = DomainRequest.OrganizationChoices.SPECIAL_DISTRICT self.domain_request.about_your_organization = "Something something about your organization" @@ -1759,6 +1909,7 @@ class TestDomainRequestIncomplete(TestCase): # is_election_board will overwrite to False bc of _update_org_type_from_generic_org_and_election self.assertFalse(self.domain_request._is_special_district_complete()) + @less_console_noise_decorator def test_is_organization_name_and_address_complete(self): self.assertTrue(self.domain_request._is_organization_name_and_address_complete()) self.domain_request.organization_name = None @@ -1766,30 +1917,35 @@ class TestDomainRequestIncomplete(TestCase): self.domain_request.save() self.assertTrue(self.domain_request._is_organization_name_and_address_complete()) + @less_console_noise_decorator def test_is_senior_official_complete(self): self.assertTrue(self.domain_request._is_senior_official_complete()) self.domain_request.senior_official = None self.domain_request.save() self.assertFalse(self.domain_request._is_senior_official_complete()) + @less_console_noise_decorator def test_is_requested_domain_complete(self): self.assertTrue(self.domain_request._is_requested_domain_complete()) self.domain_request.requested_domain = None self.domain_request.save() self.assertFalse(self.domain_request._is_requested_domain_complete()) + @less_console_noise_decorator def test_is_purpose_complete(self): self.assertTrue(self.domain_request._is_purpose_complete()) self.domain_request.purpose = None self.domain_request.save() self.assertFalse(self.domain_request._is_purpose_complete()) + @less_console_noise_decorator def test_is_submitter_complete(self): self.assertTrue(self.domain_request._is_submitter_complete()) self.domain_request.submitter = None self.domain_request.save() self.assertFalse(self.domain_request._is_submitter_complete()) + @less_console_noise_decorator def test_is_other_contacts_complete_missing_one_field(self): self.assertTrue(self.domain_request._is_other_contacts_complete()) contact = self.domain_request.other_contacts.first() @@ -1797,10 +1953,12 @@ class TestDomainRequestIncomplete(TestCase): contact.save() self.assertFalse(self.domain_request._is_other_contacts_complete()) + @less_console_noise_decorator def test_is_other_contacts_complete_all_none(self): self.domain_request.other_contacts.clear() self.assertFalse(self.domain_request._is_other_contacts_complete()) + @less_console_noise_decorator def test_is_other_contacts_False_and_has_rationale(self): # Click radio button "No" for no other contacts and give rationale self.domain_request.other_contacts.clear() @@ -1808,6 +1966,7 @@ class TestDomainRequestIncomplete(TestCase): self.domain_request.no_other_contacts_rationale = "Some rationale" self.assertTrue(self.domain_request._is_other_contacts_complete()) + @less_console_noise_decorator def test_is_other_contacts_False_and_NO_rationale(self): # Click radio button "No" for no other contacts and DONT give rationale self.domain_request.other_contacts.clear() @@ -1815,6 +1974,7 @@ class TestDomainRequestIncomplete(TestCase): self.domain_request.no_other_contacts_rationale = None self.assertFalse(self.domain_request._is_other_contacts_complete()) + @less_console_noise_decorator def test_is_additional_details_complete(self): test_cases = [ # CISA Rep - Yes @@ -2021,6 +2181,7 @@ class TestDomainRequestIncomplete(TestCase): msg=f"Failed for case: {case}", ) + @less_console_noise_decorator def test_is_policy_acknowledgement_complete(self): self.assertTrue(self.domain_request._is_policy_acknowledgement_complete()) self.domain_request.is_policy_acknowledged = False @@ -2028,6 +2189,7 @@ class TestDomainRequestIncomplete(TestCase): self.domain_request.is_policy_acknowledged = None self.assertFalse(self.domain_request._is_policy_acknowledgement_complete()) + @less_console_noise_decorator def test_form_complete(self): request = self.factory.get("/") request.user = self.user diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index ab21d60b3..74b84834e 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -1,12 +1,16 @@ import io from django.test import Client, RequestFactory from io import StringIO -from registrar.models.domain_request import DomainRequest -from registrar.models.domain import Domain +from registrar.models import ( + DomainRequest, + Domain, + UserDomainRole, +) from registrar.utility.csv_export import ( DomainDataFull, DomainDataType, DomainDataFederal, + DomainDataTypeUser, DomainGrowth, DomainManaged, DomainUnmanaged, @@ -27,14 +31,14 @@ import boto3_mocking from registrar.utility.s3_bucket import S3ClientError, S3ClientErrorCodes # type: ignore from django.utils import timezone from api.tests.common import less_console_noise_decorator -from .common import MockDb, MockEppLib, less_console_noise, get_time_aware_date +from .common import MockDbForSharedTests, MockDbForIndividualTests, MockEppLib, less_console_noise, get_time_aware_date -class CsvReportsTest(MockDb): - """Tests to determine if we are uploading our reports correctly""" +class CsvReportsTest(MockDbForSharedTests): + """Tests to determine if we are uploading our reports correctly.""" def setUp(self): - """Create fake domain data""" + """setup fake comain data""" super().setUp() self.client = Client(HTTP_HOST="localhost:8080") self.factory = RequestFactory() @@ -198,17 +202,13 @@ class CsvReportsTest(MockDb): self.assertEqual(expected_file_content, response.content) -class ExportDataTest(MockDb, MockEppLib): - def setUp(self): - super().setUp() - - def tearDown(self): - super().tearDown() +class ExportDataTest(MockDbForIndividualTests, MockEppLib): + """Test the ExportData class from csv_export.""" @less_console_noise_decorator def test_domain_data_type(self): """Shows security contacts, domain managers, so""" - self.maxDiff = None + # Add security email information self.domain_1.name = "defaultsecurity.gov" self.domain_1.save() @@ -237,7 +237,7 @@ class ExportDataTest(MockDb, MockEppLib): "cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive,World War I Centennial Commission,,,,(blank),,," "meoward@rocks.com,\n" "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,," - ',,,(blank),"meoward@rocks.com, info@example.com, big_lebowski@dude.co",' + ',,,(blank),"big_lebowski@dude.co, info@example.com, meoward@rocks.com",' "woofwardthethird@rocks.com\n" "adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,," "squeaker@rocks.com\n" @@ -260,6 +260,57 @@ class ExportDataTest(MockDb, MockEppLib): self.maxDiff = None self.assertEqual(csv_content, expected_content) + @less_console_noise_decorator + def test_domain_data_type_user(self): + """Shows security contacts, domain managers, so for the current user""" + + # Add security email information + self.domain_1.name = "defaultsecurity.gov" + self.domain_1.save() + # Invoke setter + self.domain_1.security_contact + self.domain_2.security_contact + self.domain_3.security_contact + # Add a first ready date on the first domain. Leaving the others blank. + self.domain_1.first_ready = get_default_start_date() + self.domain_1.save() + + # Create a user and associate it with some domains + UserDomainRole.objects.create(user=self.user, domain=self.domain_2) + + # Create a request object + factory = RequestFactory() + request = factory.get("/") + request.user = self.user + + # Create a CSV file in memory + csv_file = StringIO() + # Call the export functions + DomainDataTypeUser.export_data_to_csv(csv_file, request=request) + # Reset the CSV file's position to the beginning + csv_file.seek(0) + # Read the content into a variable + csv_content = csv_file.read() + + # We expect only domains associated with the user + expected_content = ( + "Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name," + "City,State,SO,SO email," + "Security contact email,Domain managers,Invited domain managers\n" + "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,,, ,," + '(blank),"big_lebowski@dude.co, info@example.com, meoward@rocks.com",' + "woofwardthethird@rocks.com\n" + "adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,, ,,(blank)," + '"info@example.com, meoward@rocks.com",squeaker@rocks.com\n' + ) + + # Normalize line endings and remove commas, + # spaces and leading/trailing whitespace + csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() + expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.maxDiff = None + self.assertEqual(csv_content, expected_content) + @less_console_noise_decorator def test_domain_data_full(self): """Shows security contacts, filtered by state""" @@ -370,8 +421,8 @@ class ExportDataTest(MockDb, MockEppLib): # Call the export functions DomainGrowth.export_data_to_csv( csv_file, - self.start_date.strftime("%Y-%m-%d"), - self.end_date.strftime("%Y-%m-%d"), + start_date=self.start_date.strftime("%Y-%m-%d"), + end_date=self.end_date.strftime("%Y-%m-%d"), ) # Reset the CSV file's position to the beginning csv_file.seek(0) @@ -412,8 +463,8 @@ class ExportDataTest(MockDb, MockEppLib): # Call the export functions DomainManaged.export_data_to_csv( csv_file, - self.start_date.strftime("%Y-%m-%d"), - self.end_date.strftime("%Y-%m-%d"), + start_date=self.start_date.strftime("%Y-%m-%d"), + end_date=self.end_date.strftime("%Y-%m-%d"), ) # Reset the CSV file's position to the beginning csv_file.seek(0) @@ -433,7 +484,7 @@ class ExportDataTest(MockDb, MockEppLib): "\n" "Domain name,Domain type,Domain managers,Invited domain managers\n" "cdomain11.gov,Federal - Executive,meoward@rocks.com,\n" - 'cdomain1.gov,Federal - Executive,"meoward@rocks.com, info@example.com, big_lebowski@dude.co",' + 'cdomain1.gov,Federal - Executive,"big_lebowski@dude.co, info@example.com, meoward@rocks.com",' "woofwardthethird@rocks.com\n" "zdomain12.gov,Interstate,meoward@rocks.com,\n" ) @@ -441,6 +492,7 @@ class ExportDataTest(MockDb, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.maxDiff = None self.assertEqual(csv_content, expected_content) @less_console_noise_decorator @@ -449,7 +501,7 @@ class ExportDataTest(MockDb, MockEppLib): # Create a CSV file in memory csv_file = StringIO() DomainUnmanaged.export_data_to_csv( - csv_file, self.start_date.strftime("%Y-%m-%d"), self.end_date.strftime("%Y-%m-%d") + csv_file, start_date=self.start_date.strftime("%Y-%m-%d"), end_date=self.end_date.strftime("%Y-%m-%d") ) # Reset the CSV file's position to the beginning @@ -496,8 +548,8 @@ class ExportDataTest(MockDb, MockEppLib): # Call the export functions DomainRequestGrowth.export_data_to_csv( csv_file, - self.start_date.strftime("%Y-%m-%d"), - self.end_date.strftime("%Y-%m-%d"), + start_date=self.start_date.strftime("%Y-%m-%d"), + end_date=self.end_date.strftime("%Y-%m-%d"), ) # Reset the CSV file's position to the beginning csv_file.seek(0) @@ -595,7 +647,7 @@ class ExportDataTest(MockDb, MockEppLib): self.assertEqual(csv_content, expected_content) -class HelperFunctions(MockDb): +class HelperFunctions(MockDbForSharedTests): """This asserts that 1=1. Its limited usefulness lies in making sure the helper methods stay healthy.""" def test_get_default_start_date(self): diff --git a/src/registrar/tests/test_transition_domain_migrations.py b/src/registrar/tests/test_transition_domain_migrations.py index c7f916976..be7bcf9e4 100644 --- a/src/registrar/tests/test_transition_domain_migrations.py +++ b/src/registrar/tests/test_transition_domain_migrations.py @@ -43,7 +43,6 @@ class TestProcessedMigrations(TestCase): DomainInformation.objects.all().delete() DomainInvitation.objects.all().delete() TransitionDomain.objects.all().delete() - FederalAgency.objects.all().delete() # Delete users User.objects.all().delete() @@ -185,6 +184,7 @@ class TestOrganizationMigration(TestCase): """Defines the file name of migration_json and the folder its contained in""" self.test_data_file_location = "registrar/tests/data" self.migration_json_filename = "test_migrationFilepaths.json" + self.federal_agency, _ = FederalAgency.objects.get_or_create(agency="Department of Commerce") def tearDown(self): """Deletes all DB objects related to migrations""" @@ -197,6 +197,7 @@ class TestOrganizationMigration(TestCase): # Delete users User.objects.all().delete() UserDomainRole.objects.all().delete() + self.federal_agency.delete() def run_load_domains(self): """ @@ -331,7 +332,6 @@ class TestOrganizationMigration(TestCase): # Lets test the first one transition = transition_domains.first() - federal_agency, _ = FederalAgency.objects.get_or_create(agency="Department of Commerce") expected_transition_domain = TransitionDomain( username="alexandra.bobbitt5@test.com", domain_name="fakewebsite2.gov", @@ -340,7 +340,7 @@ class TestOrganizationMigration(TestCase): generic_org_type="Federal", organization_name="Fanoodle", federal_type="Executive", - federal_agency=federal_agency, + federal_agency=self.federal_agency, epp_creation_date=datetime.date(2004, 5, 7), epp_expiration_date=datetime.date(2023, 9, 30), first_name="Seline", @@ -395,7 +395,6 @@ class TestOrganizationMigration(TestCase): # == Third, test that we've loaded data as we expect == # _domain = Domain.objects.filter(name="fakewebsite2.gov").get() domain_information = DomainInformation.objects.filter(domain=_domain).get() - federal_agency, _ = FederalAgency.objects.get_or_create(agency="Department of Commerce") expected_creator = User.objects.filter(username="System").get() expected_so = Contact.objects.filter( @@ -404,7 +403,7 @@ class TestOrganizationMigration(TestCase): expected_domain_information = DomainInformation( creator=expected_creator, generic_org_type="federal", - federal_agency=federal_agency, + federal_agency=self.federal_agency, federal_type="executive", organization_name="Fanoodle", address_line1="93001 Arizona Drive", @@ -451,7 +450,6 @@ class TestOrganizationMigration(TestCase): # == Fourth, test that no data is overwritten as we expect == # _domain = Domain.objects.filter(name="fakewebsite2.gov").get() domain_information = DomainInformation.objects.filter(domain=_domain).get() - federal_agency, _ = FederalAgency.objects.get_or_create(agency="Department of Commerce") expected_creator = User.objects.filter(username="System").get() expected_so = Contact.objects.filter( @@ -460,7 +458,7 @@ class TestOrganizationMigration(TestCase): expected_domain_information = DomainInformation( creator=expected_creator, generic_org_type="federal", - federal_agency=federal_agency, + federal_agency=self.federal_agency, federal_type="executive", organization_name="Fanoodle", address_line1="93001 Galactic Way", diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index 29ce121b3..6c2ad6b5e 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -8,13 +8,13 @@ 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.federal_agency import FederalAgency from registrar.models.portfolio import Portfolio from registrar.models.public_contact import PublicContact from registrar.models.user import User from registrar.models.user_domain_role import UserDomainRole from registrar.views.domain import DomainNameserversView - -from .common import MockEppLib, less_console_noise # type: ignore +from .common import MockEppLib, create_test_user, less_console_noise # type: ignore from unittest.mock import patch from django.urls import reverse @@ -30,18 +30,23 @@ logger = logging.getLogger(__name__) class TestViews(TestCase): + def setUp(self): + super().setUp() self.client = Client() + @less_console_noise_decorator def test_health_check_endpoint(self): response = self.client.get("/health") self.assertContains(response, "OK", status_code=200) + @less_console_noise_decorator def test_home_page(self): """Home page should NOT be available without a login.""" response = self.client.get("/") self.assertEqual(response.status_code, 302) + @less_console_noise_decorator def test_domain_request_form_not_logged_in(self): """Domain request form not accessible without a logged-in user.""" response = self.client.get("/request/") @@ -50,75 +55,61 @@ class TestViews(TestCase): class TestWithUser(MockEppLib): + """Class for executing tests with a test user. + Note that tests share the test user within their test class, so the user + cannot be changed within a test.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user = create_test_user() + def setUp(self): super().setUp() - username = "test_user" - first_name = "First" - last_name = "Last" - email = "info@example.com" - phone = "8003111234" - title = "test title" - self.user = get_user_model().objects.create( - username=username, first_name=first_name, last_name=last_name, title=title, email=email, phone=phone - ) + self.client = Client() - username_regular_incomplete = "test_regular_user_incomplete" - username_other_incomplete = "test_other_user_incomplete" - first_name_2 = "Incomplete" - email_2 = "unicorn@igorville.com" - # in the case below, REGULAR user is 'Verified by Login.gov, ie. IAL2 - self.incomplete_regular_user = get_user_model().objects.create( - username=username_regular_incomplete, - first_name=first_name_2, - email=email_2, - verification_type=User.VerificationTypeChoices.REGULAR, - ) - # in the case below, other user is representative of GRANDFATHERED, - # VERIFIED_BY_STAFF, INVITED, FIXTURE_USER, ie. IAL1 - self.incomplete_other_user = get_user_model().objects.create( - username=username_other_incomplete, - first_name=first_name_2, - email=email_2, - verification_type=User.VerificationTypeChoices.VERIFIED_BY_STAFF, - ) - - def tearDown(self): - # delete any domain requests too - super().tearDown() - DomainRequest.objects.all().delete() - DomainInformation.objects.all().delete() + @classmethod + def tearDownClass(cls): + super().tearDownClass() User.objects.all().delete() class TestEnvironmentVariablesEffects(TestCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user = create_test_user() + def setUp(self): self.client = Client() - username = "test_user" - first_name = "First" - last_name = "Last" - email = "info@example.com" - self.user = get_user_model().objects.create( - username=username, first_name=first_name, last_name=last_name, email=email - ) self.client.force_login(self.user) def tearDown(self): super().tearDown() + UserDomainRole.objects.all().delete() Domain.objects.all().delete() - self.user.delete() + @classmethod + def tearDownClass(cls): + super().tearDownClass() + User.objects.all().delete() + + @less_console_noise_decorator @override_settings(IS_PRODUCTION=True) def test_production_environment(self): """No banner on prod.""" home_page = self.client.get("/") self.assertNotContains(home_page, "You are on a test site.") + @less_console_noise_decorator @override_settings(IS_PRODUCTION=False) def test_non_production_environment(self): """Banner on non-prod.""" home_page = self.client.get("/") self.assertContains(home_page, "You are on a test site.") + @less_console_noise_decorator def side_effect_raise_value_error(self): """Side effect that raises a 500 error""" raise ValueError("Some error") @@ -130,9 +121,7 @@ class TestEnvironmentVariablesEffects(TestCase): fake_domain, _ = Domain.objects.get_or_create(name="igorville.gov") # Add a role - fake_role, _ = UserDomainRole.objects.get_or_create( - user=self.user, domain=fake_domain, role=UserDomainRole.Roles.MANAGER - ) + UserDomainRole.objects.get_or_create(user=self.user, domain=fake_domain, role=UserDomainRole.Roles.MANAGER) with patch.object(DomainNameserversView, "get_initial", side_effect=self.side_effect_raise_value_error): with self.assertRaises(ValueError): @@ -153,9 +142,7 @@ class TestEnvironmentVariablesEffects(TestCase): fake_domain, _ = Domain.objects.get_or_create(name="igorville.gov") # Add a role - fake_role, _ = UserDomainRole.objects.get_or_create( - user=self.user, domain=fake_domain, role=UserDomainRole.Roles.MANAGER - ) + UserDomainRole.objects.get_or_create(user=self.user, domain=fake_domain, role=UserDomainRole.Roles.MANAGER) with patch.object(DomainNameserversView, "get_initial", side_effect=self.side_effect_raise_value_error): with self.assertRaises(ValueError): @@ -176,15 +163,13 @@ class HomeTests(TestWithUser): super().setUp() self.client.force_login(self.user) - def tearDown(self): - super().tearDown() - Contact.objects.all().delete() - + @less_console_noise_decorator 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?") + @less_console_noise_decorator def test_state_help_text(self): """Tests if each domain state has help text""" @@ -226,6 +211,7 @@ class HomeTests(TestWithUser): user_role.delete() test_domain.delete() + @less_console_noise_decorator 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. " @@ -233,7 +219,9 @@ class HomeTests(TestWithUser): 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) + test_role, _ = UserDomainRole.objects.get_or_create( + user=self.user, domain=test_domain, role=UserDomainRole.Roles.MANAGER + ) # Grab the json response of the domains list response = self.client.get("/get-domains-json/") @@ -244,6 +232,10 @@ class HomeTests(TestWithUser): # Check that we have the right text content. self.assertContains(response, expired_text, count=1) + test_role.delete() + test_domain.delete() + + @less_console_noise_decorator def test_state_help_text_no_expiration_date(self): """Tests if each domain state has help text when expiration date is None""" @@ -287,6 +279,10 @@ class HomeTests(TestWithUser): # Check that we have the right text content. self.assertContains(response, unknown_text, count=1) + UserDomainRole.objects.all().delete() + Domain.objects.all().delete() + + @less_console_noise_decorator def test_home_deletes_withdrawn_domain_request(self): """Tests if the user can delete a DomainRequest in the 'withdrawn' status""" @@ -303,6 +299,7 @@ class HomeTests(TestWithUser): # clean up domain_request.delete() + @less_console_noise_decorator def test_home_deletes_started_domain_request(self): """Tests if the user can delete a DomainRequest in the 'started' status""" @@ -352,6 +349,7 @@ class HomeTests(TestWithUser): # clean up domain_request.delete() + @less_console_noise_decorator def test_home_deletes_domain_request_and_orphans(self): """Tests if delete for DomainRequest deletes orphaned Contact objects""" @@ -421,6 +419,10 @@ class HomeTests(TestWithUser): self.assertEqual(edge_case, contact_2) + DomainRequest.objects.all().delete() + Contact.objects.all().delete() + + @less_console_noise_decorator 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)""" @@ -481,6 +483,10 @@ class HomeTests(TestWithUser): orphan = Contact.objects.filter(id=contact_shared.id) self.assertFalse(orphan.exists()) + DomainRequest.objects.all().delete() + Contact.objects.all().delete() + + @less_console_noise_decorator def test_domain_request_form_view(self): response = self.client.get("/request/", follow=True) self.assertContains( @@ -488,16 +494,24 @@ class HomeTests(TestWithUser): "You’re about to start your .gov domain request.", ) + @less_console_noise_decorator 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) + username = "restricted_user" + first_name = "First" + last_name = "Last" + email = "restricted@example.com" + phone = "8003111234" + status = User.RESTRICTED + restricted_user = get_user_model().objects.create( + username=username, first_name=first_name, last_name=last_name, email=email, phone=phone, status=status + ) + self.client.force_login(restricted_user) + response = self.client.get("/request/", follow=True) + self.assertEqual(response.status_code, 403) + restricted_user.delete() class FinishUserProfileTests(TestWithUser, WebTest): @@ -509,6 +523,7 @@ class FinishUserProfileTests(TestWithUser, WebTest): def setUp(self): super().setUp() + self.initial_user_title = self.user.title self.user.title = None self.user.save() self.client.force_login(self.user) @@ -519,6 +534,10 @@ class FinishUserProfileTests(TestWithUser, WebTest): def tearDown(self): super().tearDown() + DomainRequest.objects.all().delete() + DomainInformation.objects.all().delete() + self.user.title = self.initial_user_title + self.user.save() PublicContact.objects.filter(domain=self.domain).delete() self.role.delete() self.domain.delete() @@ -543,48 +562,70 @@ class FinishUserProfileTests(TestWithUser, WebTest): def test_full_name_initial_value(self): """Test that full_name initial value is empty when first_name or last_name is empty. This will later be displayed as "unknown" using javascript.""" - self.app.set_user(self.incomplete_regular_user.username) + username_regular_incomplete = "test_regular_user_incomplete" + first_name_2 = "Incomplete" + email_2 = "unicorn@igorville.com" + incomplete_regular_user = get_user_model().objects.create( + username=username_regular_incomplete, + first_name=first_name_2, + email=email_2, + verification_type=User.VerificationTypeChoices.REGULAR, + ) + self.app.set_user(incomplete_regular_user.username) # Test when first_name is empty - self.incomplete_regular_user.first_name = "" - self.incomplete_regular_user.last_name = "Doe" - self.incomplete_regular_user.save() + incomplete_regular_user.first_name = "" + incomplete_regular_user.last_name = "Doe" + incomplete_regular_user.save() finish_setup_page = self.app.get(reverse("home")).follow() form = finish_setup_page.form self.assertEqual(form["full_name"].value, "") # Test when last_name is empty - self.incomplete_regular_user.first_name = "John" - self.incomplete_regular_user.last_name = "" - self.incomplete_regular_user.save() + incomplete_regular_user.first_name = "John" + incomplete_regular_user.last_name = "" + incomplete_regular_user.save() finish_setup_page = self.app.get(reverse("home")).follow() form = finish_setup_page.form self.assertEqual(form["full_name"].value, "") # Test when both first_name and last_name are empty - self.incomplete_regular_user.first_name = "" - self.incomplete_regular_user.last_name = "" - self.incomplete_regular_user.save() + incomplete_regular_user.first_name = "" + incomplete_regular_user.last_name = "" + incomplete_regular_user.save() finish_setup_page = self.app.get(reverse("home")).follow() form = finish_setup_page.form self.assertEqual(form["full_name"].value, "") # Test when both first_name and last_name are present - self.incomplete_regular_user.first_name = "John" - self.incomplete_regular_user.last_name = "Doe" - self.incomplete_regular_user.save() + incomplete_regular_user.first_name = "John" + incomplete_regular_user.last_name = "Doe" + incomplete_regular_user.save() finish_setup_page = self.app.get(reverse("home")).follow() form = finish_setup_page.form self.assertEqual(form["full_name"].value, "John Doe") + incomplete_regular_user.delete() + @less_console_noise_decorator def test_new_user_with_profile_feature_on(self): """Tests that a new user is redirected to the profile setup page when profile_feature is on""" - self.app.set_user(self.incomplete_regular_user.username) + username_regular_incomplete = "test_regular_user_incomplete" + first_name_2 = "Incomplete" + email_2 = "unicorn@igorville.com" + # in the case below, REGULAR user is 'Verified by Login.gov, ie. IAL2 + incomplete_regular_user = get_user_model().objects.create( + username=username_regular_incomplete, + first_name=first_name_2, + email=email_2, + verification_type=User.VerificationTypeChoices.REGULAR, + ) + + self.app.set_user(incomplete_regular_user.username) with override_flag("profile_feature", active=True): # This will redirect the user to the setup page. # Follow implicity checks if our redirect is working. @@ -618,14 +659,22 @@ class FinishUserProfileTests(TestWithUser, WebTest): # This is the same as clicking the back button. completed_setup_page = self.app.get(reverse("home")) self.assertContains(completed_setup_page, "Manage your domain") + incomplete_regular_user.delete() @less_console_noise_decorator def test_new_user_with_empty_name_can_add_name(self): """Tests that a new user without a name can still enter this information accordingly""" - self.incomplete_regular_user.first_name = "" - self.incomplete_regular_user.last_name = "" - self.incomplete_regular_user.save() - self.app.set_user(self.incomplete_regular_user.username) + username_regular_incomplete = "test_regular_user_incomplete" + email = "unicorn@igorville.com" + # in the case below, REGULAR user is 'Verified by Login.gov, ie. IAL2 + incomplete_regular_user = get_user_model().objects.create( + username=username_regular_incomplete, + first_name="", + last_name="", + email=email, + verification_type=User.VerificationTypeChoices.REGULAR, + ) + self.app.set_user(incomplete_regular_user.username) with override_flag("profile_feature", active=True): # This will redirect the user to the setup page. # Follow implicity checks if our redirect is working. @@ -661,12 +710,22 @@ class FinishUserProfileTests(TestWithUser, WebTest): # This is the same as clicking the back button. completed_setup_page = self.app.get(reverse("home")) self.assertContains(completed_setup_page, "Manage your domain") + incomplete_regular_user.delete() @less_console_noise_decorator def test_new_user_goes_to_domain_request_with_profile_feature_on(self): """Tests that a new user is redirected to the domain request page when profile_feature is on""" - - self.app.set_user(self.incomplete_regular_user.username) + username_regular_incomplete = "test_regular_user_incomplete" + first_name_2 = "Incomplete" + email_2 = "unicorn@igorville.com" + # in the case below, REGULAR user is 'Verified by Login.gov, ie. IAL2 + incomplete_regular_user = get_user_model().objects.create( + username=username_regular_incomplete, + first_name=first_name_2, + email=email_2, + verification_type=User.VerificationTypeChoices.REGULAR, + ) + self.app.set_user(incomplete_regular_user.username) with override_flag("profile_feature", active=True): # This will redirect the user to the setup page finish_setup_page = self.app.get(reverse("domain-request:")).follow() @@ -709,6 +768,7 @@ class FinishUserProfileTests(TestWithUser, WebTest): self.assertNotContains(completed_setup_page, "What contact information should we use to reach you?") self.assertContains(completed_setup_page, "You’re about to start your .gov domain request") + incomplete_regular_user.delete() @less_console_noise_decorator def test_new_user_with_profile_feature_off(self): @@ -739,6 +799,7 @@ class FinishUserProfileForOtherUsersTests(TestWithUser, WebTest): def setUp(self): super().setUp() + self.initial_user_title = self.user.title self.user.title = None self.user.save() self.client.force_login(self.user) @@ -749,6 +810,8 @@ class FinishUserProfileForOtherUsersTests(TestWithUser, WebTest): def tearDown(self): super().tearDown() + self.user.title = self.initial_user_title + self.user.save() PublicContact.objects.filter(domain=self.domain).delete() self.role.delete() Domain.objects.all().delete() @@ -768,7 +831,18 @@ class FinishUserProfileForOtherUsersTests(TestWithUser, WebTest): def test_new_user_with_profile_feature_on(self): """Tests that a new user is redirected to the profile setup page when profile_feature is on, and testing that the confirmation modal is present""" - self.app.set_user(self.incomplete_other_user.username) + username_other_incomplete = "test_other_user_incomplete" + first_name_2 = "Incomplete" + email_2 = "unicorn@igorville.com" + # in the case below, other user is representative of GRANDFATHERED, + # VERIFIED_BY_STAFF, INVITED, FIXTURE_USER, ie. IAL1 + incomplete_other_user = get_user_model().objects.create( + username=username_other_incomplete, + first_name=first_name_2, + email=email_2, + verification_type=User.VerificationTypeChoices.VERIFIED_BY_STAFF, + ) + self.app.set_user(incomplete_other_user.username) with override_flag("profile_feature", active=True): # This will redirect the user to the user profile page. # Follow implicity checks if our redirect is working. @@ -847,9 +921,10 @@ class UserProfileTests(TestWithUser, WebTest): PublicContact.objects.filter(domain=self.domain).delete() self.role.delete() self.domain.delete() - Contact.objects.all().delete() - DraftDomain.objects.all().delete() DomainRequest.objects.all().delete() + DraftDomain.objects.all().delete() + Contact.objects.all().delete() + DomainInformation.objects.all().delete() @less_console_noise_decorator def error_500_main_nav_with_profile_feature_turned_on(self): @@ -1023,16 +1098,19 @@ class PortfoliosTests(TestWithUser, WebTest): def setUp(self): super().setUp() - self.user.save() self.client.force_login(self.user) self.domain, _ = Domain.objects.get_or_create(name="sampledomain.gov", state=Domain.State.READY) self.role, _ = UserDomainRole.objects.get_or_create( user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER ) - self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="xyz inc") + self.federal_agency = FederalAgency.objects.create() + self.portfolio, _ = Portfolio.objects.get_or_create( + creator=self.user, organization_name="xyz inc", federal_agency=self.federal_agency + ) def tearDown(self): Portfolio.objects.all().delete() + self.federal_agency.delete() super().tearDown() PublicContact.objects.filter(domain=self.domain).delete() UserDomainRole.objects.all().delete() diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index f9097123b..00fcc8fe0 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -5,6 +5,9 @@ from django.conf import settings from django.urls import reverse from django.contrib.auth import get_user_model +from api.tests.common import less_console_noise_decorator +from registrar.models.portfolio import Portfolio +from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices from .common import MockEppLib, MockSESClient, create_user # type: ignore from django_webtest import WebTest # type: ignore import boto3_mocking # type: ignore @@ -45,6 +48,7 @@ logger = logging.getLogger(__name__) class TestWithDomainPermissions(TestWithUser): + @less_console_noise_decorator def setUp(self): super().setUp() self.domain, _ = Domain.objects.get_or_create(name="igorville.gov") @@ -136,12 +140,14 @@ class TestWithDomainPermissions(TestWithUser): Host.objects.all().delete() Domain.objects.all().delete() UserDomainRole.objects.all().delete() + Portfolio.objects.all().delete() except ValueError: # pass if already deleted pass super().tearDown() class TestDomainPermissions(TestWithDomainPermissions): + @less_console_noise_decorator def test_not_logged_in(self): """Not logged in gets a redirect to Login.""" for view_name in [ @@ -158,6 +164,7 @@ class TestDomainPermissions(TestWithDomainPermissions): response = self.client.get(reverse(view_name, kwargs={"pk": self.domain.id})) self.assertEqual(response.status_code, 302) + @less_console_noise_decorator def test_no_domain_role(self): """Logged in but no role gets 403 Forbidden.""" self.client.force_login(self.user) @@ -174,10 +181,10 @@ class TestDomainPermissions(TestWithDomainPermissions): "domain-security-email", ]: with self.subTest(view_name=view_name): - with less_console_noise(): - response = self.client.get(reverse(view_name, kwargs={"pk": self.domain.id})) + response = self.client.get(reverse(view_name, kwargs={"pk": self.domain.id})) self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_domain_pages_blocked_for_on_hold_and_deleted(self): """Test that the domain pages are blocked for on hold and deleted domains""" @@ -199,12 +206,12 @@ class TestDomainPermissions(TestWithDomainPermissions): self.domain_deleted, ]: with self.subTest(view_name=view_name, domain=domain): - with less_console_noise(): - response = self.client.get(reverse(view_name, kwargs={"pk": domain.id})) - self.assertEqual(response.status_code, 403) + response = self.client.get(reverse(view_name, kwargs={"pk": domain.id})) + self.assertEqual(response.status_code, 403) class TestDomainOverview(TestWithDomainPermissions, WebTest): + def setUp(self): super().setUp() self.app.set_user(self.user.username) @@ -306,27 +313,58 @@ class TestDomainDetail(TestDomainOverview): self.assertContains(detail_page, "noinformation.gov") self.assertContains(detail_page, "Domain missing domain information") + @less_console_noise_decorator + def test_domain_readonly_on_detail_page(self): + """Test that a domain, which is part of a portfolio, but for which the user is not a domain manager, + properly displays read only""" + + portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user) + # need to create a different user than self.user because the user needs permission assignments + user = get_user_model().objects.create( + first_name="Test", + last_name="User", + email="bogus@example.gov", + phone="8003111234", + title="test title", + portfolio=portfolio, + portfolio_roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + ) + domain, _ = Domain.objects.get_or_create(name="bogusdomain.gov") + DomainInformation.objects.get_or_create(creator=user, domain=domain, portfolio=portfolio) + self.client.force_login(user) + detail_page = self.client.get(f"/domain/{domain.id}") + # Check that alert message displays properly + self.assertContains( + detail_page, "To manage information for this domain, you must add yourself as a domain manager." + ) + # Check that user does not have option to Edit domain + self.assertNotContains(detail_page, "Edit") + class TestDomainManagers(TestDomainOverview): def tearDown(self): """Ensure that the user has its original permissions""" super().tearDown() + @less_console_noise_decorator def test_domain_managers(self): response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id})) self.assertContains(response, "Domain managers") + @less_console_noise_decorator def test_domain_managers_add_link(self): """Button to get to user add page works.""" management_page = self.app.get(reverse("domain-users", kwargs={"pk": self.domain.id})) add_page = management_page.click("Add a domain manager") self.assertContains(add_page, "Add a domain manager") + @less_console_noise_decorator def test_domain_user_add(self): response = self.client.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) self.assertContains(response, "Add a domain manager") @boto3_mocking.patching + @less_console_noise_decorator def test_domain_user_add_form(self): """Adding an existing user works.""" other_user, _ = get_user_model().objects.get_or_create(email="mayor@igorville.gov") @@ -353,6 +391,7 @@ class TestDomainManagers(TestDomainOverview): self.assertContains(success_page, "mayor@igorville.gov") @boto3_mocking.patching + @less_console_noise_decorator def test_domain_invitation_created(self): """Add user on a nonexistent email creates an invitation. @@ -383,6 +422,7 @@ class TestDomainManagers(TestDomainOverview): self.assertTrue(DomainInvitation.objects.filter(email=email_address).exists()) @boto3_mocking.patching + @less_console_noise_decorator def test_domain_invitation_created_for_caps_email(self): """Add user on a nonexistent email with CAPS creates an invitation to lowercase email. @@ -403,8 +443,7 @@ class TestDomainManagers(TestDomainOverview): mock_client = MockSESClient() with boto3_mocking.clients.handler_for("sesv2", mock_client): - with less_console_noise(): - success_result = add_page.form.submit() + success_result = add_page.form.submit() self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) success_page = success_result.follow() @@ -414,6 +453,7 @@ class TestDomainManagers(TestDomainOverview): self.assertTrue(DomainInvitation.objects.filter(email=email_address).exists()) @boto3_mocking.patching + @less_console_noise_decorator def test_domain_invitation_email_sent(self): """Inviting a non-existent user sends them an email.""" # make sure there is no user with this email @@ -425,12 +465,11 @@ class TestDomainManagers(TestDomainOverview): mock_client = MagicMock() mock_client_instance = mock_client.return_value with boto3_mocking.clients.handler_for("sesv2", mock_client): - with less_console_noise(): - add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) - session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - add_page.form["email"] = email_address - self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - add_page.form.submit() + add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + add_page.form["email"] = email_address + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + add_page.form.submit() # check the mock instance to see if `send_email` was called right mock_client_instance.send_email.assert_called_once_with( @@ -440,6 +479,7 @@ class TestDomainManagers(TestDomainOverview): ) @boto3_mocking.patching + @less_console_noise_decorator def test_domain_invitation_email_has_email_as_requestor_non_existent(self): """Inviting a non existent user sends them an email, with email as the name.""" # make sure there is no user with this email @@ -452,12 +492,11 @@ class TestDomainManagers(TestDomainOverview): mock_client_instance = mock_client.return_value with boto3_mocking.clients.handler_for("sesv2", mock_client): - with less_console_noise(): - add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) - session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - add_page.form["email"] = email_address - self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - add_page.form.submit() + add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + add_page.form["email"] = email_address + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + add_page.form.submit() # check the mock instance to see if `send_email` was called right mock_client_instance.send_email.assert_called_once_with( @@ -479,6 +518,7 @@ class TestDomainManagers(TestDomainOverview): self.assertNotIn("First Last", email_content) @boto3_mocking.patching + @less_console_noise_decorator def test_domain_invitation_email_has_email_as_requestor(self): """Inviting a user sends them an email, with email as the name.""" # Create a fake user object @@ -491,12 +531,11 @@ class TestDomainManagers(TestDomainOverview): mock_client_instance = mock_client.return_value with boto3_mocking.clients.handler_for("sesv2", mock_client): - with less_console_noise(): - add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) - session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - add_page.form["email"] = email_address - self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - add_page.form.submit() + add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + add_page.form["email"] = email_address + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + add_page.form.submit() # check the mock instance to see if `send_email` was called right mock_client_instance.send_email.assert_called_once_with( @@ -518,6 +557,7 @@ class TestDomainManagers(TestDomainOverview): self.assertNotIn("First Last", email_content) @boto3_mocking.patching + @less_console_noise_decorator def test_domain_invitation_email_has_email_as_requestor_staff(self): """Inviting a user sends them an email, with email as the name.""" # Create a fake user object @@ -534,12 +574,11 @@ class TestDomainManagers(TestDomainOverview): mock_client_instance = mock_client.return_value with boto3_mocking.clients.handler_for("sesv2", mock_client): - with less_console_noise(): - add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) - session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - add_page.form["email"] = email_address - self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - add_page.form.submit() + add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + add_page.form["email"] = email_address + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + add_page.form.submit() # check the mock instance to see if `send_email` was called right mock_client_instance.send_email.assert_called_once_with( @@ -561,6 +600,7 @@ class TestDomainManagers(TestDomainOverview): self.assertNotIn("First Last", email_content) @boto3_mocking.patching + @less_console_noise_decorator def test_domain_invitation_email_displays_error_non_existent(self): """Inviting a non existent user sends them an email, with email as the name.""" # make sure there is no user with this email @@ -577,12 +617,11 @@ class TestDomainManagers(TestDomainOverview): mock_error_message = MagicMock() with boto3_mocking.clients.handler_for("sesv2", mock_client): with patch("django.contrib.messages.error") as mock_error_message: - with less_console_noise(): - add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) - session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - add_page.form["email"] = email_address - self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - add_page.form.submit().follow() + add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + add_page.form["email"] = email_address + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + add_page.form.submit().follow() expected_message_content = "Can't send invitation email. No email is associated with your account." @@ -593,6 +632,7 @@ class TestDomainManagers(TestDomainOverview): self.assertEqual(expected_message_content, returned_error_message) @boto3_mocking.patching + @less_console_noise_decorator def test_domain_invitation_email_displays_error(self): """When the requesting user has no email, an error is displayed""" # make sure there is no user with this email @@ -611,12 +651,11 @@ class TestDomainManagers(TestDomainOverview): mock_error_message = MagicMock() with boto3_mocking.clients.handler_for("sesv2", mock_client): with patch("django.contrib.messages.error") as mock_error_message: - with less_console_noise(): - add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) - session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - add_page.form["email"] = email_address - self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - add_page.form.submit().follow() + add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + add_page.form["email"] = email_address + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + add_page.form.submit().follow() expected_message_content = "Can't send invitation email. No email is associated with your account." @@ -626,34 +665,35 @@ class TestDomainManagers(TestDomainOverview): # Check that the message content is what we expect self.assertEqual(expected_message_content, returned_error_message) + @less_console_noise_decorator def test_domain_invitation_cancel(self): """Posting to the delete view deletes an invitation.""" email_address = "mayor@igorville.gov" invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address) mock_client = MockSESClient() with boto3_mocking.clients.handler_for("sesv2", mock_client): - with less_console_noise(): - self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id})) + self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id})) mock_client.EMAILS_SENT.clear() with self.assertRaises(DomainInvitation.DoesNotExist): DomainInvitation.objects.get(id=invitation.id) + @less_console_noise_decorator def test_domain_invitation_cancel_retrieved_invitation(self): """Posting to the delete view when invitation retrieved returns an error message""" email_address = "mayor@igorville.gov" invitation, _ = DomainInvitation.objects.get_or_create( domain=self.domain, email=email_address, status=DomainInvitation.DomainInvitationStatus.RETRIEVED ) - with less_console_noise(): - response = self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id}), follow=True) - # Assert that an error message is displayed to the user - self.assertContains(response, f"Invitation to {email_address} has already been retrieved.") - # Assert that the Cancel link is not displayed - self.assertNotContains(response, "Cancel") + response = self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id}), follow=True) + # Assert that an error message is displayed to the user + self.assertContains(response, f"Invitation to {email_address} has already been retrieved.") + # Assert that the Cancel link is not displayed + self.assertNotContains(response, "Cancel") # Assert that the DomainInvitation is not deleted self.assertTrue(DomainInvitation.objects.filter(id=invitation.id).exists()) DomainInvitation.objects.filter(email=email_address).delete() + @less_console_noise_decorator def test_domain_invitation_cancel_no_permissions(self): """Posting to the delete view as a different user should fail.""" email_address = "mayor@igorville.gov" @@ -664,12 +704,12 @@ class TestDomainManagers(TestDomainOverview): self.client.force_login(other_user) mock_client = MagicMock() with boto3_mocking.clients.handler_for("sesv2", mock_client): - with less_console_noise(): # permission denied makes console errors - result = self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id})) + result = self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id})) self.assertEqual(result.status_code, 403) @boto3_mocking.patching + @less_console_noise_decorator def test_domain_invitation_flow(self): """Send an invitation to a new user, log in and load the dashboard.""" email_address = "mayor@igorville.gov" @@ -685,8 +725,7 @@ class TestDomainManagers(TestDomainOverview): mock_client = MagicMock() with boto3_mocking.clients.handler_for("sesv2", mock_client): - with less_console_noise(): - add_page.form.submit() + add_page.form.submit() # user was invited, create them new_user = User.objects.create(username=email_address, email=email_address) @@ -701,11 +740,13 @@ class TestDomainManagers(TestDomainOverview): class TestDomainNameservers(TestDomainOverview, MockEppLib): + @less_console_noise_decorator def test_domain_nameservers(self): """Can load domain's nameservers page.""" page = self.client.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id})) self.assertContains(page, "DNS name servers") + @less_console_noise_decorator def test_domain_nameservers_form_submit_one_nameserver(self): """Nameserver form submitted with one nameserver throws error. @@ -717,8 +758,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) # attempt to submit the form with only one nameserver, should error # regarding required fields - with less_console_noise(): # swallow log warning message - result = nameservers_page.form.submit() + result = nameservers_page.form.submit() # form submission was a post with an error, response should be a 200 # error text appears twice, once at the top of the page, once around # the required field. form requires a minimum of 2 name servers @@ -729,6 +769,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): status_code=200, ) + @less_console_noise_decorator def test_domain_nameservers_form_submit_subdomain_missing_ip(self): """Nameserver form catches missing ip error on subdomain. @@ -742,8 +783,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): # only one has ips nameservers_page.form["form-1-server"] = "ns2.igorville.gov" - with less_console_noise(): # swallow log warning message - result = nameservers_page.form.submit() + result = nameservers_page.form.submit() # form submission was a post with an error, response should be a 200 # error text appears twice, once at the top of the page, once around # the required field. subdomain missing an ip @@ -754,6 +794,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): status_code=200, ) + @less_console_noise_decorator def test_domain_nameservers_form_submit_missing_host(self): """Nameserver form catches error when host is missing. @@ -766,8 +807,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): # attempt to submit the form without two hosts, both subdomains, # only one has ips nameservers_page.form["form-1-ip"] = "127.0.0.1" - with less_console_noise(): # swallow log warning message - result = nameservers_page.form.submit() + result = nameservers_page.form.submit() # form submission was a post with an error, response should be a 200 # error text appears twice, once at the top of the page, once around # the required field. nameserver has ip but missing host @@ -778,6 +818,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): status_code=200, ) + @less_console_noise_decorator def test_domain_nameservers_form_submit_duplicate_host(self): """Nameserver form catches error when host is duplicated. @@ -790,8 +831,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): # attempt to submit the form with duplicate host names of fake.host.com nameservers_page.form["form-0-ip"] = "" nameservers_page.form["form-1-server"] = "fake.host.com" - with less_console_noise(): # swallow log warning message - result = nameservers_page.form.submit() + result = nameservers_page.form.submit() # form submission was a post with an error, response should be a 200 # error text appears twice, once at the top of the page, once around # the required field. remove duplicate entry @@ -802,6 +842,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): status_code=200, ) + @less_console_noise_decorator def test_domain_nameservers_form_submit_whitespace(self): """Nameserver form removes whitespace from ip. @@ -820,8 +861,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): nameservers_page.form["form-0-ip"] = valid_ip nameservers_page.form["form-1-ip"] = valid_ip_2 nameservers_page.form["form-1-server"] = nameserver2 - with less_console_noise(): # swallow log warning message - result = nameservers_page.form.submit() + result = nameservers_page.form.submit() # form submission was a post with an ip address which has been stripped of whitespace, # response should be a 302 to success page self.assertEqual(result.status_code, 302) @@ -835,6 +875,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): # with an error message displayed, so need to follow 302 and test for success message self.assertContains(page, "The name servers for this domain have been updated") + @less_console_noise_decorator def test_domain_nameservers_form_submit_glue_record_not_allowed(self): """Nameserver form catches error when IP is present but host not subdomain. @@ -853,8 +894,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): nameservers_page.form["form-0-server"] = nameserver1 nameservers_page.form["form-1-server"] = nameserver2 nameservers_page.form["form-1-ip"] = valid_ip - with less_console_noise(): # swallow log warning message - result = nameservers_page.form.submit() + result = nameservers_page.form.submit() # form submission was a post with an error, response should be a 200 # error text appears twice, once at the top of the page, once around # the required field. nameserver has ip but missing host @@ -865,6 +905,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): status_code=200, ) + @less_console_noise_decorator def test_domain_nameservers_form_submit_invalid_ip(self): """Nameserver form catches invalid IP on submission. @@ -880,8 +921,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): # only one has ips nameservers_page.form["form-1-server"] = nameserver nameservers_page.form["form-1-ip"] = invalid_ip - with less_console_noise(): # swallow log warning message - result = nameservers_page.form.submit() + result = nameservers_page.form.submit() # form submission was a post with an error, response should be a 200 # error text appears twice, once at the top of the page, once around # the required field. nameserver has ip but missing host @@ -892,6 +932,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): status_code=200, ) + @less_console_noise_decorator def test_domain_nameservers_form_submit_invalid_host(self): """Nameserver form catches invalid host on submission. @@ -907,8 +948,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): # only one has ips nameservers_page.form["form-1-server"] = nameserver nameservers_page.form["form-1-ip"] = valid_ip - with less_console_noise(): # swallow log warning message - result = nameservers_page.form.submit() + result = nameservers_page.form.submit() # form submission was a post with an error, response should be a 200 # error text appears twice, once at the top of the page, once around # the required field. nameserver has invalid host @@ -919,6 +959,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): status_code=200, ) + @less_console_noise_decorator def test_domain_nameservers_form_submits_successfully(self): """Nameserver form submits successfully with valid input. @@ -935,8 +976,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): nameservers_page.form["form-0-ip"] = valid_ip nameservers_page.form["form-1-server"] = nameserver2 nameservers_page.form["form-1-ip"] = valid_ip_2 - with less_console_noise(): # swallow log warning message - result = nameservers_page.form.submit() + result = nameservers_page.form.submit() # form submission was a successful post, response should be a 302 self.assertEqual(result.status_code, 302) self.assertEqual( @@ -947,6 +987,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): page = result.follow() self.assertContains(page, "The name servers for this domain have been updated") + @less_console_noise_decorator def test_domain_nameservers_can_blank_out_first_or_second_one_if_enough_entries(self): """Nameserver form submits successfully with 2 valid inputs, even if the first or second entries are blanked out. @@ -969,8 +1010,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): nameservers_page.form["form-1-ip"] = valid_ip_2 nameservers_page.form["form-2-server"] = nameserver3 nameservers_page.form["form-2-ip"] = valid_ip_3 - with less_console_noise(): # swallow log warning message - result = nameservers_page.form.submit() + result = nameservers_page.form.submit() # form submission was a successful post, response should be a 302 self.assertEqual(result.status_code, 302) @@ -996,8 +1036,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): nameservers_page.form["form-1-ip"] = valid_ip_2 nameservers_page.form["form-2-server"] = nameserver3 nameservers_page.form["form-2-ip"] = valid_ip_3 - with less_console_noise(): # swallow log warning message - result = nameservers_page.form.submit() + result = nameservers_page.form.submit() # form submission was a successful post, response should be a 302 self.assertEqual(result.status_code, 302) @@ -1009,6 +1048,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): nameservers_page = result.follow() self.assertContains(nameservers_page, "The name servers for this domain have been updated") + @less_console_noise_decorator def test_domain_nameservers_can_blank_out_first_and_second_one_if_enough_entries(self): """Nameserver form submits successfully with 2 valid inputs, even if the first and second entries are blanked out. @@ -1045,8 +1085,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): nameservers_page.form["form-2-ip"] = valid_ip_3 nameservers_page.form["form-3-server"] = nameserver4 nameservers_page.form["form-3-ip"] = valid_ip_4 - with less_console_noise(): # swallow log warning message - result = nameservers_page.form.submit() + result = nameservers_page.form.submit() # form submission was a successful post, response should be a 302 self.assertEqual(result.status_code, 302) @@ -1058,6 +1097,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): nameservers_page = result.follow() self.assertContains(nameservers_page, "The name servers for this domain have been updated") + @less_console_noise_decorator def test_domain_nameservers_form_invalid(self): """Nameserver form does not submit with invalid data. @@ -1069,8 +1109,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): # first two nameservers are required, so if we empty one out we should # get a form error nameservers_page.form["form-0-server"] = "" - with less_console_noise(): # swallow logged warning message - result = nameservers_page.form.submit() + result = nameservers_page.form.submit() # form submission was a post with an error, response should be a 200 # error text appears four times, twice at the top of the page, # once around each required field. @@ -1083,11 +1122,13 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib): class TestDomainSeniorOfficial(TestDomainOverview): + @less_console_noise_decorator def test_domain_senior_official(self): """Can load domain's senior official page.""" page = self.client.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id})) self.assertContains(page, "Senior official", count=13) + @less_console_noise_decorator def test_domain_senior_official_content(self): """Senior official information appears on the page.""" self.domain_information.senior_official = Contact(first_name="Testy") @@ -1096,6 +1137,7 @@ class TestDomainSeniorOfficial(TestDomainOverview): page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id})) self.assertContains(page, "Testy") + @less_console_noise_decorator def test_domain_edit_senior_official_in_place(self): """When editing a senior official for domain information and SO is not joined to any other objects""" @@ -1120,6 +1162,7 @@ class TestDomainSeniorOfficial(TestDomainOverview): self.assertEqual("Testy2", self.domain_information.senior_official.first_name) self.assertEqual(so_pk, self.domain_information.senior_official.id) + @less_console_noise_decorator def assert_all_form_fields_have_expected_values(self, form, test_cases, test_for_disabled=False): """ Asserts that each specified form field has the expected value and, optionally, checks if the field is disabled. @@ -1146,6 +1189,7 @@ class TestDomainSeniorOfficial(TestDomainOverview): # Test for disabled on each field self.assertTrue("disabled" in form[field_name].attrs) + @less_console_noise_decorator def test_domain_edit_senior_official_federal(self): """Tests that no edit can occur when the underlying domain is federal""" @@ -1202,6 +1246,7 @@ class TestDomainSeniorOfficial(TestDomainOverview): self.assertEqual("CIO", self.domain_information.senior_official.title) self.assertEqual("nobody@igorville.gov", self.domain_information.senior_official.email) + @less_console_noise_decorator def test_domain_edit_senior_official_tribal(self): """Tests that no edit can occur when the underlying domain is tribal""" @@ -1258,6 +1303,7 @@ class TestDomainSeniorOfficial(TestDomainOverview): self.assertEqual("CIO", self.domain_information.senior_official.title) self.assertEqual("nobody@igorville.gov", self.domain_information.senior_official.email) + @less_console_noise_decorator def test_domain_edit_senior_official_creates_new(self): """When editing a senior official for domain information and SO IS joined to another object""" @@ -1295,12 +1341,14 @@ class TestDomainSeniorOfficial(TestDomainOverview): class TestDomainOrganization(TestDomainOverview): + @less_console_noise_decorator def test_domain_org_name_address(self): """Can load domain's org name and mailing address page.""" page = self.client.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id})) # once on the sidebar, once in the page title, once as H1 self.assertContains(page, "Organization name and mailing address", count=3) + @less_console_noise_decorator def test_domain_org_name_address_content(self): """Org name and address information appears on the page.""" self.domain_information.organization_name = "Town of Igorville" @@ -1308,6 +1356,7 @@ class TestDomainOrganization(TestDomainOverview): page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id})) self.assertContains(page, "Town of Igorville") + @less_console_noise_decorator def test_domain_org_name_address_form(self): """Submitting changes works on the org name address page.""" self.domain_information.organization_name = "Town of Igorville" @@ -1325,6 +1374,7 @@ class TestDomainOrganization(TestDomainOverview): self.assertContains(success_result_page, "Not igorville") self.assertContains(success_result_page, "Faketown") + @less_console_noise_decorator def test_domain_org_name_address_form_tribal(self): """ Submitting a change to organization_name is blocked for tribal domains @@ -1382,6 +1432,7 @@ class TestDomainOrganization(TestDomainOverview): # Check for the value we want to update self.assertContains(success_result_page, "Faketown") + @less_console_noise_decorator def test_domain_org_name_address_form_federal(self): """ Submitting a change to federal_agency is blocked for federal domains @@ -1437,6 +1488,7 @@ class TestDomainOrganization(TestDomainOverview): # Check for the value we want to update self.assertContains(success_result_page, "Faketown") + @less_console_noise_decorator def test_federal_agency_submit_blocked(self): """ Submitting a change to federal_agency is blocked for federal domains @@ -1470,11 +1522,13 @@ class TestDomainOrganization(TestDomainOverview): class TestDomainContactInformation(TestDomainOverview): + @less_console_noise_decorator def test_domain_your_contact_information(self): """Can load domain's your contact information page.""" page = self.client.get(reverse("domain-your-contact-information", kwargs={"pk": self.domain.id})) self.assertContains(page, "Your contact information") + @less_console_noise_decorator def test_domain_your_contact_information_content(self): """Logged-in user's contact information appears on the page.""" self.user.first_name = "Testy" @@ -1602,20 +1656,21 @@ class TestDomainSecurityEmail(TestDomainOverview): self.assertEqual(message.tags, message_tag) self.assertEqual(message.message.strip(), expected_message.strip()) + @less_console_noise_decorator def test_domain_overview_blocked_for_ineligible_user(self): """We could easily duplicate this test for all domain management views, but a single url test should be solid enough since all domain management pages share the same permissions class""" self.user.status = User.RESTRICTED self.user.save() - with less_console_noise(): - response = self.client.get(reverse("domain", kwargs={"pk": self.domain.id})) - self.assertEqual(response.status_code, 403) + response = self.client.get(reverse("domain", kwargs={"pk": self.domain.id})) + self.assertEqual(response.status_code, 403) class TestDomainDNSSEC(TestDomainOverview): """MockEPPLib is already inherited.""" + @less_console_noise_decorator def test_dnssec_page_refreshes_enable_button(self): """DNSSEC overview page loads when domain has no DNSSEC data and shows a 'Enable DNSSEC' button.""" @@ -1623,6 +1678,7 @@ class TestDomainDNSSEC(TestDomainOverview): page = self.client.get(reverse("domain-dns-dnssec", kwargs={"pk": self.domain.id})) self.assertContains(page, "Enable DNSSEC") + @less_console_noise_decorator def test_dnssec_page_loads_with_data_in_domain(self): """DNSSEC overview page loads when domain has DNSSEC data and the template contains a button to disable DNSSEC.""" @@ -1644,6 +1700,7 @@ class TestDomainDNSSEC(TestDomainOverview): self.assertContains(updated_page, "Enable DNSSEC") + @less_console_noise_decorator def test_ds_form_loads_with_no_domain_data(self): """DNSSEC Add DS data page loads when there is no domain DNSSEC data and shows a button to Add new record""" @@ -1652,6 +1709,7 @@ class TestDomainDNSSEC(TestDomainOverview): self.assertContains(page, "You have no DS data added") self.assertContains(page, "Add new record") + @less_console_noise_decorator def test_ds_form_loads_with_ds_data(self): """DNSSEC Add DS data page loads when there is domain DNSSEC DS data and shows the data""" @@ -1659,6 +1717,7 @@ class TestDomainDNSSEC(TestDomainOverview): page = self.client.get(reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dsdata.id})) self.assertContains(page, "DS data record 1") + @less_console_noise_decorator def test_ds_data_form_modal(self): """When user clicks on save, a modal pops up.""" add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dsdata.id})) @@ -1677,6 +1736,7 @@ class TestDomainDNSSEC(TestDomainOverview): # Now check to see whether the JS trigger for the modal is present on the page self.assertContains(response, "Trigger Disable DNSSEC Modal") + @less_console_noise_decorator def test_ds_data_form_submits(self): """DS data form submits successfully @@ -1685,8 +1745,7 @@ class TestDomainDNSSEC(TestDomainOverview): add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dsdata.id})) session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - with less_console_noise(): # swallow log warning message - result = add_data_page.forms[0].submit() + result = add_data_page.forms[0].submit() # form submission was a post, response should be a redirect self.assertEqual(result.status_code, 302) self.assertEqual( @@ -1697,6 +1756,7 @@ class TestDomainDNSSEC(TestDomainOverview): page = result.follow() self.assertContains(page, "The DS data records for this domain have been updated.") + @less_console_noise_decorator def test_ds_data_form_invalid(self): """DS data form errors with invalid data (missing required fields) @@ -1710,8 +1770,7 @@ class TestDomainDNSSEC(TestDomainOverview): add_data_page.forms[0]["form-0-algorithm"] = "" add_data_page.forms[0]["form-0-digest_type"] = "" add_data_page.forms[0]["form-0-digest"] = "" - with less_console_noise(): # swallow logged warning message - result = add_data_page.forms[0].submit() + result = add_data_page.forms[0].submit() # form submission was a post with an error, response should be a 200 # error text appears twice, once at the top of the page, once around # the field. @@ -1720,6 +1779,7 @@ class TestDomainDNSSEC(TestDomainOverview): self.assertContains(result, "Digest type is required", count=2, status_code=200) self.assertContains(result, "Digest is required", count=2, status_code=200) + @less_console_noise_decorator def test_ds_data_form_invalid_keytag(self): """DS data form errors with invalid data (key tag too large) @@ -1734,8 +1794,7 @@ class TestDomainDNSSEC(TestDomainOverview): add_data_page.forms[0]["form-0-algorithm"] = "" add_data_page.forms[0]["form-0-digest_type"] = "" add_data_page.forms[0]["form-0-digest"] = "" - with less_console_noise(): # swallow logged warning message - result = add_data_page.forms[0].submit() + result = add_data_page.forms[0].submit() # form submission was a post with an error, response should be a 200 # error text appears twice, once at the top of the page, once around # the field. @@ -1743,6 +1802,7 @@ class TestDomainDNSSEC(TestDomainOverview): result, str(DsDataError(code=DsDataErrorCodes.INVALID_KEYTAG_SIZE)), count=2, status_code=200 ) + @less_console_noise_decorator def test_ds_data_form_invalid_digest_chars(self): """DS data form errors with invalid data (digest contains non hexadecimal chars) @@ -1757,8 +1817,7 @@ class TestDomainDNSSEC(TestDomainOverview): add_data_page.forms[0]["form-0-algorithm"] = "3" add_data_page.forms[0]["form-0-digest_type"] = "1" add_data_page.forms[0]["form-0-digest"] = "GG1234" - with less_console_noise(): # swallow logged warning message - result = add_data_page.forms[0].submit() + result = add_data_page.forms[0].submit() # form submission was a post with an error, response should be a 200 # error text appears twice, once at the top of the page, once around # the field. @@ -1766,6 +1825,7 @@ class TestDomainDNSSEC(TestDomainOverview): result, str(DsDataError(code=DsDataErrorCodes.INVALID_DIGEST_CHARS)), count=2, status_code=200 ) + @less_console_noise_decorator def test_ds_data_form_invalid_digest_sha1(self): """DS data form errors with invalid data (digest is invalid sha-1) @@ -1780,8 +1840,7 @@ class TestDomainDNSSEC(TestDomainOverview): add_data_page.forms[0]["form-0-algorithm"] = "3" add_data_page.forms[0]["form-0-digest_type"] = "1" # SHA-1 add_data_page.forms[0]["form-0-digest"] = "A123" - with less_console_noise(): # swallow logged warning message - result = add_data_page.forms[0].submit() + result = add_data_page.forms[0].submit() # form submission was a post with an error, response should be a 200 # error text appears twice, once at the top of the page, once around # the field. @@ -1789,6 +1848,7 @@ class TestDomainDNSSEC(TestDomainOverview): result, str(DsDataError(code=DsDataErrorCodes.INVALID_DIGEST_SHA1)), count=2, status_code=200 ) + @less_console_noise_decorator def test_ds_data_form_invalid_digest_sha256(self): """DS data form errors with invalid data (digest is invalid sha-256) @@ -1803,8 +1863,7 @@ class TestDomainDNSSEC(TestDomainOverview): add_data_page.forms[0]["form-0-algorithm"] = "3" add_data_page.forms[0]["form-0-digest_type"] = "2" # SHA-256 add_data_page.forms[0]["form-0-digest"] = "GG1234" - with less_console_noise(): # swallow logged warning message - result = add_data_page.forms[0].submit() + result = add_data_page.forms[0].submit() # form submission was a post with an error, response should be a 200 # error text appears twice, once at the top of the page, once around # the field. diff --git a/src/registrar/tests/test_views_domains_json.py b/src/registrar/tests/test_views_domains_json.py index 28a7308f5..70ae23b43 100644 --- a/src/registrar/tests/test_views_domains_json.py +++ b/src/registrar/tests/test_views_domains_json.py @@ -1,4 +1,4 @@ -from registrar.models import UserDomainRole, Domain +from registrar.models import UserDomainRole, Domain, DomainInformation, Portfolio from django.urls import reverse from .test_views import TestWithUser from django_webtest import WebTest # type: ignore @@ -15,16 +15,25 @@ class GetDomainsJsonTest(TestWithUser, WebTest): self.domain1 = Domain.objects.create(name="example1.com", expiration_date="2024-01-01", state="unknown") self.domain2 = Domain.objects.create(name="example2.com", expiration_date="2024-02-01", state="dns needed") self.domain3 = Domain.objects.create(name="example3.com", expiration_date="2024-03-01", state="ready") + self.domain4 = Domain.objects.create(name="example4.com", expiration_date="2024-03-01", state="ready") # Create UserDomainRoles UserDomainRole.objects.create(user=self.user, domain=self.domain1) UserDomainRole.objects.create(user=self.user, domain=self.domain2) UserDomainRole.objects.create(user=self.user, domain=self.domain3) + # Create Portfolio + self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Example org") + + # Add domain3 and domain4 to portfolio + DomainInformation.objects.create(creator=self.user, domain=self.domain3, portfolio=self.portfolio) + DomainInformation.objects.create(creator=self.user, domain=self.domain4, portfolio=self.portfolio) + def tearDown(self): + UserDomainRole.objects.all().delete() + DomainInformation.objects.all().delete() + Portfolio.objects.all().delete() super().tearDown() - UserDomainRole.objects.all().delete() - UserDomainRole.objects.all().delete() @less_console_noise_decorator def test_get_domains_json_unauthenticated(self): @@ -105,6 +114,83 @@ class GetDomainsJsonTest(TestWithUser, WebTest): ) self.assertEqual(svg_icon_expected, svg_icons[i]) + @less_console_noise_decorator + def test_get_domains_json_with_portfolio(self): + """Test that an authenticated user gets the list of 2 domains for portfolio.""" + + response = self.app.get(reverse("get_domains_json"), {"portfolio": self.portfolio.id}) + self.assertEqual(response.status_code, 200) + data = response.json + + # Check pagination info + self.assertEqual(data["page"], 1) + self.assertFalse(data["has_next"]) + self.assertFalse(data["has_previous"]) + self.assertEqual(data["num_pages"], 1) + + # Check the number of domains + self.assertEqual(len(data["domains"]), 2) + + # Expected domains + expected_domains = [self.domain3, self.domain4] + + # Extract fields from response + domain_ids = [domain["id"] for domain in data["domains"]] + names = [domain["name"] for domain in data["domains"]] + expiration_dates = [domain["expiration_date"] for domain in data["domains"]] + states = [domain["state"] for domain in data["domains"]] + state_displays = [domain["state_display"] for domain in data["domains"]] + get_state_help_texts = [domain["get_state_help_text"] for domain in data["domains"]] + action_urls = [domain["action_url"] for domain in data["domains"]] + action_labels = [domain["action_label"] for domain in data["domains"]] + svg_icons = [domain["svg_icon"] for domain in data["domains"]] + + # Check fields for each domain + for i, expected_domain in enumerate(expected_domains): + self.assertEqual(expected_domain.id, domain_ids[i]) + self.assertEqual(expected_domain.name, names[i]) + self.assertEqual(expected_domain.expiration_date, expiration_dates[i]) + self.assertEqual(expected_domain.state, states[i]) + + # Parsing the expiration date from string to date + parsed_expiration_date = parse_date(expiration_dates[i]) + expected_domain.expiration_date = parsed_expiration_date + + # Check state_display and get_state_help_text + self.assertEqual(expected_domain.state_display(), state_displays[i]) + self.assertEqual(expected_domain.get_state_help_text(), get_state_help_texts[i]) + + self.assertEqual(reverse("domain", kwargs={"pk": expected_domain.id}), action_urls[i]) + + # Check action_label + user_domain_role_exists = UserDomainRole.objects.filter( + domain_id=expected_domains[i].id, user=self.user + ).exists() + action_label_expected = ( + "View" + if not user_domain_role_exists + or expected_domains[i].state + in [ + Domain.State.DELETED, + Domain.State.ON_HOLD, + ] + else "Manage" + ) + self.assertEqual(action_label_expected, action_labels[i]) + + # Check svg_icon + svg_icon_expected = ( + "visibility" + if not user_domain_role_exists + or expected_domains[i].state + in [ + Domain.State.DELETED, + Domain.State.ON_HOLD, + ] + else "settings" + ) + self.assertEqual(svg_icon_expected, svg_icons[i]) + @less_console_noise_decorator def test_get_domains_json_search(self): """Test search.""" diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index e0fae5332..f72130d94 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -1,5 +1,6 @@ from django.urls import reverse from api.tests.common import less_console_noise_decorator +from registrar.config import settings from registrar.models.portfolio import Portfolio from django_webtest import WebTest # type: ignore from registrar.models import ( @@ -9,7 +10,8 @@ from registrar.models import ( UserDomainRole, User, ) -from .test_views import TestWithUser +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices +from .common import create_test_user from waffle.testutils import override_flag import logging @@ -17,15 +19,25 @@ import logging logger = logging.getLogger(__name__) -class TestPortfolioViews(TestWithUser, WebTest): +class TestPortfolio(WebTest): def setUp(self): super().setUp() + self.user = create_test_user() self.domain, _ = Domain.objects.get_or_create(name="igorville.gov") self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California") self.role, _ = UserDomainRole.objects.get_or_create( user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER ) + def tearDown(self): + Portfolio.objects.all().delete() + UserDomainRole.objects.all().delete() + DomainRequest.objects.all().delete() + DomainInformation.objects.all().delete() + Domain.objects.all().delete() + User.objects.all().delete() + super().tearDown() + @less_console_noise_decorator def test_middleware_does_not_redirect_if_no_permission(self): """Test that user with no portfolio permission is not redirected when attempting to access home""" @@ -44,7 +56,7 @@ class TestPortfolioViews(TestWithUser, WebTest): def test_middleware_does_not_redirect_if_no_portfolio(self): """Test that user with no assigned portfolio is not redirected when attempting to access home""" self.app.set_user(self.user.username) - self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO] + self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO] self.user.save() self.user.refresh_from_db() with override_flag("organization_feature", active=True): @@ -56,10 +68,10 @@ class TestPortfolioViews(TestWithUser, WebTest): @less_console_noise_decorator def test_middleware_redirects_to_portfolio_organization_page(self): - """Test that user with VIEW_PORTFOLIO is redirected to portfolio organization page""" + """Test that user with a portfolio and VIEW_PORTFOLIO is redirected to portfolio organization page""" self.app.set_user(self.user.username) self.user.portfolio = self.portfolio - self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO] + self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO] self.user.save() self.user.refresh_from_db() with override_flag("organization_feature", active=True): @@ -72,12 +84,13 @@ class TestPortfolioViews(TestWithUser, WebTest): @less_console_noise_decorator def test_middleware_redirects_to_portfolio_domains_page(self): - """Test that user with VIEW_PORTFOLIO and VIEW_ALL_DOMAINS is redirected to portfolio domains page""" + """Test that user with a portfolio, VIEW_PORTFOLIO, VIEW_ALL_DOMAINS + is redirected to portfolio domains page""" self.app.set_user(self.user.username) self.user.portfolio = self.portfolio self.user.portfolio_additional_permissions = [ - User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO, - User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, ] self.user.save() self.user.refresh_from_db() @@ -100,9 +113,7 @@ class TestPortfolioViews(TestWithUser, WebTest): with override_flag("organization_feature", active=True): # This will redirect the user to the portfolio page. # Follow implicity checks if our redirect is working. - response = self.app.get( - reverse("portfolio-domains", kwargs={"portfolio_id": self.portfolio.pk}), status=403 - ) + response = self.app.get(reverse("domains"), status=403) # Assert the response is a 403 Forbidden self.assertEqual(response.status_code, 403) @@ -116,9 +127,7 @@ class TestPortfolioViews(TestWithUser, WebTest): with override_flag("organization_feature", active=True): # This will redirect the user to the portfolio page. # Follow implicity checks if our redirect is working. - response = self.app.get( - reverse("portfolio-domain-requests", kwargs={"portfolio_id": self.portfolio.pk}), status=403 - ) + response = self.app.get(reverse("domain-requests"), status=403) # Assert the response is a 403 Forbidden self.assertEqual(response.status_code, 403) @@ -132,21 +141,64 @@ class TestPortfolioViews(TestWithUser, WebTest): with override_flag("organization_feature", active=True): # This will redirect the user to the portfolio page. # Follow implicity checks if our redirect is working. - response = self.app.get( - reverse("portfolio-organization", kwargs={"portfolio_id": self.portfolio.pk}), status=403 - ) + response = self.app.get(reverse("organization"), status=403) # Assert the response is a 403 Forbidden self.assertEqual(response.status_code, 403) + @less_console_noise_decorator + def test_portfolio_organization_page_read_only(self): + """Test that user with a portfolio can access the portfolio organization page, read only""" + self.app.set_user(self.user.username) + self.user.portfolio = self.portfolio + self.portfolio.city = "Los Angeles" + self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO] + self.portfolio.save() + self.user.save() + self.user.refresh_from_db() + with override_flag("organization_feature", active=True): + response = self.app.get(reverse("organization")) + # Assert the response is a 200 + self.assertEqual(response.status_code, 200) + # The label for Federal agency will always be a h4 + self.assertContains(response, '

    Federal agency

    ') + # The read only label for city will be a h4 + self.assertContains(response, '

    City

    ') + self.assertNotContains(response, 'for="id_city"') + self.assertContains(response, '

    Los Angeles

    ') + + @less_console_noise_decorator + def test_portfolio_organization_page_edit_access(self): + """Test that user with a portfolio can access the portfolio organization page, read only""" + self.app.set_user(self.user.username) + self.user.portfolio = self.portfolio + self.user.portfolio_additional_permissions = [ + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.EDIT_PORTFOLIO, + ] + self.portfolio.city = "Los Angeles" + self.portfolio.save() + self.user.save() + self.user.refresh_from_db() + with override_flag("organization_feature", active=True): + response = self.app.get(reverse("organization")) + # Assert the response is a 200 + self.assertEqual(response.status_code, 200) + # The label for Federal agency will always be a h4 + self.assertContains(response, '

    Federal agency

    ') + # The read only label for city will be a h4 + self.assertNotContains(response, '

    City

    ') + self.assertNotContains(response, '

    Los Angeles

    >') + self.assertContains(response, 'for="id_city"') + @less_console_noise_decorator def test_navigation_links_hidden_when_user_not_have_permission(self): """Test that navigation links are hidden when user does not have portfolio permissions""" self.app.set_user(self.user.username) self.user.portfolio = self.portfolio self.user.portfolio_additional_permissions = [ - User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO, - User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, - User.UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, ] self.user.save() self.user.refresh_from_db() @@ -158,16 +210,12 @@ class TestPortfolioViews(TestWithUser, WebTest): self.assertContains(portfolio_page, self.portfolio.organization_name) self.assertNotContains(portfolio_page, "

    Organization

    ") self.assertContains(portfolio_page, '

    Domains

    ') - self.assertContains( - portfolio_page, reverse("portfolio-domains", kwargs={"portfolio_id": self.portfolio.pk}) - ) - self.assertContains( - portfolio_page, reverse("portfolio-domain-requests", kwargs={"portfolio_id": self.portfolio.pk}) - ) + self.assertContains(portfolio_page, reverse("domains")) + self.assertContains(portfolio_page, reverse("domain-requests")) - # reducing portfolio permissions to just VIEW_PORTFOLIO, which should remove domains + # removing non-basic portfolio perms, which should remove domains # and domain requests from nav - self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO] + self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO] self.user.save() self.user.refresh_from_db() @@ -176,17 +224,104 @@ class TestPortfolioViews(TestWithUser, WebTest): self.assertContains(portfolio_page, self.portfolio.organization_name) self.assertContains(portfolio_page, "

    Organization

    ") self.assertNotContains(portfolio_page, '

    Domains

    ') - self.assertNotContains( - portfolio_page, reverse("portfolio-domains", kwargs={"portfolio_id": self.portfolio.pk}) - ) - self.assertNotContains( - portfolio_page, reverse("portfolio-domain-requests", kwargs={"portfolio_id": self.portfolio.pk}) + self.assertNotContains(portfolio_page, reverse("domains")) + self.assertNotContains(portfolio_page, reverse("domain-requests")) + + @less_console_noise_decorator + def test_navigation_links_hidden_when_user_not_have_role(self): + """Test that admin / memmber roles are associated with the right access""" + self.app.set_user(self.user.username) + self.user.portfolio = self.portfolio + self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + self.user.save() + self.user.refresh_from_db() + with override_flag("organization_feature", active=True): + # This will redirect the user to the portfolio page. + # Follow implicity checks if our redirect is working. + portfolio_page = self.app.get(reverse("home")).follow() + # Assert that we're on the right page + self.assertContains(portfolio_page, self.portfolio.organization_name) + self.assertNotContains(portfolio_page, "

    Organization

    ") + self.assertContains(portfolio_page, '

    Domains

    ') + self.assertContains(portfolio_page, reverse("domains")) + self.assertContains(portfolio_page, reverse("domain-requests")) + + # removing non-basic portfolio role, which should remove domains + # and domain requests from nav + self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_MEMBER] + self.user.save() + self.user.refresh_from_db() + + portfolio_page = self.app.get(reverse("home")).follow() + + self.assertContains(portfolio_page, self.portfolio.organization_name) + self.assertContains(portfolio_page, "

    Organization

    ") + self.assertNotContains(portfolio_page, '

    Domains

    ') + self.assertNotContains(portfolio_page, reverse("domains")) + self.assertNotContains(portfolio_page, reverse("domain-requests")) + + @less_console_noise_decorator + def test_portfolio_org_name(self): + """Can load portfolio's org name page.""" + with override_flag("organization_feature", active=True): + self.app.set_user(self.user.username) + self.user.portfolio = self.portfolio + self.user.portfolio_additional_permissions = [ + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.EDIT_PORTFOLIO, + ] + self.user.save() + self.user.refresh_from_db() + + page = self.app.get(reverse("organization")) + self.assertContains( + page, "The name of your federal agency will be publicly listed as the domain registrant." ) - def tearDown(self): - Portfolio.objects.all().delete() - UserDomainRole.objects.all().delete() - DomainRequest.objects.all().delete() - DomainInformation.objects.all().delete() - Domain.objects.all().delete() - super().tearDown() + @less_console_noise_decorator + def test_domain_org_name_address_content(self): + """Org name and address information appears on the page.""" + with override_flag("organization_feature", active=True): + self.app.set_user(self.user.username) + self.user.portfolio = self.portfolio + self.user.portfolio_additional_permissions = [ + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.EDIT_PORTFOLIO, + ] + self.user.save() + self.user.refresh_from_db() + + self.portfolio.organization_name = "Hotel California" + self.portfolio.save() + page = self.app.get(reverse("organization")) + # Once in the sidenav, once in the main nav + self.assertContains(page, "Hotel California", count=2) + self.assertContains(page, "Non-Federal Agency") + + @less_console_noise_decorator + def test_domain_org_name_address_form(self): + """Submitting changes works on the org name address page.""" + with override_flag("organization_feature", active=True): + self.app.set_user(self.user.username) + self.user.portfolio = self.portfolio + self.user.portfolio_additional_permissions = [ + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.EDIT_PORTFOLIO, + ] + self.user.save() + self.user.refresh_from_db() + + self.portfolio.address_line1 = "1600 Penn Ave" + self.portfolio.save() + portfolio_org_name_page = self.app.get(reverse("organization")) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + + portfolio_org_name_page.form["address_line1"] = "6 Downing st" + portfolio_org_name_page.form["city"] = "London" + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + success_result_page = portfolio_org_name_page.form.submit() + self.assertEqual(success_result_page.status_code, 200) + + self.assertContains(success_result_page, "6 Downing st") + self.assertContains(success_result_page, "London") diff --git a/src/registrar/tests/test_views_request.py b/src/registrar/tests/test_views_request.py index de924576b..0cee9d563 100644 --- a/src/registrar/tests/test_views_request.py +++ b/src/registrar/tests/test_views_request.py @@ -3,7 +3,7 @@ from unittest.mock import Mock from django.conf import settings from django.urls import reverse - +from api.tests.common import less_console_noise_decorator from .common import MockSESClient, completed_domain_request # type: ignore from django_webtest import WebTest # type: ignore import boto3_mocking # type: ignore @@ -37,14 +37,23 @@ class DomainRequestTests(TestWithUser, WebTest): def setUp(self): super().setUp() + self.federal_agency, _ = FederalAgency.objects.get_or_create(agency="General Services Administration") self.app.set_user(self.user.username) self.TITLES = DomainRequestWizard.TITLES + def tearDown(self): + super().tearDown() + DomainRequest.objects.all().delete() + DomainInformation.objects.all().delete() + self.federal_agency.delete() + + @less_console_noise_decorator def test_domain_request_form_intro_acknowledgement(self): """Tests that user is presented with intro acknowledgement page""" intro_page = self.app.get(reverse("domain-request:")) self.assertContains(intro_page, "You’re about to start your .gov domain request") + @less_console_noise_decorator def test_domain_request_form_intro_is_skipped_when_edit_access(self): """Tests that user is NOT presented with intro acknowledgement page when accessed through 'edit'""" domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.STARTED, user=self.user) @@ -55,6 +64,7 @@ class DomainRequestTests(TestWithUser, WebTest): redirect_url = detail_page.url self.assertEqual(redirect_url, "/request/generic_org_type/") + @less_console_noise_decorator def test_domain_request_form_empty_submit(self): """Tests empty submit on the first page after the acknowledgement page""" intro_page = self.app.get(reverse("domain-request:")) @@ -77,31 +87,31 @@ class DomainRequestTests(TestWithUser, WebTest): result = type_page.forms[0].submit() self.assertIn("What kind of U.S.-based government organization do you represent?", result) + @less_console_noise_decorator def test_domain_request_multiple_domain_requests_exist(self): """Test that an info message appears when user has multiple domain requests already""" # create and submit a domain request domain_request = completed_domain_request(user=self.user) mock_client = MockSESClient() with boto3_mocking.clients.handler_for("sesv2", mock_client): - with less_console_noise(): - domain_request.submit() - domain_request.save() + domain_request.submit() + domain_request.save() # now, attempt to create another one - with less_console_noise(): - intro_page = self.app.get(reverse("domain-request:")) - session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - intro_form = intro_page.forms[0] - self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - intro_result = intro_form.submit() + intro_page = self.app.get(reverse("domain-request:")) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + intro_form = intro_page.forms[0] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + intro_result = intro_form.submit() - # follow first redirect - self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - type_page = intro_result.follow() - session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + # follow first redirect + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + type_page = intro_result.follow() + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - self.assertContains(type_page, "You cannot submit this request yet") + self.assertContains(type_page, "You cannot submit this request yet") + @less_console_noise_decorator def test_domain_request_into_acknowledgement_creates_new_request(self): """ We had to solve a bug where the wizard was creating 2 requests on first intro acknowledgement ('continue') @@ -155,6 +165,7 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertEqual(domain_request_count, 2) @boto3_mocking.patching + @less_console_noise_decorator def test_domain_request_form_submission(self): """ Can fill out the entire form and submit. @@ -227,9 +238,7 @@ class DomainRequestTests(TestWithUser, WebTest): self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) org_contact_page = federal_result.follow() org_contact_form = org_contact_page.forms[0] - # federal agency so we have to fill in federal_agency - federal_agency, _ = FederalAgency.objects.get_or_create(agency="General Services Administration") - org_contact_form["organization_contact-federal_agency"] = federal_agency.id + org_contact_form["organization_contact-federal_agency"] = self.federal_agency.id org_contact_form["organization_contact-organization_name"] = "Testorg" org_contact_form["organization_contact-address_line1"] = "address 1" org_contact_form["organization_contact-address_line2"] = "address 2" @@ -524,6 +533,7 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertEqual(num_pages, num_pages_tested) @boto3_mocking.patching + @less_console_noise_decorator def test_domain_request_form_submission_incomplete(self): num_pages_tested = 0 # skipping elections, type_of_work, tribal_government @@ -584,9 +594,7 @@ class DomainRequestTests(TestWithUser, WebTest): self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) org_contact_page = federal_result.follow() org_contact_form = org_contact_page.forms[0] - # federal agency so we have to fill in federal_agency - federal_agency, _ = FederalAgency.objects.get_or_create(agency="General Services Administration") - org_contact_form["organization_contact-federal_agency"] = federal_agency.id + org_contact_form["organization_contact-federal_agency"] = self.federal_agency.id org_contact_form["organization_contact-organization_name"] = "Testorg" org_contact_form["organization_contact-address_line1"] = "address 1" org_contact_form["organization_contact-address_line2"] = "address 2" @@ -879,6 +887,7 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertEqual(num_pages, num_pages_tested) + @less_console_noise_decorator def test_domain_request_form_conditional_federal(self): """Federal branch question is shown for federal organizations.""" intro_page = self.app.get(reverse("domain-request:")) @@ -934,6 +943,7 @@ class DomainRequestTests(TestWithUser, WebTest): contact_page = federal_result.follow() self.assertContains(contact_page, "Federal agency") + @less_console_noise_decorator def test_domain_request_form_conditional_elections(self): """Election question is shown for other organizations.""" intro_page = self.app.get(reverse("domain-request:")) @@ -988,6 +998,7 @@ class DomainRequestTests(TestWithUser, WebTest): contact_page = election_result.follow() self.assertNotContains(contact_page, "Federal agency") + @less_console_noise_decorator def test_domain_request_form_section_skipping(self): """Can skip forward and back in sections""" intro_page = self.app.get(reverse("domain-request:")) @@ -1025,6 +1036,7 @@ class DomainRequestTests(TestWithUser, WebTest): 0, ) + @less_console_noise_decorator def test_domain_request_form_nonfederal(self): """Non-federal organizations don't have to provide their federal agency.""" intro_page = self.app.get(reverse("domain-request:")) @@ -1069,6 +1081,7 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertEqual(contact_result.status_code, 302) self.assertEqual(contact_result["Location"], "/request/about_your_organization/") + @less_console_noise_decorator def test_domain_request_about_your_organization_special(self): """Special districts have to answer an additional question.""" intro_page = self.app.get(reverse("domain-request:")) @@ -1097,6 +1110,7 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertContains(contact_page, self.TITLES[Step.ABOUT_YOUR_ORGANIZATION]) + @less_console_noise_decorator def test_federal_agency_dropdown_excludes_expected_values(self): """The Federal Agency dropdown on a domain request form should not include options for gov Administration and Non-Federal Agency""" @@ -1144,6 +1158,7 @@ class DomainRequestTests(TestWithUser, WebTest): # make sure correct federal agency options still show up self.assertContains(org_contact_page, "General Services Administration") + @less_console_noise_decorator 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""" @@ -1151,6 +1166,7 @@ class DomainRequestTests(TestWithUser, WebTest): other_contacts_form = other_contacts_page.forms[0] self.assertEquals(other_contacts_form["other_contacts-has_other_contacts"].value, None) + @less_console_noise_decorator 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""" @@ -1163,6 +1179,7 @@ class DomainRequestTests(TestWithUser, WebTest): # Check the anything else yes/no field self.assertEquals(additional_form["additional_details-has_anything_else_text"].value, None) + @less_console_noise_decorator 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""" @@ -1183,6 +1200,7 @@ class DomainRequestTests(TestWithUser, WebTest): other_contacts_form = other_contacts_page.forms[0] self.assertEquals(other_contacts_form["other_contacts-has_other_contacts"].value, "True") + @less_console_noise_decorator 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 values for cisa_representative_first_name and @@ -1214,6 +1232,7 @@ class DomainRequestTests(TestWithUser, WebTest): yes_no_anything_else = additional_details_form["additional_details-has_anything_else_text"].value self.assertEquals(yes_no_anything_else, "True") + @less_console_noise_decorator 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""" @@ -1236,6 +1255,7 @@ class DomainRequestTests(TestWithUser, WebTest): other_contacts_form = other_contacts_page.forms[0] self.assertEquals(other_contacts_form["other_contacts-has_other_contacts"].value, "False") + @less_console_noise_decorator 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""" @@ -1271,6 +1291,7 @@ class DomainRequestTests(TestWithUser, WebTest): yes_no_anything_else = additional_details_form["additional_details-has_anything_else_text"].value self.assertEquals(yes_no_anything_else, "False") + @less_console_noise_decorator 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""" @@ -1332,6 +1353,7 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertEqual(domain_request.cisa_representative_last_name, None) self.assertEqual(domain_request.cisa_representative_email, None) + @less_console_noise_decorator 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""" @@ -1385,6 +1407,7 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertEqual(domain_request.has_cisa_representative, True) self.assertEqual(domain_request.has_anything_else_text, True) + @less_console_noise_decorator 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( @@ -1417,6 +1440,7 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertContains(response, "Enter the first name / given name of the CISA regional representative.") self.assertContains(response, "Enter the last name / family name of the CISA regional representative.") + @less_console_noise_decorator 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) @@ -1447,6 +1471,7 @@ class DomainRequestTests(TestWithUser, WebTest): expected_message = "Provide additional details you’d like us to know. If you have nothing to add, select “No.”" self.assertContains(response, expected_message) + @less_console_noise_decorator 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""" @@ -1480,6 +1505,7 @@ class DomainRequestTests(TestWithUser, WebTest): # due to screen reader information / html. self.assertContains(response, "This question is required.", count=4) + @less_console_noise_decorator 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""" @@ -1528,6 +1554,7 @@ class DomainRequestTests(TestWithUser, WebTest): None, ) + @less_console_noise_decorator def test_submitting_no_other_contacts_rationale_deletes_other_contacts(self): """When a user submits the Other Contacts form with no other contacts selected, the domain request's other contacts get deleted for other contacts that exist and are not joined to other objects @@ -1570,6 +1597,7 @@ class DomainRequestTests(TestWithUser, WebTest): "Hello again!", ) + @less_console_noise_decorator def test_submitting_no_other_contacts_rationale_removes_reference_other_contacts_when_joined(self): """When a user submits the Other Contacts form with no other contacts selected, the domain request's other contacts references get removed for other contacts that exist and are joined to other objects""" @@ -1665,6 +1693,7 @@ class DomainRequestTests(TestWithUser, WebTest): "Hello again!", ) + @less_console_noise_decorator def test_if_yes_no_form_is_no_then_no_other_contacts_required(self): """Applicants with no other contacts have to give a reason.""" other_contacts_page = self.app.get(reverse("domain-request:other_contacts")) @@ -1680,6 +1709,7 @@ class DomainRequestTests(TestWithUser, WebTest): # Assert that it is not returned, ie the contacts form is not required self.assertNotContains(response, "Enter the first name / given name of this contact.") + @less_console_noise_decorator def test_if_yes_no_form_is_yes_then_other_contacts_required(self): """Applicants with other contacts do not have to give a reason.""" other_contacts_page = self.app.get(reverse("domain-request:other_contacts")) @@ -1695,6 +1725,7 @@ class DomainRequestTests(TestWithUser, WebTest): # Assert that it is returned, ie the contacts form is required self.assertContains(response, "Enter the first name / given name of this contact.") + @less_console_noise_decorator def test_delete_other_contact(self): """Other contacts can be deleted after being saved to database. @@ -1779,6 +1810,7 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertEqual(domain_request.other_contacts.count(), 1) self.assertEqual(domain_request.other_contacts.first().first_name, "Testy3") + @less_console_noise_decorator def test_delete_other_contact_does_not_allow_zero_contacts(self): """Delete Other Contact does not allow submission with zero contacts.""" # Populate the database with a domain request that @@ -1851,6 +1883,7 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertEqual(domain_request.other_contacts.count(), 1) self.assertEqual(domain_request.other_contacts.first().first_name, "Testy2") + @less_console_noise_decorator def test_delete_other_contact_sets_visible_empty_form_as_required_after_failed_submit(self): """When you: 1. add an empty contact, @@ -1928,6 +1961,7 @@ class DomainRequestTests(TestWithUser, WebTest): # Enter the first name ... self.assertContains(response, "Enter the first name / given name of this contact.") + @less_console_noise_decorator def test_edit_other_contact_in_place(self): """When you: 1. edit an existing contact which is not joined to another model, @@ -2009,6 +2043,7 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertEquals(other_contact_pk, other_contact.id) self.assertEquals("Testy3", other_contact.first_name) + @less_console_noise_decorator def test_edit_other_contact_creates_new(self): """When you: 1. edit an existing contact which IS joined to another model, @@ -2089,6 +2124,7 @@ class DomainRequestTests(TestWithUser, WebTest): senior_official = domain_request.senior_official self.assertEquals("Testy", senior_official.first_name) + @less_console_noise_decorator def test_edit_senior_official_in_place(self): """When you: 1. edit a senior official which is not joined to another model, @@ -2154,6 +2190,7 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertEquals(so_pk, updated_so.id) self.assertEquals("Testy2", updated_so.first_name) + @less_console_noise_decorator def test_edit_senior_official_creates_new(self): """When you: 1. edit an existing senior official which IS joined to another model, @@ -2226,6 +2263,7 @@ class DomainRequestTests(TestWithUser, WebTest): senior_official = domain_request.senior_official self.assertEquals("Testy2", senior_official.first_name) + @less_console_noise_decorator def test_edit_submitter_in_place(self): """When you: 1. edit a submitter (your contact) which is not joined to another model, @@ -2290,6 +2328,7 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertEquals(submitter_pk, updated_submitter.id) self.assertEquals("Testy2", updated_submitter.first_name) + @less_console_noise_decorator def test_edit_submitter_creates_new(self): """When you: 1. edit an existing your contact which IS joined to another model, @@ -2362,6 +2401,7 @@ class DomainRequestTests(TestWithUser, WebTest): submitter = domain_request.submitter self.assertEquals("Testy2", submitter.first_name) + @less_console_noise_decorator def test_domain_request_about_your_organiztion_interstate(self): """Special districts have to answer an additional question.""" intro_page = self.app.get(reverse("domain-request:")) @@ -2390,6 +2430,7 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertContains(contact_page, self.TITLES[Step.ABOUT_YOUR_ORGANIZATION]) + @less_console_noise_decorator def test_domain_request_tribal_government(self): """Tribal organizations have to answer an additional question.""" intro_page = self.app.get(reverse("domain-request:")) @@ -2421,6 +2462,7 @@ class DomainRequestTests(TestWithUser, WebTest): # and the step is on the sidebar list. self.assertContains(tribal_government_page, self.TITLES[Step.TRIBAL_GOVERNMENT]) + @less_console_noise_decorator def test_domain_request_so_dynamic_text(self): intro_page = self.app.get(reverse("domain-request:")) # django-webtest does not handle cookie-based sessions well because it keeps @@ -2460,9 +2502,7 @@ class DomainRequestTests(TestWithUser, WebTest): self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) org_contact_page = federal_result.follow() org_contact_form = org_contact_page.forms[0] - # federal agency so we have to fill in federal_agency - federal_agency, _ = FederalAgency.objects.get_or_create(agency="General Services Administration") - org_contact_form["organization_contact-federal_agency"] = federal_agency.id + org_contact_form["organization_contact-federal_agency"] = self.federal_agency.id org_contact_form["organization_contact-organization_name"] = "Testorg" org_contact_form["organization_contact-address_line1"] = "address 1" org_contact_form["organization_contact-address_line2"] = "address 2" @@ -2493,6 +2533,7 @@ class DomainRequestTests(TestWithUser, WebTest): so_page = election_page.click(str(self.TITLES["senior_official"]), index=0) self.assertContains(so_page, "Domain requests from cities") + @less_console_noise_decorator def test_domain_request_dotgov_domain_dynamic_text(self): intro_page = self.app.get(reverse("domain-request:")) # django-webtest does not handle cookie-based sessions well because it keeps @@ -2532,9 +2573,7 @@ class DomainRequestTests(TestWithUser, WebTest): self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) org_contact_page = federal_result.follow() org_contact_form = org_contact_page.forms[0] - # federal agency so we have to fill in federal_agency - federal_agency, _ = FederalAgency.objects.get_or_create(agency="General Services Administration") - org_contact_form["organization_contact-federal_agency"] = federal_agency.id + org_contact_form["organization_contact-federal_agency"] = self.federal_agency.id org_contact_form["organization_contact-organization_name"] = "Testorg" org_contact_form["organization_contact-address_line1"] = "address 1" org_contact_form["organization_contact-address_line2"] = "address 2" @@ -2595,6 +2634,7 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertContains(dotgov_page, "CityofEudoraKS.gov") self.assertNotContains(dotgov_page, "medicare.gov") + @less_console_noise_decorator def test_domain_request_formsets(self): """Users are able to add more than one of some fields.""" current_sites_page = self.app.get(reverse("domain-request:current_sites")) @@ -2749,6 +2789,7 @@ class DomainRequestTests(TestWithUser, WebTest): # page = self.app.get(url) # self.assertNotContains(page, "VALUE") + @less_console_noise_decorator def test_long_org_name_in_domain_request(self): """ Make sure the long name is displaying in the domain request form, @@ -2771,6 +2812,7 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertContains(type_page, "Federal: an agency of the U.S. government") + @less_console_noise_decorator def test_submit_modal_no_domain_text_fallback(self): """When user clicks on submit your domain request and the requested domain is null (possible through url direct access to the review page), present @@ -2790,6 +2832,12 @@ class DomainRequestTestDifferentStatuses(TestWithUser, WebTest): self.app.set_user(self.user.username) self.client.force_login(self.user) + def tearDown(self): + super().tearDown() + DomainRequest.objects.all().delete() + DomainInformation.objects.all().delete() + + @less_console_noise_decorator def test_domain_request_status(self): """Checking domain request status page""" domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.SUBMITTED, user=self.user) @@ -2803,6 +2851,7 @@ class DomainRequestTestDifferentStatuses(TestWithUser, WebTest): self.assertContains(detail_page, "Admin Tester") self.assertContains(detail_page, "Status:") + @less_console_noise_decorator def test_domain_request_status_with_ineligible_user(self): """Checking domain request status page whith a blocked user. The user should still have access to view.""" @@ -2819,6 +2868,7 @@ class DomainRequestTestDifferentStatuses(TestWithUser, WebTest): self.assertContains(detail_page, "Admin Tester") self.assertContains(detail_page, "Status:") + @less_console_noise_decorator def test_domain_request_withdraw(self): """Checking domain request status page""" domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.SUBMITTED, user=self.user) @@ -2849,6 +2899,7 @@ class DomainRequestTestDifferentStatuses(TestWithUser, WebTest): response = self.client.get("/get-domain-requests-json/") self.assertContains(response, "Withdrawn") + @less_console_noise_decorator def test_domain_request_withdraw_no_permissions(self): """Can't withdraw domain requests as a restricted user.""" self.user.status = User.RESTRICTED @@ -2873,6 +2924,7 @@ class DomainRequestTestDifferentStatuses(TestWithUser, WebTest): page = self.client.get(reverse(url_name, kwargs={"pk": domain_request.pk})) self.assertEqual(page.status_code, 403) + @less_console_noise_decorator def test_domain_request_status_no_permissions(self): """Can't access domain requests without being the creator.""" domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.SUBMITTED, user=self.user) @@ -2892,6 +2944,7 @@ class DomainRequestTestDifferentStatuses(TestWithUser, WebTest): page = self.client.get(reverse(url_name, kwargs={"pk": domain_request.pk})) self.assertEqual(page.status_code, 403) + @less_console_noise_decorator def test_approved_domain_request_not_in_active_requests(self): """An approved domain request is not shown in the Active Requests table on home.html.""" @@ -2916,13 +2969,17 @@ class TestWizardUnlockingSteps(TestWithUser, WebTest): def tearDown(self): super().tearDown() + DomainRequest.objects.all().delete() + DomainInformation.objects.all().delete() + @less_console_noise_decorator def test_unlocked_steps_empty_domain_request(self): """Test when all fields in the domain request are empty.""" unlocked_steps = self.wizard.db_check_for_unlocking_steps() expected_dict = [] self.assertEqual(unlocked_steps, expected_dict) + @less_console_noise_decorator def test_unlocked_steps_full_domain_request(self): """Test when all fields in the domain request are filled.""" @@ -2959,6 +3016,7 @@ class TestWizardUnlockingSteps(TestWithUser, WebTest): else: self.fail(f"Expected a redirect, but got a different response: {response}") + @less_console_noise_decorator def test_unlocked_steps_partial_domain_request(self): """Test when some fields in the domain request are filled.""" diff --git a/src/registrar/tests/test_views_requests_json.py b/src/registrar/tests/test_views_requests_json.py index 5b36ef759..7bdc922cf 100644 --- a/src/registrar/tests/test_views_requests_json.py +++ b/src/registrar/tests/test_views_requests_json.py @@ -12,99 +12,102 @@ class GetRequestsJsonTest(TestWithUser, WebTest): super().setUp() self.app.set_user(self.user.username) + @classmethod + def setUpClass(cls): + super().setUpClass() lamb_chops, _ = DraftDomain.objects.get_or_create(name="lamb-chops.gov") short_ribs, _ = DraftDomain.objects.get_or_create(name="short-ribs.gov") beef_chuck, _ = DraftDomain.objects.get_or_create(name="beef-chuck.gov") stew_beef, _ = DraftDomain.objects.get_or_create(name="stew-beef.gov") # Create domain requests for the user - self.domain_requests = [ + cls.domain_requests = [ DomainRequest.objects.create( - creator=self.user, + creator=cls.user, requested_domain=lamb_chops, submission_date="2024-01-01", status=DomainRequest.DomainRequestStatus.STARTED, created_at="2024-01-01", ), DomainRequest.objects.create( - creator=self.user, + creator=cls.user, requested_domain=short_ribs, submission_date="2024-02-01", status=DomainRequest.DomainRequestStatus.WITHDRAWN, created_at="2024-02-01", ), DomainRequest.objects.create( - creator=self.user, + creator=cls.user, requested_domain=beef_chuck, submission_date="2024-03-01", status=DomainRequest.DomainRequestStatus.REJECTED, created_at="2024-03-01", ), DomainRequest.objects.create( - creator=self.user, + creator=cls.user, requested_domain=stew_beef, submission_date="2024-04-01", status=DomainRequest.DomainRequestStatus.STARTED, created_at="2024-04-01", ), DomainRequest.objects.create( - creator=self.user, + creator=cls.user, requested_domain=None, submission_date="2024-05-01", status=DomainRequest.DomainRequestStatus.STARTED, created_at="2024-05-01", ), DomainRequest.objects.create( - creator=self.user, + creator=cls.user, requested_domain=None, submission_date="2024-06-01", status=DomainRequest.DomainRequestStatus.WITHDRAWN, created_at="2024-06-01", ), DomainRequest.objects.create( - creator=self.user, + creator=cls.user, requested_domain=None, submission_date="2024-07-01", status=DomainRequest.DomainRequestStatus.REJECTED, created_at="2024-07-01", ), DomainRequest.objects.create( - creator=self.user, + creator=cls.user, requested_domain=None, submission_date="2024-08-01", status=DomainRequest.DomainRequestStatus.STARTED, created_at="2024-08-01", ), DomainRequest.objects.create( - creator=self.user, + creator=cls.user, requested_domain=None, submission_date="2024-09-01", status=DomainRequest.DomainRequestStatus.STARTED, created_at="2024-09-01", ), DomainRequest.objects.create( - creator=self.user, + creator=cls.user, requested_domain=None, submission_date="2024-10-01", status=DomainRequest.DomainRequestStatus.WITHDRAWN, created_at="2024-10-01", ), DomainRequest.objects.create( - creator=self.user, + creator=cls.user, requested_domain=None, submission_date="2024-11-01", status=DomainRequest.DomainRequestStatus.REJECTED, created_at="2024-11-01", ), DomainRequest.objects.create( - creator=self.user, + creator=cls.user, requested_domain=None, submission_date="2024-11-02", status=DomainRequest.DomainRequestStatus.WITHDRAWN, created_at="2024-11-02", ), DomainRequest.objects.create( - creator=self.user, + creator=cls.user, requested_domain=None, submission_date="2024-12-01", status=DomainRequest.DomainRequestStatus.APPROVED, @@ -112,9 +115,11 @@ class GetRequestsJsonTest(TestWithUser, WebTest): ), ] - def tearDown(self): - super().tearDown() + @classmethod + def tearDownClass(cls): + super().tearDownClass() DomainRequest.objects.all().delete() + DraftDomain.objects.all().delete() def test_get_domain_requests_json_authenticated(self): """Test that domain requests are returned properly for an authenticated user.""" diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 56aa41569..d852df5db 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -109,7 +109,7 @@ class BaseExport(ABC): return Q() @classmethod - def get_filter_conditions(cls, start_date=None, end_date=None): + def get_filter_conditions(cls, **export_kwargs): """ Get a Q object of filter conditions to filter when building queryset. """ @@ -145,7 +145,7 @@ class BaseExport(ABC): return queryset @classmethod - def write_csv_before(cls, csv_writer, start_date=None, end_date=None): + def write_csv_before(cls, csv_writer, **export_kwargs): """ Write to csv file before the write_csv method. Override in subclasses where needed. @@ -192,7 +192,7 @@ class BaseExport(ABC): return cls.update_queryset(queryset, **kwargs) @classmethod - def export_data_to_csv(cls, csv_file, start_date=None, end_date=None): + def export_data_to_csv(cls, csv_file, **export_kwargs): """ All domain metadata: Exports domains of all statuses plus domain managers. @@ -205,7 +205,7 @@ class BaseExport(ABC): prefetch_related = cls.get_prefetch_related() exclusions = cls.get_exclusions() annotations_for_sort = cls.get_annotations_for_sort() - filter_conditions = cls.get_filter_conditions(start_date, end_date) + filter_conditions = cls.get_filter_conditions(**export_kwargs) computed_fields = cls.get_computed_fields() related_table_fields = cls.get_related_table_fields() @@ -227,10 +227,13 @@ class BaseExport(ABC): models_dict = convert_queryset_to_dict(annotated_queryset, is_model=False) # Write to csv file before the write_csv - cls.write_csv_before(writer, start_date, end_date) + cls.write_csv_before(writer, **export_kwargs) # Write the csv file - cls.write_csv(writer, columns, models_dict) + rows = cls.write_csv(writer, columns, models_dict) + + # Return rows that for easier parsing and testing + return rows @classmethod def write_csv( @@ -257,6 +260,9 @@ class BaseExport(ABC): writer.writerows(rows) + # Return rows for easier parsing and testing + return rows + @classmethod @abstractmethod def parse_row(cls, columns, model): @@ -344,7 +350,11 @@ class DomainExport(BaseExport): """ Fetch all UserDomainRole entries and return a mapping of domain to user__email. """ - user_domain_roles = UserDomainRole.objects.select_related("user").values_list("domain__name", "user__email") + user_domain_roles = ( + UserDomainRole.objects.select_related("user") + .order_by("domain__name", "user__email") + .values_list("domain__name", "user__email") + ) return list(user_domain_roles) @classmethod @@ -364,8 +374,9 @@ class DomainExport(BaseExport): if first_ready_on is None: first_ready_on = "(blank)" - domain_org_type = model.get("generic_org_type") - human_readable_domain_org_type = DomainRequest.OrganizationChoices.get_org_label(domain_org_type) + # organization_type has generic_org_type AND is_election + domain_org_type = model.get("organization_type") + human_readable_domain_org_type = DomainRequest.OrgChoicesElectionOffice.get_org_label(domain_org_type) domain_federal_type = model.get("federal_type") human_readable_domain_federal_type = BranchChoices.get_branch_label(domain_federal_type) domain_type = human_readable_domain_org_type @@ -554,6 +565,25 @@ class DomainDataType(DomainExport): ] +class DomainDataTypeUser(DomainDataType): + """ + The DomainDataType report, but sliced on the current request user + """ + + @classmethod + def get_filter_conditions(cls, request=None): + """ + Get a Q object of filter conditions to filter when building queryset. + """ + if request is None or not hasattr(request, "user") or not request.user: + # Return nothing + return Q(id__in=[]) + + user_domain_roles = UserDomainRole.objects.filter(user=request.user) + domain_ids = user_domain_roles.values_list("domain_id", flat=True) + return Q(domain__id__in=domain_ids) + + class DomainDataFull(DomainExport): """ Shows security contacts, filtered by state @@ -611,7 +641,7 @@ class DomainDataFull(DomainExport): return ["domain"] @classmethod - def get_filter_conditions(cls, start_date=None, end_date=None): + def get_filter_conditions(cls): """ Get a Q object of filter conditions to filter when building queryset. """ @@ -706,7 +736,7 @@ class DomainDataFederal(DomainExport): return ["domain"] @classmethod - def get_filter_conditions(cls, start_date=None, end_date=None): + def get_filter_conditions(cls): """ Get a Q object of filter conditions to filter when building queryset. """ diff --git a/src/registrar/views/__init__.py b/src/registrar/views/__init__.py index 37977f334..a00cc1d73 100644 --- a/src/registrar/views/__init__.py +++ b/src/registrar/views/__init__.py @@ -17,3 +17,4 @@ from .domain import ( from .user_profile import UserProfileView, FinishProfileSetupView from .health import * from .index import * +from .portfolios import * diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 766bef7c2..da34ce646 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -170,6 +170,17 @@ class DomainView(DomainBaseView): context["security_email"] = security_email return context + def can_access_domain_via_portfolio(self, pk): + """Most views should not allow permission to portfolio users. + If particular views allow permissions, they will need to override + this function.""" + if self.request.user.has_domains_portfolio_permission(): + if Domain.objects.filter(id=pk).exists(): + domain = Domain.objects.get(id=pk) + if domain.domain_info.portfolio == self.request.user.portfolio: + return True + return False + def in_editable_state(self, pk): """Override in_editable_state from DomainPermission Allow detail page to be viewable""" diff --git a/src/registrar/views/domains_json.py b/src/registrar/views/domains_json.py index 3b3cae2c7..5bb9b037f 100644 --- a/src/registrar/views/domains_json.py +++ b/src/registrar/views/domains_json.py @@ -6,6 +6,8 @@ from django.contrib.auth.decorators import login_required from django.urls import reverse from django.db.models import Q +from registrar.models.domain_information import DomainInformation + logger = logging.getLogger(__name__) @@ -14,10 +16,9 @@ def get_domains_json(request): """Given the current request, get all domains that are associated with the UserDomainRole object""" - user_domain_roles = UserDomainRole.objects.filter(user=request.user).select_related("domain_info__sub_organization") - domain_ids = user_domain_roles.values_list("domain_id", flat=True) + domain_ids = get_domain_ids_from_request(request) - objects = Domain.objects.filter(id__in=domain_ids) + objects = Domain.objects.filter(id__in=domain_ids).select_related("domain_info__sub_organization") unfiltered_total = objects.count() objects = apply_search(objects, request) @@ -28,7 +29,7 @@ def get_domains_json(request): page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) - domains = [serialize_domain(domain) for domain in page_obj.object_list] + domains = [serialize_domain(domain, request.user) for domain in page_obj.object_list] return JsonResponse( { @@ -43,6 +44,21 @@ def get_domains_json(request): ) +def get_domain_ids_from_request(request): + """Get domain ids from request. + + If portfolio specified, return domain ids associated with portfolio. + Otherwise, return domain ids associated with request.user. + """ + portfolio = request.GET.get("portfolio") + if portfolio: + domain_infos = DomainInformation.objects.filter(portfolio=portfolio) + return domain_infos.values_list("domain_id", flat=True) + else: + user_domain_roles = UserDomainRole.objects.filter(user=request.user) + return user_domain_roles.values_list("domain_id", flat=True) + + def apply_search(queryset, request): search_term = request.GET.get("search_term") if search_term: @@ -94,7 +110,7 @@ def apply_sorting(queryset, request): return queryset.order_by(sort_by) -def serialize_domain(domain): +def serialize_domain(domain, user): suborganization_name = None try: domain_info = domain.domain_info @@ -106,6 +122,9 @@ def serialize_domain(domain): domain_info = None logger.debug(f"Issue in domains_json: We could not find domain_info for {domain}") + # Check if there is a UserDomainRole for this domain and user + user_domain_role_exists = UserDomainRole.objects.filter(domain_id=domain.id, user=user).exists() + view_only = not user_domain_role_exists or domain.state in [Domain.State.DELETED, Domain.State.ON_HOLD] return { "id": domain.id, "name": domain.name, @@ -114,7 +133,7 @@ def serialize_domain(domain): "state_display": domain.state_display(), "get_state_help_text": domain.get_state_help_text(), "action_url": reverse("domain", kwargs={"pk": domain.id}), - "action_label": ("View" if domain.state in [Domain.State.DELETED, Domain.State.ON_HOLD] else "Manage"), - "svg_icon": ("visibility" if domain.state in [Domain.State.DELETED, Domain.State.ON_HOLD] else "settings"), + "action_label": ("View" if view_only else "Manage"), + "svg_icon": ("visibility" if view_only else "settings"), "suborganization": suborganization_name, } diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index f693e2cc9..63ebbaa01 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -1,58 +1,96 @@ -from django.shortcuts import get_object_or_404, render +import logging +from django.http import Http404 +from django.shortcuts import render +from django.urls import reverse +from django.contrib import messages +from registrar.forms.portfolio import PortfolioOrgAddressForm from registrar.models.portfolio import Portfolio from registrar.views.utility.permission_views import ( PortfolioDomainRequestsPermissionView, PortfolioDomainsPermissionView, PortfolioBasePermissionView, ) -from waffle.decorators import flag_is_active from django.views.generic import View +from django.views.generic.edit import FormMixin + + +logger = logging.getLogger(__name__) class PortfolioDomainsView(PortfolioDomainsPermissionView, View): template_name = "portfolio_domains.html" - def get(self, request, portfolio_id): - context = {} - - if self.request.user.is_authenticated: - context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature") - context["has_organization_feature_flag"] = flag_is_active(request, "organization_feature") - portfolio = get_object_or_404(Portfolio, id=portfolio_id) - context["portfolio"] = portfolio - - return render(request, "portfolio_domains.html", context) + def get(self, request): + return render(request, "portfolio_domains.html") class PortfolioDomainRequestsView(PortfolioDomainRequestsPermissionView, View): template_name = "portfolio_requests.html" - def get(self, request, portfolio_id): - context = {} - + def get(self, request): if self.request.user.is_authenticated: - context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature") - context["has_organization_feature_flag"] = flag_is_active(request, "organization_feature") - portfolio = get_object_or_404(Portfolio, id=portfolio_id) - context["portfolio"] = portfolio request.session["new_request"] = True - - return render(request, "portfolio_requests.html", context) + return render(request, "portfolio_requests.html") -class PortfolioOrganizationView(PortfolioBasePermissionView, View): +class PortfolioOrganizationView(PortfolioBasePermissionView, FormMixin): + """ + View to handle displaying and updating the portfolio's organization details. + """ + model = Portfolio template_name = "portfolio_organization.html" + form_class = PortfolioOrgAddressForm + context_object_name = "portfolio" - def get(self, request, portfolio_id): - context = {} + def get_context_data(self, **kwargs): + """Add additional context data to the template.""" + context = super().get_context_data(**kwargs) + context["has_edit_org_portfolio_permission"] = self.request.user.has_edit_org_portfolio_permission() + return context - if self.request.user.is_authenticated: - context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature") - context["has_organization_feature_flag"] = flag_is_active(request, "organization_feature") - portfolio = get_object_or_404(Portfolio, id=portfolio_id) - context["portfolio"] = portfolio + def get_object(self, queryset=None): + """Get the portfolio object based on the request user.""" + portfolio = self.request.user.portfolio + if portfolio is None: + raise Http404("No organization found for this user") + return portfolio - return render(request, "portfolio_organization.html", context) + def get_form_kwargs(self): + """Include the instance in the form kwargs.""" + kwargs = super().get_form_kwargs() + kwargs["instance"] = self.get_object() + return kwargs + + def get(self, request, *args, **kwargs): + """Handle GET requests to display the form.""" + self.object = self.get_object() + form = self.get_form() + return self.render_to_response(self.get_context_data(form=form)) + + def post(self, request, *args, **kwargs): + """Handle POST requests to process form submission.""" + self.object = self.get_object() + form = self.get_form() + if form.is_valid(): + return self.form_valid(form) + else: + return self.form_invalid(form) + + def form_valid(self, form): + """Handle the case when the form is valid.""" + self.object = form.save(commit=False) + self.object.creator = self.request.user + self.object.save() + messages.success(self.request, "The organization information for this portfolio has been updated.") + return super().form_valid(form) + + def form_invalid(self, form): + """Handle the case when the form is invalid.""" + return self.render_to_response(self.get_context_data(form=form)) + + def get_success_url(self): + """Redirect to the overview page for the portfolio.""" + return reverse("organization") diff --git a/src/registrar/views/admin_views.py b/src/registrar/views/report_views.py similarity index 94% rename from src/registrar/views/admin_views.py rename to src/registrar/views/report_views.py index 4d015ab37..428298b52 100644 --- a/src/registrar/views/admin_views.py +++ b/src/registrar/views/report_views.py @@ -158,6 +158,17 @@ class ExportDataType(View): return response +class ExportDataTypeUser(View): + """Returns a domain report for a given user on the request""" + + def get(self, request, *args, **kwargs): + # match the CSV example with all the fields + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="your-domains.csv"' + csv_export.DomainDataTypeUser.export_data_to_csv(response, request=request) + return response + + class ExportDataFull(View): def get(self, request, *args, **kwargs): # Smaller export based on 1 @@ -194,7 +205,7 @@ class ExportDataDomainsGrowth(View): response = HttpResponse(content_type="text/csv") response["Content-Disposition"] = f'attachment; filename="domain-growth-report-{start_date}-to-{end_date}.csv"' - csv_export.DomainGrowth.export_data_to_csv(response, start_date, end_date) + csv_export.DomainGrowth.export_data_to_csv(response, start_date=start_date, end_date=end_date) return response @@ -206,7 +217,7 @@ class ExportDataRequestsGrowth(View): response = HttpResponse(content_type="text/csv") response["Content-Disposition"] = f'attachment; filename="requests-{start_date}-to-{end_date}.csv"' - csv_export.DomainRequestGrowth.export_data_to_csv(response, start_date, end_date) + csv_export.DomainRequestGrowth.export_data_to_csv(response, start_date=start_date, end_date=end_date) return response @@ -217,7 +228,7 @@ class ExportDataManagedDomains(View): end_date = request.GET.get("end_date", "") response = HttpResponse(content_type="text/csv") response["Content-Disposition"] = f'attachment; filename="managed-domains-{start_date}-to-{end_date}.csv"' - csv_export.DomainManaged.export_data_to_csv(response, start_date, end_date) + csv_export.DomainManaged.export_data_to_csv(response, start_date=start_date, end_date=end_date) return response @@ -228,6 +239,6 @@ class ExportDataUnmanagedDomains(View): end_date = request.GET.get("end_date", "") response = HttpResponse(content_type="text/csv") response["Content-Disposition"] = f'attachment; filename="unmanaged-domains-{start_date}-to-{end_date}.csv"' - csv_export.DomainUnmanaged.export_data_to_csv(response, start_date, end_date) + csv_export.DomainUnmanaged.export_data_to_csv(response, start_date=start_date, end_date=end_date) return response diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index 5f8485ec5..16a896100 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -184,11 +184,17 @@ class DomainPermission(PermissionsLoginMixin): # user needs to have a role on the domain if not UserDomainRole.objects.filter(user=self.request.user, domain__id=pk).exists(): - return False + return self.can_access_domain_via_portfolio(pk) # if we need to check more about the nature of role, do it here. return True + def can_access_domain_via_portfolio(self, pk): + """Most views should not allow permission to portfolio users. + If particular views allow access to the domain pages, they will need to override + this function.""" + return False + def in_editable_state(self, pk): """Is the domain in an editable state""" diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index 181934787..501658e9f 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -43,6 +43,9 @@ class DomainPermissionView(DomainPermission, DetailView, abc.ABC): context["is_analyst_or_superuser"] = user.has_perm("registrar.analyst_access_permission") or user.has_perm( "registrar.full_access_permission" ) + context["is_domain_manager"] = UserDomainRole.objects.filter(user=user, domain=self.object).exists() + context["is_portfolio_user"] = self.can_access_domain_via_portfolio(self.object.pk) + context["is_editable"] = self.is_editable() # Stored in a variable for the linter action = "analyst_action" action_location = "analyst_action_location" @@ -54,6 +57,22 @@ class DomainPermissionView(DomainPermission, DetailView, abc.ABC): return context + def is_editable(self): + """Returns whether domain is editable in the context of the view""" + logger.info("checking if is_editable") + domain_editable = self.object.is_editable() + if not domain_editable: + return False + + # if user is domain manager or analyst or admin, return True + if ( + self.can_access_other_user_domains(self.object.id) + or UserDomainRole.objects.filter(user=self.request.user, domain=self.object).exists() + ): + return True + + return False + # Abstract property enforces NotImplementedError on an attribute. @property @abc.abstractmethod