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-manual.yaml b/.github/workflows/deploy-manual.yaml
index 874a37c3c..e0bbee436 100644
--- a/.github/workflows/deploy-manual.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'
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 fdfd1cfa3..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
@@ -35,7 +36,6 @@ on:
- ab
- rjm
- dk
- - ms
jobs:
reset-db:
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/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 dd27a31e6..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,
{
@@ -710,6 +777,26 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"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",
+ "Important dates",
+ "last_login",
+ "date_joined",
]
list_filter = (
@@ -784,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
@@ -799,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"""
@@ -1002,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."""
@@ -1286,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",
@@ -1602,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",
@@ -2660,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
@@ -2688,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..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();
-
})();
@@ -305,6 +284,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,
@@ -603,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/_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 a392fa903..9d707a533 100644
--- a/src/registrar/config/settings.py
+++ b/src/registrar/config/settings.py
@@ -241,9 +241,9 @@ 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",
],
},
},
@@ -664,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 2e5a531d1..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 portfolio_domains, portfolio_domain_requests
from api.views import available, get_current_federal, get_current_full
@@ -60,14 +60,19 @@ for step, view in [
urlpatterns = [
path("", views.index, name="home"),
path(
- "portfolio//domains/",
- portfolio_domains,
- name="portfolio-domains",
+ "domains/",
+ views.PortfolioDomainsView.as_view(),
+ name="domains",
),
path(
- "portfolio//domain_requests/",
- portfolio_domain_requests,
- name="portfolio-domain-requests",
+ "requests/",
+ views.PortfolioDomainRequestsView.as_view(),
+ name="domain-requests",
+ ),
+ path(
+ "organization/",
+ views.PortfolioOrganizationView.as_view(),
+ name="organization",
),
path(
"admin/logout/",
@@ -119,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 acea72c4e..861a4e701 100644
--- a/src/registrar/context_processors.py
+++ b/src/registrar/context_processors.py
@@ -50,13 +50,38 @@ 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)}
def add_has_profile_feature_flag_to_context(request):
return {"has_profile_feature_flag": flag_is_active(request, "profile_feature")}
+
+
+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:
+ 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": flag_is_active(request, "organization_feature"),
+ }
+ except AttributeError:
+ # Handles cases where request.user might not exist
+ return {
+ "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/migrations/0113_user_portfolio_user_portfolio_additional_permissions_and_more.py b/src/registrar/migrations/0113_user_portfolio_user_portfolio_additional_permissions_and_more.py
new file mode 100644
index 000000000..afab257a2
--- /dev/null
+++ b/src/registrar/migrations/0113_user_portfolio_user_portfolio_additional_permissions_and_more.py
@@ -0,0 +1,80 @@
+# Generated by Django 4.2.10 on 2024-07-22 19:19
+
+from django.conf import settings
+import django.contrib.postgres.fields
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("registrar", "0112_remove_contact_registrar_c_user_id_4059c4_idx_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="portfolio",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="user",
+ to="registrar.portfolio",
+ ),
+ ),
+ migrations.AddField(
+ 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"),
+ ("edit_domains", "User is a manager on a domain"),
+ ("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,
+ ),
+ ),
+ migrations.AddField(
+ model_name="user",
+ name="portfolio_roles",
+ field=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,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="portfolio",
+ name="creator",
+ field=models.ForeignKey(
+ help_text="Associated user",
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="created_portfolios",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ]
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/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/portfolio.py b/src/registrar/models/portfolio.py
index c72f95c33..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
@@ -23,7 +18,13 @@ class Portfolio(TimeStampedModel):
# Stores who created this model. If no creator is specified in DJA,
# then the creator will default to the current request user"""
- creator = models.ForeignKey("registrar.User", on_delete=models.PROTECT, help_text="Associated user", unique=False)
+ creator = models.ForeignKey(
+ "registrar.User",
+ on_delete=models.PROTECT,
+ help_text="Associated user",
+ related_name="created_portfolios",
+ unique=False,
+ )
notes = models.TextField(
null=True,
diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py
index 2c6a195e6..b1c9473db 100644
--- a/src/registrar/models/user.py
+++ b/src/registrar/models/user.py
@@ -4,7 +4,6 @@ from django.contrib.auth.models import AbstractUser
from django.db import models
from django.db.models import Q
-from registrar.models.portfolio import Portfolio
from registrar.models.user_domain_role import UserDomainRole
from .domain_invitation import DomainInvitation
@@ -12,6 +11,7 @@ from .transition_domain import TransitionDomain
from .verified_by_staff import VerifiedByStaff
from .domain import Domain
from .domain_request import DomainRequest
+from django.contrib.postgres.fields import ArrayField
from waffle.decorators import flag_is_active
from phonenumber_field.modelfields import PhoneNumberField # type: ignore
@@ -62,6 +62,52 @@ 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"
+
+ 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,
+ UserPortfolioPermissionChoices.VIEW_MEMBER,
+ UserPortfolioPermissionChoices.EDIT_MEMBER,
+ UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
+ UserPortfolioPermissionChoices.EDIT_REQUESTS,
+ UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
+ UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
+ ],
+ UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [
+ UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
+ UserPortfolioPermissionChoices.VIEW_MEMBER,
+ UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
+ UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
+ ],
+ UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
+ UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
+ ],
+ }
+
# #### Constants for choice fields ####
RESTRICTED = "restricted"
STATUS_CHOICES = ((RESTRICTED, RESTRICTED),)
@@ -82,6 +128,34 @@ class User(AbstractUser):
related_name="users",
)
+ portfolio = models.ForeignKey(
+ "registrar.Portfolio",
+ null=True,
+ blank=True,
+ related_name="user",
+ on_delete=models.SET_NULL,
+ )
+
+ 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.",
+ )
+
phone = PhoneNumberField(
null=True,
blank=True,
@@ -172,6 +246,45 @@ class User(AbstractUser):
def has_contact_info(self):
return bool(self.title or self.email or self.phone)
+ def _get_portfolio_permissions(self):
+ """
+ Retrieve the permissions for the user's portfolio roles.
+ """
+ portfolio_permissions = set() # Use a set to avoid duplicate permissions
+
+ if self.portfolio_roles:
+ for role in self.portfolio_roles:
+ if role in self.PORTFOLIO_ROLE_PERMISSIONS:
+ portfolio_permissions.update(self.PORTFOLIO_ROLE_PERMISSIONS[role])
+ if self.portfolio_additional_permissions:
+ portfolio_permissions.update(self.portfolio_additional_permissions)
+ return list(portfolio_permissions) # Convert back to list if necessary
+
+ def _has_portfolio_permission(self, portfolio_permission):
+ """The views should only call this function when testing for perms and not rely on roles."""
+
+ if not self.portfolio:
+ return False
+
+ portfolio_permissions = self._get_portfolio_permissions()
+
+ return portfolio_permission in portfolio_permissions
+
+ # 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)
+
+ 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)
+
+ 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)
+
@classmethod
def needs_identity_verification(cls, email, uuid):
"""A method used by our oidc classes to test whether a user needs email/uuid verification
@@ -293,6 +406,4 @@ class User(AbstractUser):
def is_org_user(self, request):
has_organization_feature_flag = flag_is_active(request, "organization_feature")
- user_portfolios_exist = Portfolio.objects.filter(creator=self).exists()
-
- return has_organization_feature_flag and user_portfolios_exist
+ return has_organization_feature_flag and self.has_base_portfolio_permission()
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/registrar_middleware.py b/src/registrar/registrar_middleware.py
index 80d1fe7a9..2af331bc9 100644
--- a/src/registrar/registrar_middleware.py
+++ b/src/registrar/registrar_middleware.py
@@ -6,7 +6,6 @@ import logging
from urllib.parse import parse_qs
from django.urls import reverse
from django.http import HttpResponseRedirect
-from registrar.models.portfolio import Portfolio
from registrar.models.user import User
from waffle.decorators import flag_is_active
@@ -141,16 +140,20 @@ class CheckPortfolioMiddleware:
def process_view(self, request, view_func, view_args, view_kwargs):
current_path = request.path
- if request.user.is_authenticated and request.user.is_org_user(request):
- user_portfolios = Portfolio.objects.filter(creator=request.user)
- first_portfolio = user_portfolios.first()
+ if current_path == self.home and request.user.is_authenticated and request.user.is_org_user(request):
+
+ if request.user.has_base_portfolio_permission():
+ portfolio = request.user.portfolio
- if first_portfolio:
# Add the portfolio to the request object
- request.portfolio = first_portfolio
+ request.portfolio = portfolio
- if current_path == self.home:
- home_with_portfolio = reverse("portfolio-domains", kwargs={"portfolio_id": first_portfolio.id})
- return HttpResponseRedirect(home_with_portfolio)
+ if request.user.has_domains_portfolio_permission():
+ portfolio_redirect = reverse("domains")
+ else:
+ # View organization is the lowest access
+ portfolio_redirect = reverse("organization")
+
+ return HttpResponseRedirect(portfolio_redirect)
return None
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 %}
-
{% 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