diff --git a/.github/ISSUE_TEMPLATE/developer-onboarding.md b/.github/ISSUE_TEMPLATE/developer-onboarding.md index eb547d5ab..d63cf2f94 100644 --- a/.github/ISSUE_TEMPLATE/developer-onboarding.md +++ b/.github/ISSUE_TEMPLATE/developer-onboarding.md @@ -19,12 +19,13 @@ There are several tools we use locally that you will need to have. - 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/) ## Access ### Steps for the onboardee -- [ ] Setup [commit signing in Github](#setting-up-commit-signing) and with git locally. +- [ ] Setup commit signing in Github and with git locally using either [gpg](#setting-up-commit-signing-with-gpg) or [ssh](#setting-up-commit-signing-with-ssh). - [ ] [Create a cloud.gov account](https://cloud.gov/docs/getting-started/accounts/) - [ ] Email github@cisa.dhs.gov (cc: Cameron) to add you to the [CISA Github organization](https://github.com/getgov) and [.gov Team](https://github.com/orgs/cisagov/teams/gov). - [ ] Ensure you can login to your cloud.gov account via the CLI @@ -51,7 +52,7 @@ cf login -a api.fr.cloud.gov --sso - [ ] [Contributing Policy](https://github.com/cisagov/dotgov/tree/main/CONTRIBUTING.md) -## Setting up commit signing +## Setting up commit signing with GPG Follow the instructions [here](https://docs.github.com/en/authentication/managing-commit-signature-verification/generating-a-new-gpg-key) to generate a new GPG key (default configurations are okay) and add it to your GPG keys on Github. @@ -72,6 +73,22 @@ when setting up your key in Github. Now test commit signing is working by checking out a branch (`yourname/test-commit-signing`) and making some small change to a file. Commit the change (it should prompt you for your GPG credential) and push it to Github. Look on Github at your branch and ensure the commit is `verified`. +## Setting up commit signing with SSH + +Follow the instructions [here](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent#generating-a-new-ssh-key) to generate a new SSH key and [add it to your SSH keys on Github](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/adding-a-new-ssh-key-to-your-github-account). Note that you need to add the key as a signing key. + +Configure your key locally: + +```bash +git config --global gpg.format ssh +git config --global commit.gpgsign true +git config --global user.signingkey +``` + +Where `` is the path to the private key you generated when running `ssh-keygen`. Usually this is located in ~\.ssh\. + +Now test commit signing is working by checking out a branch (`yourinitials/test-commit-signing`) and making some small change to a file. Commit the change (it should prompt you for your key passphrase) and push it to Github. Look on Github at your branch and ensure the commit is `verified`. + ### MacOS **Note:** if you are on a mac and not able to successfully create a signed commit, getting the following error: ```zsh diff --git a/.github/workflows/createcachetable.yaml b/.github/workflows/createcachetable.yaml index 8fa4d76c8..207ecf70e 100644 --- a/.github/workflows/createcachetable.yaml +++ b/.github/workflows/createcachetable.yaml @@ -28,6 +28,7 @@ on: - ab - rjm - dk + - ms jobs: createcachetable: diff --git a/.github/workflows/deploy-branch-to-sandbox.yaml b/.github/workflows/deploy-branch-to-sandbox.yaml new file mode 100644 index 000000000..f57961fa8 --- /dev/null +++ b/.github/workflows/deploy-branch-to-sandbox.yaml @@ -0,0 +1,92 @@ +# Manually deploy a branch of choice to an environment of choice. + +name: Manual Build and Deploy +run-name: Manually build and deploy branch to sandbox of choice + +on: + workflow_dispatch: + inputs: + environment: + description: 'Environment to deploy' + required: true + default: 'backup' + type: 'choice' + options: + - ab + - backup + - cb + - dk + - es + - gd + - ko + - ky + - nl + - rb + - rh + - rjm + - meoward + - bob + - hotgov + - litterbox + - ms + # GitHub Actions has no "good" way yet to dynamically input branches + branch: + description: 'Branch to deploy' + required: true + default: 'main' + type: string + + +jobs: + variables: + runs-on: ubuntu-latest + steps: + - name: Setting global variables + uses: actions/github-script@v6 + id: var + with: + script: | + core.setOutput('environment', '${{ github.head_ref }}'.split("/")[0]); + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Compile USWDS assets + working-directory: ./src + run: | + docker compose run node npm install npm@latest && + docker compose run node npm install && + docker compose run node npx gulp copyAssets && + docker compose run node npx gulp compile + - name: Collect static assets + working-directory: ./src + run: docker compose run app python manage.py collectstatic --no-input + - name: Deploy to cloud.gov sandbox + uses: cloud-gov/cg-cli-tools@main + env: + ENVIRONMENT: ${{ github.event.inputs.environment }} + CF_USERNAME: CF_${{ github.event.inputs.environment }}_USERNAME + CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD + with: + cf_username: ${{ secrets[env.CF_USERNAME] }} + cf_password: ${{ secrets[env.CF_PASSWORD] }} + 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-development.yaml b/.github/workflows/deploy-development.yaml index 03994e1de..fa447ed76 100644 --- a/.github/workflows/deploy-development.yaml +++ b/.github/workflows/deploy-development.yaml @@ -22,7 +22,8 @@ jobs: - name: Compile USWDS assets working-directory: ./src run: | - docker compose run node npm install && + docker compose run node npm install npm@latest && + docker compose run node npm install && docker compose run node npx gulp copyAssets && docker compose run node npx gulp compile - name: Collect static assets diff --git a/.github/workflows/deploy-sandbox.yaml b/.github/workflows/deploy-sandbox.yaml index 4bd7f99dd..57561919c 100644 --- a/.github/workflows/deploy-sandbox.yaml +++ b/.github/workflows/deploy-sandbox.yaml @@ -47,7 +47,8 @@ jobs: - name: Compile USWDS assets working-directory: ./src run: | - docker compose run node npm install && + docker compose run node npm install npm@latest && + docker compose run node npm install && docker compose run node npx gulp copyAssets && docker compose run node npx gulp compile - name: Collect static assets diff --git a/.github/workflows/issue-label-notifier.yaml b/.github/workflows/issue-label-notifier.yaml new file mode 100644 index 000000000..c4f10d48f --- /dev/null +++ b/.github/workflows/issue-label-notifier.yaml @@ -0,0 +1,18 @@ +name: Notify users based on issue labels + +on: + issues: + types: [labeled] + pull_request: + types: [labeled] + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - uses: jenschelkopf/issue-label-notification-action@1.3 + with: + recipients: | + design-review=@Katherine-Osos + message: 'cc/ {recipients} — adding you to this **{label}** issue!' + \ No newline at end of file diff --git a/src/djangooidc/tests/test_views.py b/src/djangooidc/tests/test_views.py index bdd61b346..6ebe25d45 100644 --- a/src/djangooidc/tests/test_views.py +++ b/src/djangooidc/tests/test_views.py @@ -429,6 +429,10 @@ class ViewsTest(TestCase): # Create a mock request request = self.factory.get("/some-url") request.session = {"acr_value": ""} + # Mock user and its attributes + mock_user = MagicMock() + mock_user.is_authenticated = True + request.user = mock_user # Ensure that the CLIENT instance used in login_callback is the mock # patch _requires_step_up_auth to return False with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch( diff --git a/src/docker-compose.yml b/src/docker-compose.yml index 39282ff96..d64ba80d5 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -1,4 +1,3 @@ -version: "3.0" services: app: build: . 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/registrar/admin.py b/src/registrar/admin.py index 9b842abf8..46f6cc68c 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -9,6 +9,8 @@ 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 waffle.decorators import flag_is_active from django.contrib import admin, messages from django.contrib.auth.admin import UserAdmin as BaseUserAdmin @@ -35,6 +37,7 @@ from django_admin_multiple_choice_list_filter.list_filters import MultipleChoice from import_export import resources from import_export.admin import ImportExportModelAdmin from django.core.exceptions import ObjectDoesNotExist +from django.contrib.admin.widgets import FilteredSelectMultiple from django.utils.translation import gettext_lazy as _ @@ -90,6 +93,31 @@ class UserResource(resources.ModelResource): model = models.User +class FilteredSelectMultipleArrayWidget(FilteredSelectMultiple): + """Custom widget to allow for editing an ArrayField in a widget similar to filter_horizontal widget""" + + def __init__(self, verbose_name, is_stacked=False, choices=(), **kwargs): + super().__init__(verbose_name, is_stacked, **kwargs) + self.choices = choices + + def value_from_datadict(self, data, files, name): + values = super().value_from_datadict(data, files, name) + return values or [] + + def get_context(self, name, value, attrs): + if value is None: + value = [] + elif isinstance(value, str): + value = value.split(",") + # alter self.choices to be a list of selected and unselected choices, based on value; + # order such that selected choices come before unselected choices + self.choices = [(choice, label) for choice, label in self.choices if choice in value] + [ + (choice, label) for choice, label in self.choices if choice not in value + ] + context = super().get_context(name, value, attrs) + return context + + class MyUserAdminForm(UserChangeForm): """This form utilizes the custom widget for its class's ManyToMany UIs. @@ -102,6 +130,14 @@ class MyUserAdminForm(UserChangeForm): widgets = { "groups": NoAutocompleteFilteredSelectMultiple("groups", False), "user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False), + "portfolio_roles": FilteredSelectMultipleArrayWidget( + "portfolio_roles", is_stacked=False, choices=User.UserPortfolioRoleChoices.choices + ), + "portfolio_additional_permissions": FilteredSelectMultipleArrayWidget( + "portfolio_additional_permissions", + is_stacked=False, + choices=User.UserPortfolioPermissionChoices.choices, + ), } def __init__(self, *args, **kwargs): @@ -652,18 +688,49 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): "is_superuser", "groups", "user_permissions", + "portfolio", + "portfolio_roles", + "portfolio_additional_permissions", ) }, ), ("Important dates", {"fields": ("last_login", "date_joined")}), ) + autocomplete_fields = [ + "portfolio", + ] + readonly_fields = ("verification_type",) - # Hide Username (uuid), Groups and Permissions - # Q: Now that we're using Groups and Permissions, - # do we expose those to analysts to view? analyst_fieldsets = ( + ( + None, + { + "fields": ( + "status", + "verification_type", + ) + }, + ), + ("User profile", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}), + ( + "Permissions", + { + "fields": ( + "is_active", + "groups", + "portfolio", + "portfolio_roles", + "portfolio_additional_permissions", + ) + }, + ), + ("Important dates", {"fields": ("last_login", "date_joined")}), + ) + + # TODO: delete after we merge organization feature + analyst_fieldsets_no_portfolio = ( ( None, { @@ -703,6 +770,27 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): "last_name", "title", "email", + "phone", + "Permissions", + "is_active", + "groups", + "Important dates", + "last_login", + "date_joined", + "portfolio", + "portfolio_roles", + "portfolio_additional_permissions", + ] + + # TODO: delete after we merge organization feature + analyst_readonly_fields_no_portfolio = [ + "User profile", + "first_name", + "middle_name", + "last_name", + "title", + "email", + "phone", "Permissions", "is_active", "groups", @@ -783,8 +871,12 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): # Show all fields for all access users return super().get_fieldsets(request, obj) elif request.user.has_perm("registrar.analyst_access_permission"): - # show analyst_fieldsets for analysts - return self.analyst_fieldsets + if flag_is_active(request, "organization_feature"): + # show analyst_fieldsets for analysts + return self.analyst_fieldsets + else: + # TODO: delete after we merge organization feature + return self.analyst_fieldsets_no_portfolio else: # any admin user should belong to either full_access_group # or cisa_analyst_group @@ -798,7 +890,11 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): else: # Return restrictive Read-only fields for analysts and # users who might not belong to groups - return self.analyst_readonly_fields + if flag_is_active(request, "organization_feature"): + return self.analyst_readonly_fields + else: + # TODO: delete after we merge organization feature + return self.analyst_readonly_fields_no_portfolio def change_view(self, request, object_id, form_url="", extra_context=None): """Add user's related domains and requests to context""" @@ -1001,6 +1097,16 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin): # Get the filtered values return super().changelist_view(request, extra_context=extra_context) + def save_model(self, request, obj, form, change): + # Clear warning messages before saving + storage = messages.get_messages(request) + storage.used = False + for message in storage: + if message.level == messages.WARNING: + storage.used = True + + return super().save_model(request, obj, form, change) + class SeniorOfficialAdmin(ListHeaderAdmin): """Custom Senior Official Admin class.""" @@ -1285,10 +1391,11 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): ] # Readonly fields for analysts and superusers - readonly_fields = ("other_contacts", "is_election_board", "federal_agency") + readonly_fields = ("other_contacts", "is_election_board") # Read only that we'll leverage for CISA Analysts analyst_readonly_fields = [ + "federal_agency", "creator", "type_of_work", "more_organization_information", @@ -1601,12 +1708,12 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): "current_websites", "alternative_domains", "is_election_board", - "federal_agency", "status_history", ) # Read only that we'll leverage for CISA Analysts analyst_readonly_fields = [ + "federal_agency", "creator", "about_your_organization", "requested_domain", @@ -2659,26 +2766,38 @@ class VerifiedByStaffAdmin(ListHeaderAdmin): class PortfolioAdmin(ListHeaderAdmin): - # NOTE: these are just placeholders. Not part of ACs (haven't been defined yet). Update in future tickets. + + 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." - # readonly_fields = [ - # "requestor", - # ] + # Creates select2 fields (with search bars) autocomplete_fields = [ "creator", "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 @@ -2687,7 +2806,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) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 0f7219913..d8bc21899 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -305,6 +305,8 @@ function addOrRemoveSessionBoolean(name, add){ // "to" select list checkToListThenInitWidget('id_groups_to', 0); checkToListThenInitWidget('id_user_permissions_to', 0); + checkToListThenInitWidget('id_portfolio_roles_to', 0); + checkToListThenInitWidget('id_portfolio_additional_permissions_to', 0); })(); // Function to check for the existence of the "to" select list element in the DOM, and if and when found, diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 7052d786f..a60a59673 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 * @@ -1140,6 +1168,7 @@ document.addEventListener('DOMContentLoaded', function() { const statusCheckboxes = document.querySelectorAll('input[name="filter-status"]'); const statusIndicator = document.querySelector('.domain__filter-indicator'); const statusToggle = document.querySelector('.usa-button--filter'); + const noPortfolioFlag = document.getElementById('no-portfolio-js-flag'); /** * Loads rows in the domains list, as well as updates pagination around the domains list @@ -1173,8 +1202,20 @@ document.addEventListener('DOMContentLoaded', function() { const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : ''; const expirationDateSortValue = expirationDate ? expirationDate.getTime() : ''; const actionUrl = domain.action_url; + const suborganization = domain.suborganization ? domain.suborganization : ''; const row = document.createElement('tr'); + + let markupForSuborganizationRow = ''; + + if (!noPortfolioFlag) { + markupForSuborganizationRow = ` + + ${suborganization} + + ` + } + row.innerHTML = ` ${domain.name} @@ -1195,6 +1236,7 @@ document.addEventListener('DOMContentLoaded', function() { + ${markupForSuborganizationRow}
{% endblock %} - {% block banner %} -
- -
- {% endblock banner %} +
+ {% block header %} + {% include "includes/header_selector.html" with logo_clickable=True %} + {% endblock header %} {% block wrapper %}
diff --git a/src/registrar/templates/django/admin/portfolio_change_form.html b/src/registrar/templates/django/admin/portfolio_change_form.html new file mode 100644 index 000000000..3a6f13ccf --- /dev/null +++ b/src/registrar/templates/django/admin/portfolio_change_form.html @@ -0,0 +1,34 @@ +{% extends 'django/admin/email_clipboard_change_form.html' %} +{% load i18n static %} + +{% block after_related_objects %} +
+

