diff --git a/.github/workflows/deploy-sandbox.yaml b/.github/workflows/deploy-sandbox.yaml index f2b4303d6..84f228893 100644 --- a/.github/workflows/deploy-sandbox.yaml +++ b/.github/workflows/deploy-sandbox.yaml @@ -27,6 +27,7 @@ jobs: || startsWith(github.head_ref, 'cb/') || startsWith(github.head_ref, 'hotgov/') || startsWith(github.head_ref, 'litterbox/') + || startsWith(github.head_ref, 'ag/') outputs: environment: ${{ steps.var.outputs.environment}} runs-on: "ubuntu-latest" diff --git a/.github/workflows/migrate.yaml b/.github/workflows/migrate.yaml index 283380236..81368f6e9 100644 --- a/.github/workflows/migrate.yaml +++ b/.github/workflows/migrate.yaml @@ -16,6 +16,7 @@ on: - stable - staging - development + - ag - litterbox - hotgov - cb diff --git a/.github/workflows/reset-db.yaml b/.github/workflows/reset-db.yaml index b9393415b..ad325c50a 100644 --- a/.github/workflows/reset-db.yaml +++ b/.github/workflows/reset-db.yaml @@ -16,6 +16,7 @@ on: options: - staging - development + - ag - litterbox - hotgov - cb diff --git a/ops/manifests/manifest-ag.yaml b/ops/manifests/manifest-ag.yaml new file mode 100644 index 000000000..68d630f3e --- /dev/null +++ b/ops/manifests/manifest-ag.yaml @@ -0,0 +1,32 @@ +--- +applications: +- name: getgov-ag + 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-ag.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-ag.app.cloud.gov + services: + - getgov-credentials + - getgov-ag-database diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index cc11f1336..86bb1fe6e 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -1604,6 +1604,18 @@ document.addEventListener('DOMContentLoaded', function() { +/** + * An IIFE that displays confirmation modal on the user profile page + */ +(function userProfileListener() { + + const showConfirmationModalTrigger = document.querySelector('.show-confirmation-modal'); + if (showConfirmationModalTrigger) { + showConfirmationModalTrigger.click(); + } +} +)(); + /** * An IIFE that hooks up the edit buttons on the finish-user-setup page */ diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index e88d75f4e..e5e8b89ee 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -70,7 +70,7 @@ body { top: 50%; left: 0; width: 0; /* No width since it's a border */ - height: 50%; + height: 40%; border-left: solid 1px color('base-light'); transform: translateY(-50%); } diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 9a6792dc7..8438812c4 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -659,6 +659,7 @@ ALLOWED_HOSTS = [ "getgov-stable.app.cloud.gov", "getgov-staging.app.cloud.gov", "getgov-development.app.cloud.gov", + "getgov-ag.app.cloud.gov", "getgov-litterbox.app.cloud.gov", "getgov-hotgov.app.cloud.gov", "getgov-cb.app.cloud.gov", diff --git a/src/registrar/forms/user_profile.py b/src/registrar/forms/user_profile.py index 557e34e0d..3dd8cbdce 100644 --- a/src/registrar/forms/user_profile.py +++ b/src/registrar/forms/user_profile.py @@ -47,7 +47,7 @@ class UserProfileForm(forms.ModelForm): self.fields["middle_name"].label = "Middle name (optional)" self.fields["last_name"].label = "Last name / family name" self.fields["title"].label = "Title or role in your organization" - self.fields["email"].label = "Organizational email" + self.fields["email"].label = "Organization email" # Set custom error messages self.fields["first_name"].error_messages = {"required": "Enter your first name / given name."} diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py index f9921513b..79e3b7a11 100644 --- a/src/registrar/registrar_middleware.py +++ b/src/registrar/registrar_middleware.py @@ -5,6 +5,7 @@ Contains middleware used in settings.py from urllib.parse import parse_qs from django.urls import reverse from django.http import HttpResponseRedirect +from registrar.models.user import User from waffle.decorators import flag_is_active from registrar.models.utility.generic_helper import replace_url_queryparams @@ -38,10 +39,17 @@ class CheckUserProfileMiddleware: self.get_response = get_response self.setup_page = reverse("finish-user-profile-setup") + self.profile_page = reverse("user-profile") self.logout_page = reverse("logout") - self.excluded_pages = [ + self.regular_excluded_pages = [ self.setup_page, self.logout_page, + "/admin", + ] + self.other_excluded_pages = [ + self.profile_page, + self.logout_page, + "/admin", ] def __call__(self, request): @@ -61,12 +69,15 @@ class CheckUserProfileMiddleware: if request.user.is_authenticated: if hasattr(request.user, "finished_setup") and not request.user.finished_setup: - return self._handle_setup_not_finished(request) + if request.user.verification_type == User.VerificationTypeChoices.REGULAR: + return self._handle_regular_user_setup_not_finished(request) + else: + return self._handle_other_user_setup_not_finished(request) # Continue processing the view return None - def _handle_setup_not_finished(self, request): + def _handle_regular_user_setup_not_finished(self, request): """Redirects the given user to the finish setup page. We set the "redirect" query param equal to where the user wants to go. @@ -82,7 +93,7 @@ class CheckUserProfileMiddleware: custom_redirect = "domain-request:" if request.path == "/request/" else None # Don't redirect on excluded pages (such as the setup page itself) - if not any(request.path.startswith(page) for page in self.excluded_pages): + if not any(request.path.startswith(page) for page in self.regular_excluded_pages): # Preserve the original query parameters, and coerce them into a dict query_params = parse_qs(request.META["QUERY_STRING"]) @@ -98,3 +109,13 @@ class CheckUserProfileMiddleware: else: # Process the view as normal return None + + def _handle_other_user_setup_not_finished(self, request): + """Redirects the given user to the profile page to finish setup.""" + + # Don't redirect on excluded pages (such as the setup page itself) + if not any(request.path.startswith(page) for page in self.other_excluded_pages): + return HttpResponseRedirect(self.profile_page) + else: + # Process the view as normal + return None diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html index 15261440d..f93159f01 100644 --- a/src/registrar/templates/home.html +++ b/src/registrar/templates/home.html @@ -15,7 +15,11 @@ {% endblock %}

Manage your domains

- + {% comment %} + IMPORTANT: + If this button is added on any other page, make sure to update the + relevant view to reset request.session["new_request"] = True + {% endcomment %}

diff --git a/src/registrar/templates/profile.html b/src/registrar/templates/profile.html index c126204bd..c62d0a7c1 100644 --- a/src/registrar/templates/profile.html +++ b/src/registrar/templates/profile.html @@ -5,6 +5,11 @@ Edit your User Profile | {% endblock title %} {% load static url_helpers %} +{# Disable the redirect #} +{% block logo %} + {% include "includes/gov_extended_logo.html" with logo_clickable=user_finished_setup %} +{% endblock %} + {% block content %}

@@ -35,6 +40,61 @@ Edit your User Profile | {% endif %} {% endif %} + + {% if show_confirmation_modal %} + +
+
+
+ +
+ +
+ +
+ +
+
+ {% endif %} + + {% endblock content %} @@ -43,3 +103,7 @@ Edit your User Profile |
{% endblock content_bottom %} + +{% block footer %} + {% include "includes/footer.html" with show_manage_your_domains=user_finished_setup %} +{% endblock footer %} diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index 90780c5da..fa63da17a 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -63,11 +63,24 @@ class TestWithUser(MockEppLib): self.user.contact.title = title self.user.contact.save() - username_incomplete = "test_user_incomplete" + username_regular_incomplete = "test_regular_user_incomplete" + username_other_incomplete = "test_other_user_incomplete" first_name_2 = "Incomplete" email_2 = "unicorn@igorville.com" - self.incomplete_user = get_user_model().objects.create( - username=username_incomplete, first_name=first_name_2, email=email_2 + # in the case below, REGULAR user is 'Verified by Login.gov, ie. IAL2 + self.incomplete_regular_user = get_user_model().objects.create( + username=username_regular_incomplete, + first_name=first_name_2, + email=email_2, + verification_type=User.VerificationTypeChoices.REGULAR, + ) + # in the case below, other user is representative of GRANDFATHERED, + # VERIFIED_BY_STAFF, INVITED, FIXTURE_USER, ie. IAL1 + self.incomplete_other_user = get_user_model().objects.create( + username=username_other_incomplete, + first_name=first_name_2, + email=email_2, + verification_type=User.VerificationTypeChoices.VERIFIED_BY_STAFF, ) def tearDown(self): @@ -75,8 +88,7 @@ class TestWithUser(MockEppLib): super().tearDown() DomainRequest.objects.all().delete() DomainInformation.objects.all().delete() - self.user.delete() - self.incomplete_user.delete() + User.objects.all().delete() class TestEnvironmentVariablesEffects(TestCase): @@ -526,7 +538,7 @@ class FinishUserProfileTests(TestWithUser, WebTest): @less_console_noise_decorator def test_new_user_with_profile_feature_on(self): """Tests that a new user is redirected to the profile setup page when profile_feature is on""" - self.app.set_user(self.incomplete_user.username) + self.app.set_user(self.incomplete_regular_user.username) with override_flag("profile_feature", active=True): # This will redirect the user to the setup page. # Follow implicity checks if our redirect is working. @@ -565,7 +577,7 @@ class FinishUserProfileTests(TestWithUser, WebTest): def test_new_user_goes_to_domain_request_with_profile_feature_on(self): """Tests that a new user is redirected to the domain request page when profile_feature is on""" - self.app.set_user(self.incomplete_user.username) + self.app.set_user(self.incomplete_regular_user.username) with override_flag("profile_feature", active=True): # This will redirect the user to the setup page finish_setup_page = self.app.get(reverse("domain-request:")).follow() @@ -619,6 +631,106 @@ class FinishUserProfileTests(TestWithUser, WebTest): self.assertContains(response, "You’re about to start your .gov domain request") +class FinishUserProfileForOtherUsersTests(TestWithUser, WebTest): + """A series of tests that target the user profile page intercept for incomplete IAL1 user profiles.""" + + # csrf checks do not work well with WebTest. + # We disable them here. + csrf_checks = False + + def setUp(self): + super().setUp() + self.user.title = None + self.user.save() + self.client.force_login(self.user) + self.domain, _ = Domain.objects.get_or_create(name="sampledomain.gov", state=Domain.State.READY) + self.role, _ = UserDomainRole.objects.get_or_create( + user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER + ) + + def tearDown(self): + super().tearDown() + PublicContact.objects.filter(domain=self.domain).delete() + self.role.delete() + self.domain.delete() + Domain.objects.all().delete() + Website.objects.all().delete() + Contact.objects.all().delete() + + def _set_session_cookie(self): + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + def _submit_form_webtest(self, form, follow=False): + page = form.submit() + self._set_session_cookie() + return page.follow() if follow else page + + @less_console_noise_decorator + def test_new_user_with_profile_feature_on(self): + """Tests that a new user is redirected to the profile setup page when profile_feature is on, + and testing that the confirmation modal is present""" + self.app.set_user(self.incomplete_other_user.username) + with override_flag("profile_feature", active=True): + # This will redirect the user to the user profile page. + # Follow implicity checks if our redirect is working. + user_profile_page = self.app.get(reverse("home")).follow() + self._set_session_cookie() + + # Assert that we're on the right page by testing for the modal + self.assertContains(user_profile_page, "domain registrants must maintain accurate contact information") + + user_profile_page = self._submit_form_webtest(user_profile_page.form) + + self.assertEqual(user_profile_page.status_code, 200) + + # Assert that modal does not appear on subsequent submits + self.assertNotContains(user_profile_page, "domain registrants must maintain accurate contact information") + # Assert that unique error message appears by testing the message in a specific div + html_content = user_profile_page.content.decode("utf-8") + # Normalize spaces and line breaks in the HTML content + normalized_html_content = " ".join(html_content.split()) + # Expected string without extra spaces and line breaks + expected_string = "Before you can manage your domain, we need you to add contact information." + # Check for the presence of the
element with the specific text + self.assertIn(f'
{expected_string}
', normalized_html_content) + + # We're missing a phone number, so the page should tell us that + self.assertContains(user_profile_page, "Enter your phone number.") + + # We need to assert that links to manage your domain are not present (in both body and footer) + self.assertNotContains(user_profile_page, "Manage your domains") + # Assert the tooltip on the logo, indicating that the logo is not clickable + self.assertContains( + user_profile_page, 'title="Before you can manage your domains, we need you to add contact information."' + ) + # Assert that modal does not appear on subsequent submits + self.assertNotContains(user_profile_page, "domain registrants must maintain accurate contact information") + + # Add a phone number + finish_setup_form = user_profile_page.form + finish_setup_form["phone"] = "(201) 555-0123" + finish_setup_form["title"] = "CEO" + finish_setup_form["last_name"] = "example" + save_page = self._submit_form_webtest(finish_setup_form, follow=True) + + self.assertEqual(save_page.status_code, 200) + self.assertContains(save_page, "Your profile has been updated.") + + # We need to assert that logo is not clickable and links to manage your domain are not present + self.assertContains(save_page, "anage your domains", count=2) + self.assertNotContains( + save_page, "Before you can manage your domains, we need you to add contact information" + ) + # Assert that modal does not appear on subsequent submits + self.assertNotContains(save_page, "domain registrants must maintain accurate contact information") + + # Try to navigate back to the home page. + # This is the same as clicking the back button. + completed_setup_page = self.app.get(reverse("home")) + self.assertContains(completed_setup_page, "Manage your domain") + + class UserProfileTests(TestWithUser, WebTest): """A series of tests that target your profile functionality""" diff --git a/src/registrar/tests/test_views_request.py b/src/registrar/tests/test_views_request.py index b46b89b9a..69d222cce 100644 --- a/src/registrar/tests/test_views_request.py +++ b/src/registrar/tests/test_views_request.py @@ -102,6 +102,58 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertContains(type_page, "You cannot submit this request yet") + def test_domain_request_into_acknowledgement_creates_new_request(self): + """ + We had to solve a bug where the wizard was creating 2 requests on first intro acknowledgement ('continue') + The wizard was also creating multiiple requests on 'continue' -> back button -> 'continue' etc. + + This tests that the domain requests get created only when they should. + """ + # Get the intro page + self.app.get(reverse("home")) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + intro_page = self.app.get(reverse("domain-request:")) + + # Select the form + intro_form = intro_page.forms[0] + + # Submit the form, this creates 1 Request + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + response = intro_form.submit(name="submit_button", value="intro_acknowledge") + + # Landing on the next page used to create another 1 request + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + response.follow() + + # Check if a new DomainRequest object has been created + domain_request_count = DomainRequest.objects.count() + self.assertEqual(domain_request_count, 1) + + # Let's go back to intro and submit again, this should not create a new request + # This is the equivalent of a back button nav from step 1 to intro -> continue + intro_form = intro_page.forms[0] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + type_form = intro_form.submit(name="submit_button", value="intro_acknowledge") + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + type_form.follow() + domain_request_count = DomainRequest.objects.count() + self.assertEqual(domain_request_count, 1) + + # Go home, which will reset the session flag for new request + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + self.app.get(reverse("home")) + + # This time, clicking continue will create a new request + intro_form = intro_page.forms[0] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + intro_result = intro_form.submit(name="submit_button", value="intro_acknowledge") + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + intro_result.follow() + domain_request_count = DomainRequest.objects.count() + self.assertEqual(domain_request_count, 2) + @boto3_mocking.patching def test_domain_request_form_submission(self): """ diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index bd8431204..77699e17a 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -219,22 +219,23 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): self.storage["domain_request_id"] = kwargs["id"] self.storage["step_history"] = self.db_check_for_unlocking_steps() - # if accessing this class directly, redirect to the first step - # in other words, if `DomainRequestWizard` is called as view - # directly by some redirect or url handler, we'll send users - # either to an acknowledgement page or to the first step in - # the processes (if an edit rather than a new request); subclasses - # will NOT be redirected. The purpose of this is to allow code to - # send users "to the domain request wizard" without needing to - # know which view is first in the list of steps. - context = self.get_context_data() + # if accessing this class directly, redirect to either to an acknowledgement + # page or to the first step in the processes (if an edit rather than a new request); + # subclasseswill NOT be redirected. The purpose of this is to allow code to + # send users "to the domain request wizard" without needing to know which view + # is first in the list of steps. if self.__class__ == DomainRequestWizard: if request.path_info == self.NEW_URL_NAME: - context = self.get_context_data() - return render(request, "domain_request_intro.html", context=context) + # Clear context so the prop getter won't create a request here. + # Creating a request will be handled in the post method for the + # intro page. Only TEMPORARY context needed is has_profile_flag + has_profile_flag = flag_is_active(self.request, "profile_feature") + context_stuff = {"has_profile_feature_flag": has_profile_flag} + return render(request, "domain_request_intro.html", context=context_stuff) else: return self.goto(self.steps.first) + context = self.get_context_data() self.steps.current = current_url context["forms"] = self.get_forms() @@ -434,6 +435,10 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): return step_list def goto(self, step): + if step == "generic_org_type": + # We need to avoid creating a new domain request if the user + # clicks the back button + self.request.session["new_request"] = False self.steps.current = step return redirect(reverse(f"{self.URL_NAMESPACE}:{step}")) @@ -456,11 +461,17 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): # which button did the user press? button: str = request.POST.get("submit_button", "") - + # If a user hits the new request url directly + if "new_request" not in request.session: + request.session["new_request"] = True # if user has acknowledged the intro message if button == "intro_acknowledge": if request.path_info == self.NEW_URL_NAME: - del self.storage + + if self.request.session["new_request"] is True: + # This will trigger the domain_request getter into creating a new DomainRequest + del self.storage + return self.goto(self.steps.first) # if accessing this class directly, redirect to the first step diff --git a/src/registrar/views/index.py b/src/registrar/views/index.py index 54f315134..c05bde21d 100644 --- a/src/registrar/views/index.py +++ b/src/registrar/views/index.py @@ -10,4 +10,7 @@ def index(request): # This is a django waffle flag which toggles features based off of the "flag" table context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature") + # This controls the creation of a new domain request in the wizard + request.session["new_request"] = True + return render(request, "home.html", context) diff --git a/src/registrar/views/user_profile.py b/src/registrar/views/user_profile.py index 47b01f7cb..f148f5652 100644 --- a/src/registrar/views/user_profile.py +++ b/src/registrar/views/user_profile.py @@ -15,6 +15,7 @@ from django.urls import NoReverseMatch, reverse from registrar.models import ( Contact, ) +from registrar.models.user import User from registrar.models.utility.generic_helper import replace_url_queryparams from registrar.views.utility.permission_views import UserProfilePermissionView from waffle.decorators import flag_is_active, waffle_flag @@ -41,6 +42,13 @@ class UserProfileView(UserProfilePermissionView, FormMixin): form = self.form_class(instance=self.object) context = self.get_context_data(object=self.object, form=form) + if ( + hasattr(self.user, "finished_setup") + and not self.user.finished_setup + and self.user.verification_type != User.VerificationTypeChoices.REGULAR + ): + context["show_confirmation_modal"] = True + return_to_request = request.GET.get("return_to_request") if return_to_request: context["return_to_request"] = True @@ -67,7 +75,11 @@ class UserProfileView(UserProfilePermissionView, FormMixin): # The text for the back button on this page context["profile_back_button_text"] = "Go to manage your domains" - context["show_back_button"] = True + context["show_back_button"] = False + + if hasattr(self.user, "finished_setup") and self.user.finished_setup: + context["user_finished_setup"] = True + context["show_back_button"] = True return context @@ -94,6 +106,12 @@ class UserProfileView(UserProfilePermissionView, FormMixin): else: return self.form_invalid(form) + def form_invalid(self, form): + """If the form is invalid, conditionally display an additional error.""" + if hasattr(self.user, "finished_setup") and not self.user.finished_setup: + messages.error(self.request, "Before you can manage your domain, we need you to add contact information.") + return super().form_invalid(form) + def form_valid(self, form): """Handle successful and valid form submissions.""" form.save() @@ -105,9 +123,9 @@ class UserProfileView(UserProfilePermissionView, FormMixin): def get_object(self, queryset=None): """Override get_object to return the logged-in user's contact""" - user = self.request.user # get the logged in user - if hasattr(user, "contact"): # Check if the user has a contact instance - return user.contact + self.user = self.request.user # get the logged in user + if hasattr(self.user, "contact"): # Check if the user has a contact instance + return self.user.contact return None