diff --git a/.github/workflows/deploy-development.yaml b/.github/workflows/deploy-development.yaml index 686635c20..e62607d95 100644 --- a/.github/workflows/deploy-development.yaml +++ b/.github/workflows/deploy-development.yaml @@ -22,9 +22,16 @@ jobs: - name: Compile USWDS assets working-directory: ./src run: | - docker compose run node npm install && - docker compose run node npx gulp copyAssets && - docker compose run node npx gulp compile + docker compose run node bash -c "\ + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash && \ + export NVM_DIR=\"\$HOME/.nvm\" && \ + [ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" && \ + [ -s \"\$NVM_DIR/bash_completion\" ] && \. \"\$NVM_DIR/bash_completion\" && \ + nvm install 21.7.3 && \ + nvm use 21.7.3 && \ + npm install && \ + npx gulp copyAssets && \ + npx gulp compile" - name: Collect static assets working-directory: ./src run: docker compose run app python manage.py collectstatic --no-input @@ -45,4 +52,4 @@ jobs: cf_password: ${{ secrets.CF_DEVELOPMENT_PASSWORD }} cf_org: cisa-dotgov cf_space: development - cf_command: "run-task getgov-development --command 'python manage.py migrate' --name migrate" + cf_command: "run-task getgov-development --command 'python manage.py migrate' --name migrate" \ No newline at end of file diff --git a/.github/workflows/deploy-sandbox.yaml b/.github/workflows/deploy-sandbox.yaml index f7f4a0d65..d9d7cbe14 100644 --- a/.github/workflows/deploy-sandbox.yaml +++ b/.github/workflows/deploy-sandbox.yaml @@ -42,9 +42,16 @@ jobs: - name: Compile USWDS assets working-directory: ./src run: | - docker compose run node npm install && - docker compose run node npx gulp copyAssets && - docker compose run node npx gulp compile + docker compose run node bash -c "\ + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash && \ + export NVM_DIR=\"\$HOME/.nvm\" && \ + [ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" && \ + [ -s \"\$NVM_DIR/bash_completion\" ] && \. \"\$NVM_DIR/bash_completion\" && \ + nvm install 21.7.3 && \ + nvm use 21.7.3 && \ + npm install && \ + npx gulp copyAssets && \ + npx gulp compile" - name: Collect static assets working-directory: ./src run: docker compose run app python manage.py collectstatic --no-input @@ -75,4 +82,4 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, body: '🥳 Successfully deployed to developer sandbox **[${{ env.ENVIRONMENT }}](https://getgov-${{ env.ENVIRONMENT }}.app.cloud.gov/)**.' - }) + }) \ No newline at end of file diff --git a/.github/workflows/deploy-stable.yaml b/.github/workflows/deploy-stable.yaml index 0ded4a3a6..9d0573e01 100644 --- a/.github/workflows/deploy-stable.yaml +++ b/.github/workflows/deploy-stable.yaml @@ -23,9 +23,16 @@ jobs: - name: Compile USWDS assets working-directory: ./src run: | - docker compose run node npm install && - docker compose run node npx gulp copyAssets && - docker compose run node npx gulp compile + docker compose run node bash -c "\ + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash && \ + export NVM_DIR=\"\$HOME/.nvm\" && \ + [ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" && \ + [ -s \"\$NVM_DIR/bash_completion\" ] && \. \"\$NVM_DIR/bash_completion\" && \ + nvm install 21.7.3 && \ + nvm use 21.7.3 && \ + npm install && \ + npx gulp copyAssets && \ + npx gulp compile" - name: Collect static assets working-directory: ./src run: docker compose run app python manage.py collectstatic --no-input diff --git a/.github/workflows/deploy-staging.yaml b/.github/workflows/deploy-staging.yaml index 1df08f412..9584985f0 100644 --- a/.github/workflows/deploy-staging.yaml +++ b/.github/workflows/deploy-staging.yaml @@ -23,9 +23,16 @@ jobs: - name: Compile USWDS assets working-directory: ./src run: | - docker compose run node npm install && - docker compose run node npx gulp copyAssets && - docker compose run node npx gulp compile + docker compose run node bash -c "\ + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash && \ + export NVM_DIR=\"\$HOME/.nvm\" && \ + [ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" && \ + [ -s \"\$NVM_DIR/bash_completion\" ] && \. \"\$NVM_DIR/bash_completion\" && \ + nvm install 21.7.3 && \ + nvm use 21.7.3 && \ + npm install && \ + npx gulp copyAssets && \ + npx gulp compile" - name: Collect static assets working-directory: ./src run: docker compose run app python manage.py collectstatic --no-input @@ -44,4 +51,4 @@ jobs: cf_password: ${{ secrets.CF_STAGING_PASSWORD }} cf_org: cisa-dotgov cf_space: staging - cf_command: "run-task getgov-staging --command 'python manage.py migrate' --name migrate" + cf_command: "run-task getgov-staging --command 'python manage.py migrate' --name migrate" \ No newline at end of file diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md index 0846208de..e4543a28c 100644 --- a/docs/operations/data_migration.md +++ b/docs/operations/data_migration.md @@ -602,18 +602,18 @@ That data are synthesized from the generic_org_type field and the is_election_bo The latest domain_election_board csv can be found [here](https://drive.google.com/file/d/1aDeCqwHmBnXBl2arvoFCN0INoZmsEGsQ/view). After downloading this file, place it in `src/migrationdata` -#### Step 2: Upload the domain_election_board file to your sandbox +#### Step 3: Upload the domain_election_board file to your sandbox Follow [Step 1: Transfer data to sandboxes](#step-1-transfer-data-to-sandboxes) and [Step 2: Transfer uploaded files to the getgov directory](#step-2-transfer-uploaded-files-to-the-getgov-directory) from the [Set Up Migrations on Sandbox](#set-up-migrations-on-sandbox) portion of this doc. -#### Step 2: SSH into your environment +#### Step 4: SSH into your environment ```cf ssh getgov-{space}``` Example: `cf ssh getgov-za` -#### Step 3: Create a shell instance +#### Step 5: Create a shell instance ```/tmp/lifecycle/shell``` -#### Step 4: Running the script +#### Step 6: Running the script ```./manage.py populate_organization_type {domain_election_board_filename}``` - The domain_election_board_filename file must adhere to this format: @@ -642,3 +642,29 @@ Example (assuming that this is being ran from src/): | | Parameter | Description | |:-:|:------------------------------------|:-------------------------------------------------------------------| | 1 | **domain_election_board_filename** | A file containing every domain that is an election office. + + +## Populate Verification Type +This section outlines how to run the `populate_verification_type` script. +The script is used to update the verification_type field on User when it is None. + +### Running on sandboxes + +#### Step 1: Login to CloudFoundry +```cf login -a api.fr.cloud.gov --sso``` + +#### Step 2: SSH into your environment +```cf ssh getgov-{space}``` + +Example: `cf ssh getgov-za` + +#### Step 3: Create a shell instance +```/tmp/lifecycle/shell``` + +#### Step 4: Running the script +```./manage.py populate_verification_type``` + +### Running locally + +#### Step 1: Running the script +```docker-compose exec app ./manage.py populate_verification_type``` diff --git a/src/djangooidc/tests/test_views.py b/src/djangooidc/tests/test_views.py index f10afcbaf..bdd61b346 100644 --- a/src/djangooidc/tests/test_views.py +++ b/src/djangooidc/tests/test_views.py @@ -4,8 +4,10 @@ from django.http import HttpResponse from django.test import Client, TestCase, RequestFactory from django.urls import reverse +from api.tests.common import less_console_noise_decorator from djangooidc.exceptions import StateMismatch, InternalError from ..views import login_callback +from registrar.models import User, Contact, VerifiedByStaff, DomainInvitation, TransitionDomain, Domain from .common import less_console_noise @@ -16,6 +18,14 @@ class ViewsTest(TestCase): self.client = Client() self.factory = RequestFactory() + def tearDown(self): + User.objects.all().delete() + Contact.objects.all().delete() + DomainInvitation.objects.all().delete() + VerifiedByStaff.objects.all().delete() + TransitionDomain.objects.all().delete() + Domain.objects.all().delete() + def say_hi(*args): return HttpResponse("Hi") @@ -229,6 +239,140 @@ class ViewsTest(TestCase): self.assertEqual(response.status_code, 302) self.assertEqual(response.url, "/") + @less_console_noise_decorator + def test_login_callback_sets_verification_type_regular(self, mock_client): + """ + Test that openid sets the verification type to regular on the returned user. + Regular, in this context, means that this user was "Verifed by Login.gov" + """ + # SETUP + session = self.client.session + session.save() + # MOCK + # mock that callback returns user_info; this is the expected behavior + mock_client.callback.side_effect = self.user_info + # patch that the request does not require step up auth + with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch( + "djangooidc.views._initialize_client" + ) as mock_init_client: + with patch("djangooidc.views._client_is_none", return_value=True): + # TEST + # test the login callback url + response = self.client.get(reverse("openid_login_callback")) + + # assert that _initialize_client was called + mock_init_client.assert_called_once() + + # Assert that we get a redirect + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "/") + + # Test the created user object + created_user = User.objects.get(email="test@example.com") + self.assertEqual(created_user.verification_type, User.VerificationTypeChoices.REGULAR) + + @less_console_noise_decorator + def test_login_callback_sets_verification_type_invited(self, mock_client): + """Test that openid sets the verification type to invited on the returned user + when they exist in the DomainInvitation table""" + # SETUP + session = self.client.session + session.save() + + domain, _ = Domain.objects.get_or_create(name="test123.gov") + invitation, _ = DomainInvitation.objects.get_or_create(email="test@example.com", domain=domain) + # MOCK + # mock that callback returns user_info; this is the expected behavior + mock_client.callback.side_effect = self.user_info + # patch that the request does not require step up auth + with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch( + "djangooidc.views._initialize_client" + ) as mock_init_client: + with patch("djangooidc.views._client_is_none", return_value=True): + # TEST + # test the login callback url + response = self.client.get(reverse("openid_login_callback")) + + # assert that _initialize_client was called + mock_init_client.assert_called_once() + + # Assert that we get a redirect + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "/") + + # Test the created user object + created_user = User.objects.get(email="test@example.com") + self.assertEqual(created_user.email, invitation.email) + self.assertEqual(created_user.verification_type, User.VerificationTypeChoices.INVITED) + + @less_console_noise_decorator + def test_login_callback_sets_verification_type_grandfathered(self, mock_client): + """Test that openid sets the verification type to grandfathered + on a user which exists in our TransitionDomain table""" + # SETUP + session = self.client.session + session.save() + # MOCK + # mock that callback returns user_info; this is the expected behavior + mock_client.callback.side_effect = self.user_info + + td, _ = TransitionDomain.objects.get_or_create(username="test@example.com", domain_name="test123.gov") + + # patch that the request does not require step up auth + with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch( + "djangooidc.views._initialize_client" + ) as mock_init_client: + with patch("djangooidc.views._client_is_none", return_value=True): + # TEST + # test the login callback url + response = self.client.get(reverse("openid_login_callback")) + + # assert that _initialize_client was called + mock_init_client.assert_called_once() + + # Assert that we get a redirect + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "/") + + # Test the created user object + created_user = User.objects.get(email="test@example.com") + self.assertEqual(created_user.email, td.username) + self.assertEqual(created_user.verification_type, User.VerificationTypeChoices.GRANDFATHERED) + + @less_console_noise_decorator + def test_login_callback_sets_verification_type_verified_by_staff(self, mock_client): + """Test that openid sets the verification type to verified_by_staff + on a user which exists in our VerifiedByStaff table""" + # SETUP + session = self.client.session + session.save() + # MOCK + # mock that callback returns user_info; this is the expected behavior + mock_client.callback.side_effect = self.user_info + + vip, _ = VerifiedByStaff.objects.get_or_create(email="test@example.com") + + # patch that the request does not require step up auth + with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch( + "djangooidc.views._initialize_client" + ) as mock_init_client: + with patch("djangooidc.views._client_is_none", return_value=True): + # TEST + # test the login callback url + response = self.client.get(reverse("openid_login_callback")) + + # assert that _initialize_client was called + mock_init_client.assert_called_once() + + # Assert that we get a redirect + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "/") + + # Test the created user object + created_user = User.objects.get(email="test@example.com") + self.assertEqual(created_user.email, vip.email) + self.assertEqual(created_user.verification_type, User.VerificationTypeChoices.VERIFIED_BY_STAFF) + def test_login_callback_no_step_up_auth(self, mock_client): """Walk through login_callback when _requires_step_up_auth returns False and assert that we have a redirect to /""" diff --git a/src/djangooidc/views.py b/src/djangooidc/views.py index ab81ccff1..815df4ecf 100644 --- a/src/djangooidc/views.py +++ b/src/djangooidc/views.py @@ -99,8 +99,22 @@ def login_callback(request): return CLIENT.create_authn_request(request.session) user = authenticate(request=request, **userinfo) if user: + + # Fixture users kind of exist in a superposition of verification types, + # because while the system "verified" them, if they login, + # we don't know how the user themselves was verified through login.gov until + # they actually try logging in. This edge-case only matters in non-production environments. + fixture_user = User.VerificationTypeChoices.FIXTURE_USER + is_fixture_user = user.verification_type and user.verification_type == fixture_user + + # Set the verification type if it doesn't already exist or if its a fixture user + if not user.verification_type or is_fixture_user: + user.set_user_verification_type() + user.save() + login(request, user) logger.info("Successfully logged in user %s" % user) + # Clear the flag if the exception is not caught request.session.pop("redirect_attempted", None) return redirect(request.session.get("next", "/")) diff --git a/src/node.Dockerfile b/src/node.Dockerfile index b478a8a26..cf0b6acc6 100644 --- a/src/node.Dockerfile +++ b/src/node.Dockerfile @@ -1,5 +1,5 @@ FROM docker.io/cimg/node:current-browsers - +FROM node:21.7.3 WORKDIR /app # Install app dependencies @@ -7,4 +7,6 @@ WORKDIR /app # where available (npm@5+) COPY --chown=circleci:circleci package*.json ./ -RUN npm install + +RUN npm install -g npm@10.5.0 +RUN npm install \ No newline at end of file diff --git a/src/package-lock.json b/src/package-lock.json index dc1464ee8..9df99a739 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -15,6 +15,10 @@ }, "devDependencies": { "@uswds/compile": "^1.0.0-beta.3" + }, + "engines": { + "node": "21.7.3", + "npm": "10.5.0" } }, "node_modules/@gulp-sourcemaps/identity-map": { diff --git a/src/package.json b/src/package.json index 274e0e282..3afce297f 100644 --- a/src/package.json +++ b/src/package.json @@ -3,6 +3,11 @@ "version": "1.0.0", "description": "========================", "main": "index.js", + "engines": { + "node": "21.7.3", + "npm": "10.5.0" + }, + "engineStrict": true, "scripts": { "pa11y-ci": "pa11y-ci", "test": "echo \"Error: no test specified\" && exit 1" @@ -17,4 +22,4 @@ "devDependencies": { "@uswds/compile": "^1.0.0-beta.3" } -} +} \ No newline at end of file diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 5433169a6..485751b3c 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -537,7 +537,7 @@ class MyUserAdmin(BaseUserAdmin): fieldsets = ( ( None, - {"fields": ("username", "password", "status")}, + {"fields": ("username", "password", "status", "verification_type")}, ), ("Personal Info", {"fields": ("first_name", "last_name", "email")}), ( @@ -555,13 +555,20 @@ class MyUserAdmin(BaseUserAdmin): ("Important dates", {"fields": ("last_login", "date_joined")}), ) + readonly_fields = ("verification_type",) + # Hide Username (uuid), Groups and Permissions # Q: Now that we're using Groups and Permissions, # do we expose those to analysts to view? analyst_fieldsets = ( ( None, - {"fields": ("status",)}, + { + "fields": ( + "status", + "verification_type", + ) + }, ), ("Personal Info", {"fields": ("first_name", "last_name", "email")}), ( @@ -681,11 +688,14 @@ class MyUserAdmin(BaseUserAdmin): return [] def get_readonly_fields(self, request, obj=None): + readonly_fields = list(self.readonly_fields) + if request.user.has_perm("registrar.full_access_permission"): - return () # No read-only fields for all access users - # Return restrictive Read-only fields for analysts and - # users who might not belong to groups - return self.analyst_readonly_fields + return readonly_fields + else: + # Return restrictive Read-only fields for analysts and + # users who might not belong to groups + return self.analyst_readonly_fields class HostIPInline(admin.StackedInline): @@ -1001,9 +1011,10 @@ class DomainInformationAdmin(ListHeaderAdmin): }, ), ( - "More details", + "Show details", { - "classes": ["collapse"], + "classes": ["collapse--dotgov"], + "description": "Extends type of organization", "fields": [ "federal_type", # "updated_federal_agency", @@ -1026,9 +1037,10 @@ class DomainInformationAdmin(ListHeaderAdmin): }, ), ( - "More details", + "Show details", { - "classes": ["collapse"], + "classes": ["collapse--dotgov"], + "description": "Extends organization name and mailing address", "fields": [ "address_line1", "address_line2", @@ -1252,9 +1264,10 @@ class DomainRequestAdmin(ListHeaderAdmin): }, ), ( - "More details", + "Show details", { - "classes": ["collapse"], + "classes": ["collapse--dotgov"], + "description": "Extends type of organization", "fields": [ "federal_type", # "updated_federal_agency", @@ -1277,9 +1290,10 @@ class DomainRequestAdmin(ListHeaderAdmin): }, ), ( - "More details", + "Show details", { - "classes": ["collapse"], + "classes": ["collapse--dotgov"], + "description": "Extends organization name and mailing address", "fields": [ "address_line1", "address_line2", diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 126ab0a2a..f38afd252 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -233,10 +233,8 @@ function openInNewTab(el, removeAttribute = false){ // Initialize custom filter_horizontal widgets; each widget has a "from" select list // and a "to" select list; initialization is based off of the presence of the // "to" select list - checkToListThenInitWidget('id_other_contacts_to', 0); - checkToListThenInitWidget('id_domain_info-0-other_contacts_to', 0); - checkToListThenInitWidget('id_current_websites_to', 0); - checkToListThenInitWidget('id_alternative_domains_to', 0); + checkToListThenInitWidget('id_groups_to', 0); + checkToListThenInitWidget('id_user_permissions_to', 0); })(); // Function to check for the existence of the "to" select list element in the DOM, and if and when found, @@ -245,215 +243,56 @@ function checkToListThenInitWidget(toListId, attempts) { let toList = document.getElementById(toListId); attempts++; - if (attempts < 6) { - if ((toList !== null)) { + if (attempts < 12) { + if (toList) { // toList found, handle it - // Add an event listener on the element - // Add disabled buttons on the element's great-grandparent - initializeWidgetOnToList(toList, toListId); + // Then get fromList and handle it + initializeWidgetOnList(toList, ".selector-chosen"); + let fromList = toList.closest('.selector').querySelector(".selector-available select"); + initializeWidgetOnList(fromList, ".selector-available"); } else { // Element not found, check again after a delay - setTimeout(() => checkToListThenInitWidget(toListId, attempts), 1000); // Check every 1000 milliseconds (1 second) + setTimeout(() => checkToListThenInitWidget(toListId, attempts), 300); // Check every 300 milliseconds } } } // Initialize the widget: -// add related buttons to the widget for edit, delete and view -// add event listeners on the from list, the to list, and selector buttons which either enable or disable the related buttons -function initializeWidgetOnToList(toList, toListId) { - // create the change button - let changeLink = createAndCustomizeLink( - toList, - toListId, - 'related-widget-wrapper-link change-related', - 'Change', - '/public/admin/img/icon-changelink.svg', - { - 'contacts': '/admin/registrar/contact/__fk__/change/?_to_field=id&_popup=1', - 'websites': '/admin/registrar/website/__fk__/change/?_to_field=id&_popup=1', - 'alternative_domains': '/admin/registrar/website/__fk__/change/?_to_field=id&_popup=1', - }, - true, - true - ); +// Replace h2 with more semantic h3 +function initializeWidgetOnList(list, parentId) { + if (list) { + // Get h2 and its container + const parentElement = list.closest(parentId); + const h2Element = parentElement.querySelector('h2'); - let hasDeletePermission = hasDeletePermissionOnPage(); + // One last check + if (parentElement && h2Element) { + // Create a new

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

element to the

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

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

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

element with the new

element + parentElement.replaceChild(h3Element, h2Element); } } - - // Return the link, which we'll use in the disable and enable functions - return link; -} - -// Either enable or disable widget buttons when select is clicked. Action (enable or disable) taken depends on the count -// of selected items in selectElement. If exactly one item is selected, buttons are enabled, and urls for the buttons are -// associated with the selected item -function handleSelectClick(selectElement, changeLink, deleteLink, viewLink) { - - // If one item is selected (across selectElement and relatedSelectElement), enable buttons; otherwise, disable them - if (selectElement.selectedOptions.length === 1) { - // enable buttons for selected item in selectElement - enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, selectElement.selectedOptions[0].value, selectElement.selectedOptions[0].text); - } else { - disableRelatedWidgetButtons(changeLink, deleteLink, viewLink); - } -} - -// return true if there exist elements on the page with classname of delete-related. -// presence of one or more of these elements indicates user has permission to delete -function hasDeletePermissionOnPage() { - return document.querySelector('.delete-related') != null -} - -function disableRelatedWidgetButtons(changeLink, deleteLink, viewLink) { - changeLink.removeAttribute('href'); - changeLink.setAttribute('title', changeLink.getAttribute('title-template')); - if (deleteLink) { - deleteLink.removeAttribute('href'); - deleteLink.setAttribute('title', deleteLink.getAttribute('title-template')); - } - viewLink.removeAttribute('href'); - viewLink.setAttribute('title', viewLink.getAttribute('title-template')); -} - -function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk, elementText) { - changeLink.setAttribute('href', changeLink.getAttribute('data-href-template').replace('__fk__', elementPk)); - changeLink.setAttribute('title', changeLink.getAttribute('title-template').replace('selected item', elementText)); - if (deleteLink) { - deleteLink.setAttribute('href', deleteLink.getAttribute('data-href-template').replace('__fk__', elementPk)); - deleteLink.setAttribute('title', deleteLink.getAttribute('title-template').replace('selected item', elementText)); - } - viewLink.setAttribute('href', viewLink.getAttribute('data-href-template').replace('__fk__', elementPk)); - viewLink.setAttribute('title', viewLink.getAttribute('title-template').replace('selected item', elementText)); } /** An IIFE for admin in DjangoAdmin to listen to changes on the domain request diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index e0db9ac57..b4b590acb 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -542,17 +542,30 @@ address.dja-address-contact-list { } // Collapse button styles for fieldsets -.module.collapse { +.module.collapse--dotgov { margin-top: -35px; padding-top: 0; border: none; - h2 { + button { background: none; - color: var(--body-fg)!important; text-transform: none; - } - a { color: var(--link-fg); + margin-top: 8px; + margin-left: 10px; + span { + text-decoration: underline; + font-size: 13px; + font-feature-settings: "kern"; + font-kerning: normal; + line-height: 13px; + font-family: -apple-system, "system-ui", "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + } + } +} +.collapse--dotgov.collapsed .collapse-toggle--dotgov { + display: inline-block!important; + * { + display: inline-block; } } @@ -634,3 +647,32 @@ address.dja-address-contact-list { .usa-button__small-text { font-size: small; } + +// Get rid of padding on all help texts +form .aligned p.help, form .aligned div.help { + padding-left: 0px !important; +} + +// We override the DJA header on multi list selects from h2 to h3 +// The following block of code styles our generated h3s to match the old h2s +.selector .selector-available h3 { + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +.selector-available h3, .selector-chosen h3 { + border: 1px solid var(--border-color); + border-radius: 4px 4px 0 0; + margin: 0; + padding: 8px; + font-size: 0.8125rem; + text-align: left; + margin: 0; + padding: 8px; + line-height: 1.3; +} + +.selector .selector-chosen h3 { + background: var(--primary); + color: var(--header-link-color); +} diff --git a/src/registrar/assets/sass/_theme/_links.scss b/src/registrar/assets/sass/_theme/_links.scss new file mode 100644 index 000000000..6d2e75a68 --- /dev/null +++ b/src/registrar/assets/sass/_theme/_links.scss @@ -0,0 +1,21 @@ +@use "uswds-core" as *; + +.dotgov-table { + a { + display: flex; + align-items: flex-start; + color: color('primary'); + + &:visited { + color: color('primary'); + } + } +} + +a { + .usa-icon { + // align icon with x height + margin-top: units(0.5); + margin-right: units(0.5); + } +} diff --git a/src/registrar/assets/sass/_theme/_tables.scss b/src/registrar/assets/sass/_theme/_tables.scss index 0d58b5878..26d90d291 100644 --- a/src/registrar/assets/sass/_theme/_tables.scss +++ b/src/registrar/assets/sass/_theme/_tables.scss @@ -56,22 +56,6 @@ .dotgov-table { width: 100%; - a { - display: flex; - align-items: flex-start; - color: color('primary'); - - &:visited { - color: color('primary'); - } - - .usa-icon { - // align icon with x height - margin-top: units(0.5); - margin-right: units(0.5); - } - } - th[data-sortable]:not([aria-sort]) .usa-table__header__button { right: auto; } @@ -108,12 +92,51 @@ padding: units(2) units(2) units(2) 0; } - th:first-of-type { - padding-left: 0; - } - thead tr:first-child th:first-child { border-top: none; } } } +@media (min-width: 1040px){ + .dotgov-table__domain-requests { + th:nth-of-type(1) { + width: 200px; + } + + th:nth-of-type(2) { + width: 158px; + } + + th:nth-of-type(3) { + width: 120px; + } + + th:nth-of-type(4) { + width: 95px; + } + + th:nth-of-type(5) { + width: 85px; + } + } +} + +@media (min-width: 1040px){ + .dotgov-table__registered-domains { + th:nth-of-type(1) { + width: 200px; + } + + th:nth-of-type(2) { + width: 158px; + } + + th:nth-of-type(3) { + width: 215px; + } + + th:nth-of-type(4) { + width: 95px; + } + } +} \ No newline at end of file diff --git a/src/registrar/assets/sass/_theme/styles.scss b/src/registrar/assets/sass/_theme/styles.scss index 942501110..64b113a29 100644 --- a/src/registrar/assets/sass/_theme/styles.scss +++ b/src/registrar/assets/sass/_theme/styles.scss @@ -10,6 +10,7 @@ --- Custom Styles ---------------------------------*/ @forward "base"; @forward "typography"; +@forward "links"; @forward "lists"; @forward "buttons"; @forward "forms"; diff --git a/src/registrar/fixtures_users.py b/src/registrar/fixtures_users.py index d01cb48e5..7f991fa0e 100644 --- a/src/registrar/fixtures_users.py +++ b/src/registrar/fixtures_users.py @@ -7,6 +7,7 @@ from registrar.models import ( UserGroup, ) + fake = Faker() logger = logging.getLogger(__name__) @@ -207,6 +208,10 @@ class UserFixture: user.email = user_data["email"] user.is_staff = True user.is_active = True + # This verification type will get reverted to "regular" (or whichever is applicables) + # once the user logs in for the first time (as they then got verified through different means). + # In the meantime, we can still describe how the user got here in the first place. + user.verification_type = User.VerificationTypeChoices.FIXTURE_USER group = UserGroup.objects.get(name=group_name) user.groups.add(group) user.save() diff --git a/src/registrar/management/commands/populate_verification_type.py b/src/registrar/management/commands/populate_verification_type.py new file mode 100644 index 000000000..b61521977 --- /dev/null +++ b/src/registrar/management/commands/populate_verification_type.py @@ -0,0 +1,23 @@ +import logging +from django.core.management import BaseCommand +from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors +from registrar.models import User + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand, PopulateScriptTemplate): + help = "Loops through each valid User object and updates its verification_type value" + + def handle(self, **kwargs): + """Loops through each valid User object and updates its verification_type value""" + filter_condition = {"verification_type__isnull": True} + self.mass_populate_field(User, filter_condition, ["verification_type"]) + + def populate_field(self, field_to_update): + """Defines how we update the verification_type field""" + field_to_update.set_user_verification_type() + logger.info( + f"{TerminalColors.OKCYAN}Updating {field_to_update} => " + f"{field_to_update.verification_type}{TerminalColors.OKCYAN}" + ) diff --git a/src/registrar/management/commands/utility/terminal_helper.py b/src/registrar/management/commands/utility/terminal_helper.py index b54209750..db3e4a9d3 100644 --- a/src/registrar/management/commands/utility/terminal_helper.py +++ b/src/registrar/management/commands/utility/terminal_helper.py @@ -1,5 +1,6 @@ import logging import sys +from abc import ABC, abstractmethod from django.core.paginator import Paginator from typing import List from registrar.utility.enums import LogCode @@ -58,6 +59,55 @@ class ScriptDataHelper: model_class.objects.bulk_update(page.object_list, fields_to_update) +class PopulateScriptTemplate(ABC): + """ + Contains an ABC for generic populate scripts + """ + + def mass_populate_field(self, sender, filter_conditions, fields_to_update): + """Loops through each valid "sender" object - specified by filter_conditions - and + updates fields defined by fields_to_update using populate_function. + + You must define populate_field before you can use this function. + """ + + objects = sender.objects.filter(**filter_conditions) + + # Code execution will stop here if the user prompts "N" + TerminalHelper.prompt_for_execution( + system_exit_on_terminate=True, + info_to_inspect=f""" + ==Proposed Changes== + Number of {sender} objects to change: {len(objects)} + These fields will be updated on each record: {fields_to_update} + """, + prompt_title="Do you wish to patch this data?", + ) + logger.info("Updating...") + + to_update: List[sender] = [] + failed_to_update: List[sender] = [] + for updated_object in objects: + try: + self.populate_field(updated_object) + to_update.append(updated_object) + except Exception as err: + failed_to_update.append(updated_object) + logger.error(err) + logger.error(f"{TerminalColors.FAIL}" f"Failed to update {updated_object}" f"{TerminalColors.ENDC}") + + # Do a bulk update on the first_ready field + ScriptDataHelper.bulk_update_fields(sender, to_update, fields_to_update) + + # Log what happened + TerminalHelper.log_script_run_summary(to_update, failed_to_update, skipped=[], debug=True) + + @abstractmethod + def populate_field(self, field_to_update): + """Defines how we update each field. Must be defined before using mass_populate_field.""" + raise NotImplementedError + + class TerminalHelper: @staticmethod def log_script_run_summary(to_update, failed_to_update, skipped, debug: bool, log_header=None): diff --git a/src/registrar/migrations/0089_user_verification_type.py b/src/registrar/migrations/0089_user_verification_type.py new file mode 100644 index 000000000..e021e89e1 --- /dev/null +++ b/src/registrar/migrations/0089_user_verification_type.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.10 on 2024-04-26 14:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0088_domaininformation_cisa_representative_email_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="verification_type", + field=models.CharField( + blank=True, + choices=[ + ("grandfathered", "Legacy user"), + ("verified_by_staff", "Verified by staff"), + ("regular", "Verified by Login.gov"), + ("invited", "Invited by a domain manager"), + ("fixture_user", "Created by fixtures"), + ], + help_text="The means through which this user was verified", + null=True, + ), + ), + ] diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 165cad4c5..5e4c88f63 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -2,6 +2,7 @@ import logging from django.contrib.auth.models import AbstractUser from django.db import models +from django.db.models import Q from registrar.models.user_domain_role import UserDomainRole @@ -23,6 +24,28 @@ class User(AbstractUser): but can be customized later. """ + class VerificationTypeChoices(models.TextChoices): + """ + Users achieve access to our system in a few different ways. + These choices reflect those pathways. + + Overview of verification types: + - GRANDFATHERED: User exists in the `TransitionDomain` table + - VERIFIED_BY_STAFF: User exists in the `VerifiedByStaff` table + - INVITED: User exists in the `DomainInvitation` table + - REGULAR: User was verified through IAL2 + - FIXTURE_USER: User was created by fixtures + """ + + GRANDFATHERED = "grandfathered", "Legacy user" + VERIFIED_BY_STAFF = "verified_by_staff", "Verified by staff" + REGULAR = "regular", "Verified by Login.gov" + INVITED = "invited", "Invited by a domain manager" + # We need a type for fixture users (rather than using verified by staff) + # because those users still do get "verified" through normal means + # after they login. + FIXTURE_USER = "fixture_user", "Created by fixtures" + # #### Constants for choice fields #### RESTRICTED = "restricted" STATUS_CHOICES = ((RESTRICTED, RESTRICTED),) @@ -50,6 +73,13 @@ class User(AbstractUser): db_index=True, ) + verification_type = models.CharField( + choices=VerificationTypeChoices.choices, + null=True, + blank=True, + help_text="The means through which this user was verified", + ) + def __str__(self): # this info is pulled from Login.gov if self.first_name or self.last_name: @@ -114,23 +144,61 @@ class User(AbstractUser): except Exception as err: raise err - # A new incoming user who is a domain manager for one of the domains - # that we inputted from Verisign (that is, their email address appears - # in the username field of a TransitionDomain) - if TransitionDomain.objects.filter(username=email).exists(): - return False + # We can't set the verification type here because the user may not + # always exist at this point. We do it down the line. + verification_type = cls.get_verification_type_from_email(email) - # New users flagged by Staff to bypass ial2 - if VerifiedByStaff.objects.filter(email=email).exists(): - return False + # Checks if the user needs verification. + # The user needs identity verification if they don't meet + # any special criteria, i.e. we are validating them "regularly" + return verification_type == cls.VerificationTypeChoices.REGULAR - # A new incoming user who is being invited to be a domain manager (that is, - # their email address is in DomainInvitation for an invitation that is not yet "retrieved"). - invited = DomainInvitation.DomainInvitationStatus.INVITED - if DomainInvitation.objects.filter(email=email, status=invited).exists(): - return False + def set_user_verification_type(self): + """ + Given pre-existing data from TransitionDomain, VerifiedByStaff, and DomainInvitation, + set the verification "type" defined in VerificationTypeChoices. + """ + email_or_username = self.email if self.email else self.username + retrieved = DomainInvitation.DomainInvitationStatus.RETRIEVED + verification_type = self.get_verification_type_from_email(email_or_username, invitation_status=retrieved) - return True + # An existing user may have been invited to a domain after they got verified. + # We need to check for this condition. + if verification_type == User.VerificationTypeChoices.INVITED: + invitation = ( + DomainInvitation.objects.filter(email=email_or_username, status=retrieved) + .order_by("created_at") + .first() + ) + + # If you joined BEFORE the oldest invitation was created, then you were verified normally. + # (See logic in get_verification_type_from_email) + if not invitation and self.date_joined < invitation.created_at: + verification_type = User.VerificationTypeChoices.REGULAR + + self.verification_type = verification_type + + @classmethod + def get_verification_type_from_email(cls, email, invitation_status=DomainInvitation.DomainInvitationStatus.INVITED): + """Retrieves the verification type based off of a provided email address""" + + verification_type = None + if TransitionDomain.objects.filter(Q(username=email) | Q(email=email)).exists(): + # A new incoming user who is a domain manager for one of the domains + # that we inputted from Verisign (that is, their email address appears + # in the username field of a TransitionDomain) + verification_type = cls.VerificationTypeChoices.GRANDFATHERED + elif VerifiedByStaff.objects.filter(email=email).exists(): + # New users flagged by Staff to bypass ial2 + verification_type = cls.VerificationTypeChoices.VERIFIED_BY_STAFF + elif DomainInvitation.objects.filter(email=email, status=invitation_status).exists(): + # A new incoming user who is being invited to be a domain manager (that is, + # their email address is in DomainInvitation for an invitation that is not yet "retrieved"). + verification_type = cls.VerificationTypeChoices.INVITED + else: + verification_type = cls.VerificationTypeChoices.REGULAR + + return verification_type def check_domain_invitations_on_login(self): """When a user first arrives on the site, we need to retrieve any domain diff --git a/src/registrar/templates/admin/base_site.html b/src/registrar/templates/admin/base_site.html index 58843421a..dd680cec5 100644 --- a/src/registrar/templates/admin/base_site.html +++ b/src/registrar/templates/admin/base_site.html @@ -23,6 +23,7 @@ + {% endblock %} {% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} diff --git a/src/registrar/templates/admin/fieldset.html b/src/registrar/templates/admin/fieldset.html index 8b8972e08..19c5db294 100644 --- a/src/registrar/templates/admin/fieldset.html +++ b/src/registrar/templates/admin/fieldset.html @@ -6,9 +6,23 @@ It is not inherently customizable on its own, so we can modify this instead. https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/includes/fieldset.html {% endcomment %}
- {% if fieldset.name %}

{{ fieldset.name }}

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

{{ fieldset.name }}

+ {% endif %} + {% endif %} - {% if fieldset.description %} + {# Customize the markup for the collapse toggle: Do not show a description for the collapse fieldsets, instead we're using the description as a screen reader only legend #} + {% if fieldset.description and 'collapse--dotgov' not in fieldset.classes %}
{{ fieldset.description|safe }}
{% endif %} diff --git a/src/registrar/templates/django/admin/includes/contact_detail_list.html b/src/registrar/templates/django/admin/includes/contact_detail_list.html index 0ac9c4c49..3b49e62a4 100644 --- a/src/registrar/templates/django/admin/includes/contact_detail_list.html +++ b/src/registrar/templates/django/admin/includes/contact_detail_list.html @@ -3,7 +3,7 @@
{% if show_formatted_name %} - {% if contact.get_formatted_name %} + {% if user.get_formatted_name %} {{ user.get_formatted_name }}
{% else %} None
@@ -47,7 +47,12 @@ {% else %} None
{% endif %} + {% else %} - No additional contact information found. + No additional contact information found.
+ {% endif %} + + {% if user_verification_type %} + {{ user_verification_type }} {% endif %}
diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html index 92eb433a9..88e4a9122 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -5,6 +5,7 @@ {% comment %} This is using a custom implementation fieldset.html (see admin/fieldset.html) {% endcomment %} + {% block field_readonly %} {% with all_contacts=original_object.other_contacts.all %} {% if field.field.name == "other_contacts" %} @@ -66,16 +67,9 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% endwith %} {% endblock field_readonly %} -{% block help_text %} -
-
{{ field.field.help_text|safe }}
-
-{% endblock help_text %} - - {% block after_help_text %} {% if field.field.name == "status" and original_object.history.count > 0 %} -
+
@@ -102,14 +96,14 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
- {% elif field.field.name == "creator" %} -
+ {% if field.field.name == "creator" %} +
- {% include "django/admin/includes/contact_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly %} + {% include "django/admin/includes/contact_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly user_verification_type=original_object.creator.get_verification_type_display%}
{% include "django/admin/includes/user_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly %} {% elif field.field.name == "submitter" %} -
+
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.submitter no_title_top_padding=field.is_readonly %}
diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html index ea9276b9f..6cc17817a 100644 --- a/src/registrar/templates/home.html +++ b/src/registrar/templates/home.html @@ -26,7 +26,7 @@

Domains

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

You don't have any registered domains.

+

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

{% endif %}

Domain requests

{% if domain_requests %} -
Your registered domains
+
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 89760ce24..71ac289c9 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -977,6 +977,26 @@ class TestDomainRequestAdmin(MockEppLib): self.assertContains(response, "", count=1) self.assertContains(response, "", count=2) self.assertContains(response, "", count=1) + + def test_collaspe_toggle_button_markup(self): + """ + Tests for the correct collapse toggle button markup + """ + + # Create a fake domain request and domain + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + p = "adminpass" + self.client.login(username="superuser", password=p) + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), + follow=True, + ) + + # Make sure the page loaded, and that we're on the right page + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain_request.requested_domain.name) + self.test_helper.assertContains(response, "Show details") @less_console_noise_decorator def test_analyst_can_see_and_edit_alternative_domain(self): @@ -3134,7 +3154,15 @@ class TestMyUserAdmin(TestCase): request.user = create_user() fieldsets = self.admin.get_fieldsets(request) expected_fieldsets = ( - (None, {"fields": ("status",)}), + ( + None, + { + "fields": ( + "status", + "verification_type", + ) + }, + ), ("Personal Info", {"fields": ("first_name", "last_name", "email")}), ("Permissions", {"fields": ("is_active", "groups")}), ("Important dates", {"fields": ("last_login", "date_joined")}), diff --git a/src/registrar/tests/test_management_scripts.py b/src/registrar/tests/test_management_scripts.py index 26161b272..617e305a1 100644 --- a/src/registrar/tests/test_management_scripts.py +++ b/src/registrar/tests/test_management_scripts.py @@ -14,8 +14,9 @@ from registrar.models import ( TransitionDomain, DomainInformation, UserDomainRole, + VerifiedByStaff, + PublicContact, ) -from registrar.models.public_contact import PublicContact from django.core.management import call_command from unittest.mock import patch, call @@ -25,6 +26,103 @@ from .common import MockEppLib, less_console_noise, completed_domain_request from api.tests.common import less_console_noise_decorator +class TestPopulateVerificationType(MockEppLib): + """Tests for the populate_organization_type script""" + + def setUp(self): + """Creates a fake domain object""" + super().setUp() + + # Get the domain requests + self.domain_request_1 = completed_domain_request( + name="lasers.gov", + generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + is_election_board=True, + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + ) + + # Approve the request + self.domain_request_1.approve() + + # Get the domains + self.domain_1 = Domain.objects.get(name="lasers.gov") + + # Get users + self.regular_user, _ = User.objects.get_or_create(username="testuser@igormail.gov") + + vip, _ = VerifiedByStaff.objects.get_or_create(email="vipuser@igormail.gov") + self.verified_by_staff_user, _ = User.objects.get_or_create(username="vipuser@igormail.gov") + + grandfathered, _ = TransitionDomain.objects.get_or_create( + username="grandpa@igormail.gov", domain_name=self.domain_1.name + ) + self.grandfathered_user, _ = User.objects.get_or_create(username="grandpa@igormail.gov") + + invited, _ = DomainInvitation.objects.get_or_create( + email="invited@igormail.gov", domain=self.domain_1, status=DomainInvitation.DomainInvitationStatus.RETRIEVED + ) + self.invited_user, _ = User.objects.get_or_create(username="invited@igormail.gov") + + self.untouched_user, _ = User.objects.get_or_create( + username="iaminvincible@igormail.gov", verification_type=User.VerificationTypeChoices.GRANDFATHERED + ) + + # Fixture users should be untouched by the script. These will auto update once the + # user logs in / creates an account. + self.fixture_user, _ = User.objects.get_or_create( + username="fixture@igormail.gov", verification_type=User.VerificationTypeChoices.FIXTURE_USER + ) + + def tearDown(self): + """Deletes all DB objects related to migrations""" + super().tearDown() + + # Delete domains and related information + Domain.objects.all().delete() + DomainInformation.objects.all().delete() + DomainRequest.objects.all().delete() + User.objects.all().delete() + Contact.objects.all().delete() + Website.objects.all().delete() + + @less_console_noise_decorator + def run_populate_verification_type(self): + """ + This method executes the populate_organization_type command. + + The 'call_command' function from Django's management framework is then used to + execute the populate_organization_type command with the specified arguments. + """ + with patch( + "registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", # noqa + return_value=True, + ): + call_command("populate_verification_type") + + @less_console_noise_decorator + def test_verification_type_script_populates_data(self): + """Ensures that the verification type script actually populates data""" + + # Run the script + self.run_populate_verification_type() + + # Scripts don't work as we'd expect in our test environment, we need to manually + # trigger the refresh event + self.regular_user.refresh_from_db() + self.grandfathered_user.refresh_from_db() + self.invited_user.refresh_from_db() + self.verified_by_staff_user.refresh_from_db() + self.untouched_user.refresh_from_db() + + # Test all users + self.assertEqual(self.regular_user.verification_type, User.VerificationTypeChoices.REGULAR) + self.assertEqual(self.grandfathered_user.verification_type, User.VerificationTypeChoices.GRANDFATHERED) + self.assertEqual(self.invited_user.verification_type, User.VerificationTypeChoices.INVITED) + self.assertEqual(self.verified_by_staff_user.verification_type, User.VerificationTypeChoices.VERIFIED_BY_STAFF) + self.assertEqual(self.untouched_user.verification_type, User.VerificationTypeChoices.GRANDFATHERED) + self.assertEqual(self.fixture_user.verification_type, User.VerificationTypeChoices.FIXTURE_USER) + + class TestPopulateOrganizationType(MockEppLib): """Tests for the populate_organization_type script""" diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index b8055f288..217e24b9a 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -1,12 +1,16 @@ +from datetime import date from django.test import Client, TestCase, override_settings from django.contrib.auth import get_user_model from api.tests.common import less_console_noise_decorator +from registrar.models.contact import Contact from registrar.models.domain import Domain +from registrar.models.draft_domain import DraftDomain +from registrar.models.user import User from registrar.models.user_domain_role import UserDomainRole from registrar.views.domain import DomainNameserversView -from .common import MockEppLib # type: ignore +from .common import MockEppLib, less_console_noise # type: ignore from unittest.mock import patch from django.urls import reverse @@ -135,3 +139,369 @@ class TestEnvironmentVariablesEffects(TestCase): self.assertEqual(contact_page_500.status_code, 500) self.assertNotContains(contact_page_500, "You are on a test site.") + + +class HomeTests(TestWithUser): + """A series of tests that target the two tables on home.html""" + + def setUp(self): + super().setUp() + self.client.force_login(self.user) + + def tearDown(self): + super().tearDown() + Contact.objects.all().delete() + + def test_empty_domain_table(self): + response = self.client.get("/") + self.assertContains(response, "You don't have any registered domains.") + self.assertContains(response, "Why don't I see my domain when I sign in to the registrar?") + + def test_home_lists_domain_requests(self): + response = self.client.get("/") + self.assertNotContains(response, "igorville.gov") + site = DraftDomain.objects.create(name="igorville.gov") + domain_request = DomainRequest.objects.create(creator=self.user, requested_domain=site) + response = self.client.get("/") + + # count = 7 because of screenreader content + self.assertContains(response, "igorville.gov", count=7) + + # clean up + domain_request.delete() + + def test_state_help_text(self): + """Tests if each domain state has help text""" + + # Get the expected text content of each state + deleted_text = "This domain has been removed and " "is no longer registered to your organization." + dns_needed_text = "Before this domain can be used, " "you’ll need to add name server addresses." + ready_text = "This domain has name servers and is ready for use." + on_hold_text = ( + "This domain is administratively paused, " + "so it can’t be edited and won’t resolve in DNS. " + "Contact help@get.gov for details." + ) + deleted_text = "This domain has been removed and " "is no longer registered to your organization." + # Generate a mapping of domain names, the state, and expected messages for the subtest + test_cases = [ + ("deleted.gov", Domain.State.DELETED, deleted_text), + ("dnsneeded.gov", Domain.State.DNS_NEEDED, dns_needed_text), + ("unknown.gov", Domain.State.UNKNOWN, dns_needed_text), + ("onhold.gov", Domain.State.ON_HOLD, on_hold_text), + ("ready.gov", Domain.State.READY, ready_text), + ] + for domain_name, state, expected_message in test_cases: + with self.subTest(domain_name=domain_name, state=state, expected_message=expected_message): + # Create a domain and a UserRole with the given params + test_domain, _ = Domain.objects.get_or_create(name=domain_name, state=state) + test_domain.expiration_date = date.today() + test_domain.save() + + user_role, _ = UserDomainRole.objects.get_or_create( + user=self.user, domain=test_domain, role=UserDomainRole.Roles.MANAGER + ) + + # Grab the home page + response = self.client.get("/") + + # Make sure the user can actually see the domain. + # We expect two instances because of SR content. + self.assertContains(response, domain_name, count=2) + + # Check that we have the right text content. + self.assertContains(response, expected_message, count=1) + + # Delete the role and domain to ensure we're testing in isolation + user_role.delete() + test_domain.delete() + + def test_state_help_text_expired(self): + """Tests if each domain state has help text when expired""" + expired_text = "This domain has expired, but it is still online. " "To renew this domain, contact help@get.gov." + test_domain, _ = Domain.objects.get_or_create(name="expired.gov", state=Domain.State.READY) + test_domain.expiration_date = date(2011, 10, 10) + test_domain.save() + + UserDomainRole.objects.get_or_create(user=self.user, domain=test_domain, role=UserDomainRole.Roles.MANAGER) + + # Grab the home page + response = self.client.get("/") + + # Make sure the user can actually see the domain. + # We expect two instances because of SR content. + self.assertContains(response, "expired.gov", count=2) + + # Check that we have the right text content. + self.assertContains(response, expired_text, count=1) + + def test_state_help_text_no_expiration_date(self): + """Tests if each domain state has help text when expiration date is None""" + + # == Test a expiration of None for state ready. This should be expired. == # + expired_text = "This domain has expired, but it is still online. " "To renew this domain, contact help@get.gov." + test_domain, _ = Domain.objects.get_or_create(name="imexpired.gov", state=Domain.State.READY) + test_domain.expiration_date = None + test_domain.save() + + UserDomainRole.objects.get_or_create(user=self.user, domain=test_domain, role=UserDomainRole.Roles.MANAGER) + + # Grab the home page + response = self.client.get("/") + + # Make sure the user can actually see the domain. + # We expect two instances because of SR content. + self.assertContains(response, "imexpired.gov", count=2) + + # Make sure the expiration date is None + self.assertEqual(test_domain.expiration_date, None) + + # Check that we have the right text content. + self.assertContains(response, expired_text, count=1) + + # == Test a expiration of None for state unknown. This should not display expired text. == # + unknown_text = "Before this domain can be used, " "you’ll need to add name server addresses." + test_domain_2, _ = Domain.objects.get_or_create(name="notexpired.gov", state=Domain.State.UNKNOWN) + test_domain_2.expiration_date = None + test_domain_2.save() + + UserDomainRole.objects.get_or_create(user=self.user, domain=test_domain_2, role=UserDomainRole.Roles.MANAGER) + + # Grab the home page + response = self.client.get("/") + + # Make sure the user can actually see the domain. + # We expect two instances because of SR content. + self.assertContains(response, "notexpired.gov", count=2) + + # Make sure the expiration date is None + self.assertEqual(test_domain_2.expiration_date, None) + + # Check that we have the right text content. + self.assertContains(response, unknown_text, count=1) + + def test_home_deletes_withdrawn_domain_request(self): + """Tests if the user can delete a DomainRequest in the 'withdrawn' status""" + + site = DraftDomain.objects.create(name="igorville.gov") + domain_request = DomainRequest.objects.create( + creator=self.user, requested_domain=site, status=DomainRequest.DomainRequestStatus.WITHDRAWN + ) + + # Ensure that igorville.gov exists on the page + home_page = self.client.get("/") + self.assertContains(home_page, "igorville.gov") + + # Check if the delete button exists. We can do this by checking for its id and text content. + self.assertContains(home_page, "Delete") + self.assertContains(home_page, "button-toggle-delete-domain-alert-1") + + # Trigger the delete logic + response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True) + + self.assertNotContains(response, "igorville.gov") + + # clean up + domain_request.delete() + + def test_home_deletes_started_domain_request(self): + """Tests if the user can delete a DomainRequest in the 'started' status""" + + site = DraftDomain.objects.create(name="igorville.gov") + domain_request = DomainRequest.objects.create( + creator=self.user, requested_domain=site, status=DomainRequest.DomainRequestStatus.STARTED + ) + + # Ensure that igorville.gov exists on the page + home_page = self.client.get("/") + self.assertContains(home_page, "igorville.gov") + + # Check if the delete button exists. We can do this by checking for its id and text content. + self.assertContains(home_page, "Delete") + self.assertContains(home_page, "button-toggle-delete-domain-alert-1") + + # Trigger the delete logic + response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True) + + self.assertNotContains(response, "igorville.gov") + + # clean up + domain_request.delete() + + def test_home_doesnt_delete_other_domain_requests(self): + """Tests to ensure the user can't delete domain requests not in the status of STARTED or WITHDRAWN""" + + # Given that we are including a subset of items that can be deleted while excluding the rest, + # subTest is appropriate here as otherwise we would need many duplicate tests for the same reason. + with less_console_noise(): + draft_domain = DraftDomain.objects.create(name="igorville.gov") + for status in DomainRequest.DomainRequestStatus: + if status not in [ + DomainRequest.DomainRequestStatus.STARTED, + DomainRequest.DomainRequestStatus.WITHDRAWN, + ]: + with self.subTest(status=status): + domain_request = DomainRequest.objects.create( + creator=self.user, requested_domain=draft_domain, status=status + ) + + # Trigger the delete logic + response = self.client.post( + reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True + ) + + # Check for a 403 error - the end user should not be allowed to do this + self.assertEqual(response.status_code, 403) + + desired_domain_request = DomainRequest.objects.filter(requested_domain=draft_domain) + + # Make sure the DomainRequest wasn't deleted + self.assertEqual(desired_domain_request.count(), 1) + + # clean up + domain_request.delete() + + def test_home_deletes_domain_request_and_orphans(self): + """Tests if delete for DomainRequest deletes orphaned Contact objects""" + + # Create the site and contacts to delete (orphaned) + contact = Contact.objects.create( + first_name="Henry", + last_name="Mcfakerson", + ) + contact_shared = Contact.objects.create( + first_name="Relative", + last_name="Aether", + ) + + # Create two non-orphaned contacts + contact_2 = Contact.objects.create( + first_name="Saturn", + last_name="Mars", + ) + + # Attach a user object to a contact (should not be deleted) + contact_user, _ = Contact.objects.get_or_create(user=self.user) + + site = DraftDomain.objects.create(name="igorville.gov") + domain_request = DomainRequest.objects.create( + creator=self.user, + requested_domain=site, + status=DomainRequest.DomainRequestStatus.WITHDRAWN, + authorizing_official=contact, + submitter=contact_user, + ) + domain_request.other_contacts.set([contact_2]) + + # Create a second domain request to attach contacts to + site_2 = DraftDomain.objects.create(name="teaville.gov") + domain_request_2 = DomainRequest.objects.create( + creator=self.user, + requested_domain=site_2, + status=DomainRequest.DomainRequestStatus.STARTED, + authorizing_official=contact_2, + submitter=contact_shared, + ) + domain_request_2.other_contacts.set([contact_shared]) + + # Ensure that igorville.gov exists on the page + home_page = self.client.get("/") + self.assertContains(home_page, "igorville.gov") + + # Trigger the delete logic + response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True) + + # igorville is now deleted + self.assertNotContains(response, "igorville.gov") + + # Check if the orphaned contact was deleted + orphan = Contact.objects.filter(id=contact.id) + self.assertFalse(orphan.exists()) + + # All non-orphan contacts should still exist and are unaltered + try: + current_user = Contact.objects.filter(id=contact_user.id).get() + except Contact.DoesNotExist: + self.fail("contact_user (a non-orphaned contact) was deleted") + + self.assertEqual(current_user, contact_user) + try: + edge_case = Contact.objects.filter(id=contact_2.id).get() + except Contact.DoesNotExist: + self.fail("contact_2 (a non-orphaned contact) was deleted") + + self.assertEqual(edge_case, contact_2) + + def test_home_deletes_domain_request_and_shared_orphans(self): + """Test the edge case for an object that will become orphaned after a delete + (but is not an orphan at the time of deletion)""" + + # Create the site and contacts to delete (orphaned) + contact = Contact.objects.create( + first_name="Henry", + last_name="Mcfakerson", + ) + contact_shared = Contact.objects.create( + first_name="Relative", + last_name="Aether", + ) + + # Create two non-orphaned contacts + contact_2 = Contact.objects.create( + first_name="Saturn", + last_name="Mars", + ) + + # Attach a user object to a contact (should not be deleted) + contact_user, _ = Contact.objects.get_or_create(user=self.user) + + site = DraftDomain.objects.create(name="igorville.gov") + domain_request = DomainRequest.objects.create( + creator=self.user, + requested_domain=site, + status=DomainRequest.DomainRequestStatus.WITHDRAWN, + authorizing_official=contact, + submitter=contact_user, + ) + domain_request.other_contacts.set([contact_2]) + + # Create a second domain request to attach contacts to + site_2 = DraftDomain.objects.create(name="teaville.gov") + domain_request_2 = DomainRequest.objects.create( + creator=self.user, + requested_domain=site_2, + status=DomainRequest.DomainRequestStatus.STARTED, + authorizing_official=contact_2, + submitter=contact_shared, + ) + domain_request_2.other_contacts.set([contact_shared]) + + home_page = self.client.get("/") + self.assertContains(home_page, "teaville.gov") + + # Trigger the delete logic + response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request_2.pk}), follow=True) + + self.assertNotContains(response, "teaville.gov") + + # Check if the orphaned contact was deleted + orphan = Contact.objects.filter(id=contact_shared.id) + self.assertFalse(orphan.exists()) + + def test_domain_request_form_view(self): + response = self.client.get("/request/", follow=True) + self.assertContains( + response, + "You’re about to start your .gov domain request.", + ) + + def test_domain_request_form_with_ineligible_user(self): + """Domain request form not accessible for an ineligible user. + This test should be solid enough since all domain request wizard + views share the same permissions class""" + self.user.status = User.RESTRICTED + self.user.save() + + with less_console_noise(): + response = self.client.get("/request/", follow=True) + self.assertEqual(response.status_code, 403) diff --git a/src/registrar/tests/test_views_request.py b/src/registrar/tests/test_views_request.py index 19be5ce74..22ad56646 100644 --- a/src/registrar/tests/test_views_request.py +++ b/src/registrar/tests/test_views_request.py @@ -3,7 +3,6 @@ from unittest.mock import Mock from django.conf import settings from django.urls import reverse -from datetime import date from .common import MockSESClient, completed_domain_request # type: ignore from django_webtest import WebTest # type: ignore @@ -17,7 +16,6 @@ from registrar.models import ( Contact, User, Website, - UserDomainRole, ) from registrar.views.domain_request import DomainRequestWizard, Step @@ -2603,364 +2601,3 @@ class TestWizardUnlockingSteps(TestWithUser, WebTest): else: self.fail(f"Expected a redirect, but got a different response: {response}") - - -class HomeTests(TestWithUser): - """A series of tests that target the two tables on home.html""" - - def setUp(self): - super().setUp() - self.client.force_login(self.user) - - def tearDown(self): - super().tearDown() - Contact.objects.all().delete() - - def test_home_lists_domain_requests(self): - response = self.client.get("/") - self.assertNotContains(response, "igorville.gov") - site = DraftDomain.objects.create(name="igorville.gov") - domain_request = DomainRequest.objects.create(creator=self.user, requested_domain=site) - response = self.client.get("/") - - # count = 7 because of screenreader content - self.assertContains(response, "igorville.gov", count=7) - - # clean up - domain_request.delete() - - def test_state_help_text(self): - """Tests if each domain state has help text""" - - # Get the expected text content of each state - deleted_text = "This domain has been removed and " "is no longer registered to your organization." - dns_needed_text = "Before this domain can be used, " "you’ll need to add name server addresses." - ready_text = "This domain has name servers and is ready for use." - on_hold_text = ( - "This domain is administratively paused, " - "so it can’t be edited and won’t resolve in DNS. " - "Contact help@get.gov for details." - ) - deleted_text = "This domain has been removed and " "is no longer registered to your organization." - # Generate a mapping of domain names, the state, and expected messages for the subtest - test_cases = [ - ("deleted.gov", Domain.State.DELETED, deleted_text), - ("dnsneeded.gov", Domain.State.DNS_NEEDED, dns_needed_text), - ("unknown.gov", Domain.State.UNKNOWN, dns_needed_text), - ("onhold.gov", Domain.State.ON_HOLD, on_hold_text), - ("ready.gov", Domain.State.READY, ready_text), - ] - for domain_name, state, expected_message in test_cases: - with self.subTest(domain_name=domain_name, state=state, expected_message=expected_message): - # Create a domain and a UserRole with the given params - test_domain, _ = Domain.objects.get_or_create(name=domain_name, state=state) - test_domain.expiration_date = date.today() - test_domain.save() - - user_role, _ = UserDomainRole.objects.get_or_create( - user=self.user, domain=test_domain, role=UserDomainRole.Roles.MANAGER - ) - - # Grab the home page - response = self.client.get("/") - - # Make sure the user can actually see the domain. - # We expect two instances because of SR content. - self.assertContains(response, domain_name, count=2) - - # Check that we have the right text content. - self.assertContains(response, expected_message, count=1) - - # Delete the role and domain to ensure we're testing in isolation - user_role.delete() - test_domain.delete() - - def test_state_help_text_expired(self): - """Tests if each domain state has help text when expired""" - expired_text = "This domain has expired, but it is still online. " "To renew this domain, contact help@get.gov." - test_domain, _ = Domain.objects.get_or_create(name="expired.gov", state=Domain.State.READY) - test_domain.expiration_date = date(2011, 10, 10) - test_domain.save() - - UserDomainRole.objects.get_or_create(user=self.user, domain=test_domain, role=UserDomainRole.Roles.MANAGER) - - # Grab the home page - response = self.client.get("/") - - # Make sure the user can actually see the domain. - # We expect two instances because of SR content. - self.assertContains(response, "expired.gov", count=2) - - # Check that we have the right text content. - self.assertContains(response, expired_text, count=1) - - def test_state_help_text_no_expiration_date(self): - """Tests if each domain state has help text when expiration date is None""" - - # == Test a expiration of None for state ready. This should be expired. == # - expired_text = "This domain has expired, but it is still online. " "To renew this domain, contact help@get.gov." - test_domain, _ = Domain.objects.get_or_create(name="imexpired.gov", state=Domain.State.READY) - test_domain.expiration_date = None - test_domain.save() - - UserDomainRole.objects.get_or_create(user=self.user, domain=test_domain, role=UserDomainRole.Roles.MANAGER) - - # Grab the home page - response = self.client.get("/") - - # Make sure the user can actually see the domain. - # We expect two instances because of SR content. - self.assertContains(response, "imexpired.gov", count=2) - - # Make sure the expiration date is None - self.assertEqual(test_domain.expiration_date, None) - - # Check that we have the right text content. - self.assertContains(response, expired_text, count=1) - - # == Test a expiration of None for state unknown. This should not display expired text. == # - unknown_text = "Before this domain can be used, " "you’ll need to add name server addresses." - test_domain_2, _ = Domain.objects.get_or_create(name="notexpired.gov", state=Domain.State.UNKNOWN) - test_domain_2.expiration_date = None - test_domain_2.save() - - UserDomainRole.objects.get_or_create(user=self.user, domain=test_domain_2, role=UserDomainRole.Roles.MANAGER) - - # Grab the home page - response = self.client.get("/") - - # Make sure the user can actually see the domain. - # We expect two instances because of SR content. - self.assertContains(response, "notexpired.gov", count=2) - - # Make sure the expiration date is None - self.assertEqual(test_domain_2.expiration_date, None) - - # Check that we have the right text content. - self.assertContains(response, unknown_text, count=1) - - def test_home_deletes_withdrawn_domain_request(self): - """Tests if the user can delete a DomainRequest in the 'withdrawn' status""" - - site = DraftDomain.objects.create(name="igorville.gov") - domain_request = DomainRequest.objects.create( - creator=self.user, requested_domain=site, status=DomainRequest.DomainRequestStatus.WITHDRAWN - ) - - # Ensure that igorville.gov exists on the page - home_page = self.client.get("/") - self.assertContains(home_page, "igorville.gov") - - # Check if the delete button exists. We can do this by checking for its id and text content. - self.assertContains(home_page, "Delete") - self.assertContains(home_page, "button-toggle-delete-domain-alert-1") - - # Trigger the delete logic - response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True) - - self.assertNotContains(response, "igorville.gov") - - # clean up - domain_request.delete() - - def test_home_deletes_started_domain_request(self): - """Tests if the user can delete a DomainRequest in the 'started' status""" - - site = DraftDomain.objects.create(name="igorville.gov") - domain_request = DomainRequest.objects.create( - creator=self.user, requested_domain=site, status=DomainRequest.DomainRequestStatus.STARTED - ) - - # Ensure that igorville.gov exists on the page - home_page = self.client.get("/") - self.assertContains(home_page, "igorville.gov") - - # Check if the delete button exists. We can do this by checking for its id and text content. - self.assertContains(home_page, "Delete") - self.assertContains(home_page, "button-toggle-delete-domain-alert-1") - - # Trigger the delete logic - response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True) - - self.assertNotContains(response, "igorville.gov") - - # clean up - domain_request.delete() - - def test_home_doesnt_delete_other_domain_requests(self): - """Tests to ensure the user can't delete domain requests not in the status of STARTED or WITHDRAWN""" - - # Given that we are including a subset of items that can be deleted while excluding the rest, - # subTest is appropriate here as otherwise we would need many duplicate tests for the same reason. - with less_console_noise(): - draft_domain = DraftDomain.objects.create(name="igorville.gov") - for status in DomainRequest.DomainRequestStatus: - if status not in [ - DomainRequest.DomainRequestStatus.STARTED, - DomainRequest.DomainRequestStatus.WITHDRAWN, - ]: - with self.subTest(status=status): - domain_request = DomainRequest.objects.create( - creator=self.user, requested_domain=draft_domain, status=status - ) - - # Trigger the delete logic - response = self.client.post( - reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True - ) - - # Check for a 403 error - the end user should not be allowed to do this - self.assertEqual(response.status_code, 403) - - desired_domain_request = DomainRequest.objects.filter(requested_domain=draft_domain) - - # Make sure the DomainRequest wasn't deleted - self.assertEqual(desired_domain_request.count(), 1) - - # clean up - domain_request.delete() - - def test_home_deletes_domain_request_and_orphans(self): - """Tests if delete for DomainRequest deletes orphaned Contact objects""" - - # Create the site and contacts to delete (orphaned) - contact = Contact.objects.create( - first_name="Henry", - last_name="Mcfakerson", - ) - contact_shared = Contact.objects.create( - first_name="Relative", - last_name="Aether", - ) - - # Create two non-orphaned contacts - contact_2 = Contact.objects.create( - first_name="Saturn", - last_name="Mars", - ) - - # Attach a user object to a contact (should not be deleted) - contact_user, _ = Contact.objects.get_or_create(user=self.user) - - site = DraftDomain.objects.create(name="igorville.gov") - domain_request = DomainRequest.objects.create( - creator=self.user, - requested_domain=site, - status=DomainRequest.DomainRequestStatus.WITHDRAWN, - authorizing_official=contact, - submitter=contact_user, - ) - domain_request.other_contacts.set([contact_2]) - - # Create a second domain request to attach contacts to - site_2 = DraftDomain.objects.create(name="teaville.gov") - domain_request_2 = DomainRequest.objects.create( - creator=self.user, - requested_domain=site_2, - status=DomainRequest.DomainRequestStatus.STARTED, - authorizing_official=contact_2, - submitter=contact_shared, - ) - domain_request_2.other_contacts.set([contact_shared]) - - # Ensure that igorville.gov exists on the page - home_page = self.client.get("/") - self.assertContains(home_page, "igorville.gov") - - # Trigger the delete logic - response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True) - - # igorville is now deleted - self.assertNotContains(response, "igorville.gov") - - # Check if the orphaned contact was deleted - orphan = Contact.objects.filter(id=contact.id) - self.assertFalse(orphan.exists()) - - # All non-orphan contacts should still exist and are unaltered - try: - current_user = Contact.objects.filter(id=contact_user.id).get() - except Contact.DoesNotExist: - self.fail("contact_user (a non-orphaned contact) was deleted") - - self.assertEqual(current_user, contact_user) - try: - edge_case = Contact.objects.filter(id=contact_2.id).get() - except Contact.DoesNotExist: - self.fail("contact_2 (a non-orphaned contact) was deleted") - - self.assertEqual(edge_case, contact_2) - - def test_home_deletes_domain_request_and_shared_orphans(self): - """Test the edge case for an object that will become orphaned after a delete - (but is not an orphan at the time of deletion)""" - - # Create the site and contacts to delete (orphaned) - contact = Contact.objects.create( - first_name="Henry", - last_name="Mcfakerson", - ) - contact_shared = Contact.objects.create( - first_name="Relative", - last_name="Aether", - ) - - # Create two non-orphaned contacts - contact_2 = Contact.objects.create( - first_name="Saturn", - last_name="Mars", - ) - - # Attach a user object to a contact (should not be deleted) - contact_user, _ = Contact.objects.get_or_create(user=self.user) - - site = DraftDomain.objects.create(name="igorville.gov") - domain_request = DomainRequest.objects.create( - creator=self.user, - requested_domain=site, - status=DomainRequest.DomainRequestStatus.WITHDRAWN, - authorizing_official=contact, - submitter=contact_user, - ) - domain_request.other_contacts.set([contact_2]) - - # Create a second domain request to attach contacts to - site_2 = DraftDomain.objects.create(name="teaville.gov") - domain_request_2 = DomainRequest.objects.create( - creator=self.user, - requested_domain=site_2, - status=DomainRequest.DomainRequestStatus.STARTED, - authorizing_official=contact_2, - submitter=contact_shared, - ) - domain_request_2.other_contacts.set([contact_shared]) - - home_page = self.client.get("/") - self.assertContains(home_page, "teaville.gov") - - # Trigger the delete logic - response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request_2.pk}), follow=True) - - self.assertNotContains(response, "teaville.gov") - - # Check if the orphaned contact was deleted - orphan = Contact.objects.filter(id=contact_shared.id) - self.assertFalse(orphan.exists()) - - def test_domain_request_form_view(self): - response = self.client.get("/request/", follow=True) - self.assertContains( - response, - "You’re about to start your .gov domain request.", - ) - - def test_domain_request_form_with_ineligible_user(self): - """Domain request form not accessible for an ineligible user. - This test should be solid enough since all domain request wizard - views share the same permissions class""" - self.user.status = User.RESTRICTED - self.user.save() - - with less_console_noise(): - response = self.client.get("/request/", follow=True) - self.assertEqual(response.status_code, 403)
Your domain requests
SubmittedIn reviewAction needed