Associated groups and suborganizations

+
+
+

Domain groups

+ +
+
+

Suborganizations

+ +
+
+
+{% endblock %} 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/emails/status_change_rejected.txt b/src/registrar/templates/emails/status_change_rejected.txt index e377b12ff..12693deb9 100644 --- a/src/registrar/templates/emails/status_change_rejected.txt +++ b/src/registrar/templates/emails/status_change_rejected.txt @@ -42,13 +42,11 @@ Your domain request was rejected because we determined that {{ domain_request.or eligible for a .gov domain. .Gov domains are only available to official U.S.-based government organizations. +Learn more about eligibility for .gov domains +. -DEMONSTRATE ELIGIBILITY -If you can provide documentation that demonstrates your eligibility, reply to this email. -This can include links to (or copies of) your authorizing legislation, your founding -charter or bylaws, or other similar documentation. Without this, we can’t approve a -.gov domain for your organization. Learn more about eligibility for .gov domains -.{% elif domain_request.rejection_reason == 'naming_not_met' %} +If you have questions or comments, reply to this email. +{% elif domain_request.rejection_reason == 'naming_not_met' %} Your domain request was rejected because it does not meet our naming requirements. Domains should uniquely identify a government organization and be clear to the general public. Learn more about naming requirements for your type of organization diff --git a/src/registrar/templates/finish_profile_setup.html b/src/registrar/templates/finish_profile_setup.html index 6e35ad5da..61cc192bf 100644 --- a/src/registrar/templates/finish_profile_setup.html +++ b/src/registrar/templates/finish_profile_setup.html @@ -4,8 +4,8 @@ {% block title %} Finish setting up your profile | {% endblock %} {# Disable the redirect #} -{% block logo %} - {% include "includes/gov_extended_logo.html" with logo_clickable=user_finished_setup %} +{% block header %} + {% include "includes/header_selector.html" with logo_clickable=user_finished_setup %} {% endblock %} {# Add the new form #} diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html index a5ed4c86c..b79b69ebc 100644 --- a/src/registrar/templates/home.html +++ b/src/registrar/templates/home.html @@ -10,11 +10,11 @@ {# the entire logged in page goes here #} {% block homepage_content %} -
{% block messages %} {% include "includes/form_messages.html" %} {% endblock %} +

Manage your domains

{% comment %} @@ -32,26 +32,8 @@ {% include "includes/domains_table.html" %} {% include "includes/domain_requests_table.html" %} - {# Note: Reimplement this after MVP #} - - - - - +
{% endblock %} -
{% else %} {# not user.is_authenticated #} {# the entire logged out page goes here #} diff --git a/src/registrar/templates/includes/domain_requests_table.html b/src/registrar/templates/includes/domain_requests_table.html index 4f091ecf6..efebd1e28 100644 --- a/src/registrar/templates/includes/domain_requests_table.html +++ b/src/registrar/templates/includes/domain_requests_table.html @@ -2,6 +2,7 @@
+ {% if portfolio is None %}

Domain requests

@@ -12,6 +13,9 @@