diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f65007b2b..dec0b9fac 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -48,7 +48,7 @@ All other changes require just a single approving review.--> - [ ] Added at least 2 developers as PR reviewers (only 1 will need to approve) - [ ] Messaged on Slack or in standup to notify the team that a PR is ready for review - [ ] Changes to “how we do things” are documented in READMEs and or onboarding guide -- [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the assoicated migrations file has been commited. +- [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the associated migrations file has been commited. #### Ensured code standards are met (Original Developer) @@ -72,7 +72,7 @@ All other changes require just a single approving review.--> - [ ] Reviewed this code and left comments - [ ] Checked that all code is adequately covered by tests - [ ] Made it clear which comments need to be addressed before this work is merged -- [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the assoicated migrations file has been commited. +- [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the associated migrations file has been commited. #### Ensured code standards are met (Code reviewer) diff --git a/.github/workflows/deploy-development.yaml b/.github/workflows/deploy-development.yaml index 562b2b11f..686635c20 100644 --- a/.github/workflows/deploy-development.yaml +++ b/.github/workflows/deploy-development.yaml @@ -38,3 +38,11 @@ jobs: cf_org: cisa-dotgov cf_space: development push_arguments: "-f ops/manifests/manifest-development.yaml" + - name: Run Django migrations + uses: cloud-gov/cg-cli-tools@main + with: + cf_username: ${{ secrets.CF_DEVELOPMENT_USERNAME }} + 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" diff --git a/.github/workflows/migrate.yaml b/.github/workflows/migrate.yaml index 8523af013..2033ee51c 100644 --- a/.github/workflows/migrate.yaml +++ b/.github/workflows/migrate.yaml @@ -26,7 +26,6 @@ on: - rb - ko - ab - - bl - rjm - dk diff --git a/.github/workflows/reset-db.yaml b/.github/workflows/reset-db.yaml index 3848a33bd..f8730c865 100644 --- a/.github/workflows/reset-db.yaml +++ b/.github/workflows/reset-db.yaml @@ -26,7 +26,6 @@ on: - rb - ko - ab - - bl - rjm - dk diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 6d34f80b5..fd9e31b91 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -36,7 +36,7 @@ jobs: - name: Unit tests working-directory: ./src - run: docker compose run app python manage.py test + run: docker compose run app python manage.py test --parallel django-migrations-complete: runs-on: ubuntu-latest diff --git a/docs/developer/README.md b/docs/developer/README.md index 57985d6e2..dc4c9ddd2 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -145,7 +145,7 @@ You can change the logging verbosity, if needed. Do a web search for "django log ## Mock data -There is a `post_migrate` signal in [signals.py](../../src/registrar/signals.py) that will load the fixtures from [fixtures_user.py](../../src/registrar/fixtures_users.py) and [fixtures_applications.py](../../src/registrar/fixtures_applications.py), giving you some test data to play with while developing. +[load.py](../../src/registrar/management/commands/load.py) called from docker-compose (locally) and reset-db.yml (upper) loads the fixtures from [fixtures_user.py](../../src/registrar/fixtures_users.py) and [fixtures_applications.py](../../src/registrar/fixtures_applications.py), giving you some test data to play with while developing. See the [database-access README](./database-access.md) for information on how to pull data to update these fixtures. diff --git a/docs/operations/README.md b/docs/operations/README.md index 0e7b7a432..0bd55ab51 100644 --- a/docs/operations/README.md +++ b/docs/operations/README.md @@ -237,3 +237,7 @@ Bugs on production software need to be documented quickly and triaged to determi 3. In the case where the engineering lead is is unresponsive or unavailable to assign the ticket immediately, the product team will make sure an engineer volunteers or is assigned to the ticket/PR review ASAP. 4. Once done, the developer must make a PR and should tag the assigned PR reviewers in our Slack dev channel stating that the PR is now waiting on their review. These reviewers should drop other tasks in order to review this promptly. 5. See the the section above on [Making bug fixes on stable](#making-bug-fixes-on-stable-during-production) for how to push changes to stable once the PR is approved + +# Investigating and monitoring the health of the Registrar + +Sometimes, we may want individuals to routinely monitor the Registrar's health, such as after big feature launches. The cadence of such monitoring and what we look for is subject to change and is instead documented in [Checklist for production verification document](https://docs.google.com/document/d/15b_qwEZMiL76BHeRHnznV1HxDQcxNRt--vPSEfixBOI). All project team members should feel free to suggest edits to this document and should refer to it if production-level monitoring is underway. \ No newline at end of file diff --git a/ops/manifests/manifest-bl.yaml b/ops/manifests/manifest-bl.yaml deleted file mode 100644 index ea0617427..000000000 --- a/ops/manifests/manifest-bl.yaml +++ /dev/null @@ -1,32 +0,0 @@ ---- -applications: -- name: getgov-bl - 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-bl.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-bl.app.cloud.gov - services: - - getgov-credentials - - getgov-bl-database diff --git a/src/djangooidc/exceptions.py b/src/djangooidc/exceptions.py index 260750a4d..226337f54 100644 --- a/src/djangooidc/exceptions.py +++ b/src/djangooidc/exceptions.py @@ -33,6 +33,10 @@ class AuthenticationFailed(OIDCException): friendly_message = "This login attempt didn't work." +class NoStateDefined(OIDCException): + friendly_message = "The session state is None." + + class InternalError(OIDCException): status = status.INTERNAL_SERVER_ERROR friendly_message = "The system broke while trying to log you in." diff --git a/src/djangooidc/oidc.py b/src/djangooidc/oidc.py index 91bfddc66..bff766bb4 100644 --- a/src/djangooidc/oidc.py +++ b/src/djangooidc/oidc.py @@ -183,6 +183,8 @@ class Client(oic.Client): if authn_response["state"] != session.get("state", None): # this most likely means the user's Django session vanished logger.error("Received state not the same as expected for %s" % state) + if session.get("state", None) is None: + raise o_e.NoStateDefined() raise o_e.AuthenticationFailed(locator=state) if self.behaviour.get("response_type") == "code": @@ -272,6 +274,11 @@ class Client(oic.Client): super(Client, self).store_response(resp, info) + def get_default_acr_value(self): + """returns the acr_value from settings + this helper function is called from djangooidc views""" + return self.behaviour.get("acr_value") + def get_step_up_acr_value(self): """returns the step_up_acr_value from settings this helper function is called from djangooidc views""" diff --git a/src/djangooidc/tests/test_views.py b/src/djangooidc/tests/test_views.py index 282e91e1f..4193f723b 100644 --- a/src/djangooidc/tests/test_views.py +++ b/src/djangooidc/tests/test_views.py @@ -3,6 +3,8 @@ from unittest.mock import MagicMock, patch from django.http import HttpResponse from django.test import Client, TestCase, RequestFactory from django.urls import reverse + +from djangooidc.exceptions import NoStateDefined from ..views import login_callback from .common import less_console_noise @@ -17,6 +19,9 @@ class ViewsTest(TestCase): def say_hi(*args): return HttpResponse("Hi") + def create_acr(*args): + return "any string" + def user_info(*args): return { "sub": "TEST", @@ -30,141 +35,155 @@ class ViewsTest(TestCase): pass def test_openid_sets_next(self, mock_client): - # setup - callback_url = reverse("openid_login_callback") - # mock - mock_client.create_authn_request.side_effect = self.say_hi - # test - response = self.client.get(reverse("login"), {"next": callback_url}) - # assert - session = mock_client.create_authn_request.call_args[0][0] - self.assertEqual(session["next"], callback_url) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "Hi") + with less_console_noise(): + # setup + callback_url = reverse("openid_login_callback") + # mock + mock_client.create_authn_request.side_effect = self.say_hi + mock_client.get_default_acr_value.side_effect = self.create_acr + # test + response = self.client.get(reverse("login"), {"next": callback_url}) + # assert + session = mock_client.create_authn_request.call_args[0][0] + self.assertEqual(session["next"], callback_url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Hi") def test_openid_raises(self, mock_client): - # mock - mock_client.create_authn_request.side_effect = Exception("Test") - # test with less_console_noise(): + # mock + mock_client.create_authn_request.side_effect = Exception("Test") + # test response = self.client.get(reverse("login")) - # assert - self.assertEqual(response.status_code, 500) - self.assertTemplateUsed(response, "500.html") - self.assertIn("Server error", response.content.decode("utf-8")) + # assert + self.assertEqual(response.status_code, 500) + self.assertTemplateUsed(response, "500.html") + self.assertIn("Server error", response.content.decode("utf-8")) + + def test_callback_with_no_session_state(self, mock_client): + """If the local session is None (ie the server restarted while user was logged out), + we do not throw an exception. Rather, we attempt to login again.""" + with less_console_noise(): + # mock + mock_client.get_default_acr_value.side_effect = self.create_acr + mock_client.callback.side_effect = NoStateDefined() + # test + response = self.client.get(reverse("openid_login_callback")) + # assert + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "/") def test_login_callback_reads_next(self, mock_client): - # setup - session = self.client.session - session["next"] = reverse("logout") - session.save() - # mock - mock_client.callback.side_effect = self.user_info - # test - with patch("djangooidc.views.requires_step_up_auth", return_value=False), less_console_noise(): - response = self.client.get(reverse("openid_login_callback")) - # assert - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, reverse("logout")) + with less_console_noise(): + # setup + session = self.client.session + session["next"] = reverse("logout") + session.save() + # mock + mock_client.callback.side_effect = self.user_info + # test + with patch("djangooidc.views.requires_step_up_auth", return_value=False), less_console_noise(): + response = self.client.get(reverse("openid_login_callback")) + # assert + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, reverse("logout")) 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 /""" - # setup - session = self.client.session - session.save() - # mock - mock_client.callback.side_effect = self.user_info - # test - with patch("djangooidc.views.requires_step_up_auth", return_value=False), less_console_noise(): - response = self.client.get(reverse("openid_login_callback")) - # assert - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, "/") + with less_console_noise(): + # setup + session = self.client.session + session.save() + # mock + mock_client.callback.side_effect = self.user_info + # test + with patch("djangooidc.views.requires_step_up_auth", return_value=False), less_console_noise(): + response = self.client.get(reverse("openid_login_callback")) + # assert + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "/") def test_requires_step_up_auth(self, mock_client): """Invoke login_callback passing it a request when requires_step_up_auth returns True and assert that session is updated and create_authn_request (mock) is called.""" - # Configure the mock to return an expected value for get_step_up_acr_value - mock_client.return_value.get_step_up_acr_value.return_value = "step_up_acr_value" - - # Create a mock request - request = self.factory.get("/some-url") - request.session = {"acr_value": ""} - - # Ensure that the CLIENT instance used in login_callback is the mock - # patch requires_step_up_auth to return True - with patch("djangooidc.views.requires_step_up_auth", return_value=True), patch( - "djangooidc.views.CLIENT.create_authn_request", return_value=MagicMock() - ) as mock_create_authn_request: - login_callback(request) - - # create_authn_request only gets called when requires_step_up_auth is True - # and it changes this acr_value in request.session - - # Assert that acr_value is no longer empty string - self.assertNotEqual(request.session["acr_value"], "") - # And create_authn_request was called again - mock_create_authn_request.assert_called_once() + with less_console_noise(): + # Configure the mock to return an expected value for get_step_up_acr_value + mock_client.return_value.get_step_up_acr_value.return_value = "step_up_acr_value" + # Create a mock request + request = self.factory.get("/some-url") + request.session = {"acr_value": ""} + # Ensure that the CLIENT instance used in login_callback is the mock + # patch requires_step_up_auth to return True + with patch("djangooidc.views.requires_step_up_auth", return_value=True), patch( + "djangooidc.views.CLIENT.create_authn_request", return_value=MagicMock() + ) as mock_create_authn_request: + login_callback(request) + # create_authn_request only gets called when requires_step_up_auth is True + # and it changes this acr_value in request.session + # Assert that acr_value is no longer empty string + self.assertNotEqual(request.session["acr_value"], "") + # And create_authn_request was called again + mock_create_authn_request.assert_called_once() def test_does_not_requires_step_up_auth(self, mock_client): """Invoke login_callback passing it a request when requires_step_up_auth returns False and assert that session is not updated and create_authn_request (mock) is not called. Possibly redundant with test_login_callback_requires_step_up_auth""" - # Create a mock request - request = self.factory.get("/some-url") - request.session = {"acr_value": ""} - - # Ensure that the CLIENT instance used in login_callback is the mock - # patch requires_step_up_auth to return False - with patch("djangooidc.views.requires_step_up_auth", return_value=False), patch( - "djangooidc.views.CLIENT.create_authn_request", return_value=MagicMock() - ) as mock_create_authn_request: - login_callback(request) - - # create_authn_request only gets called when requires_step_up_auth is True - # and it changes this acr_value in request.session - - # Assert that acr_value is NOT updated by testing that it is still an empty string - self.assertEqual(request.session["acr_value"], "") - # Assert create_authn_request was not called - mock_create_authn_request.assert_not_called() + with less_console_noise(): + # Create a mock request + request = self.factory.get("/some-url") + request.session = {"acr_value": ""} + # Ensure that the CLIENT instance used in login_callback is the mock + # patch requires_step_up_auth to return False + with patch("djangooidc.views.requires_step_up_auth", return_value=False), patch( + "djangooidc.views.CLIENT.create_authn_request", return_value=MagicMock() + ) as mock_create_authn_request: + login_callback(request) + # create_authn_request only gets called when requires_step_up_auth is True + # and it changes this acr_value in request.session + # Assert that acr_value is NOT updated by testing that it is still an empty string + self.assertEqual(request.session["acr_value"], "") + # Assert create_authn_request was not called + mock_create_authn_request.assert_not_called() @patch("djangooidc.views.authenticate") def test_login_callback_raises(self, mock_auth, mock_client): - # mock - mock_client.callback.side_effect = self.user_info - mock_auth.return_value = None - # test - with patch("djangooidc.views.requires_step_up_auth", return_value=False), less_console_noise(): - response = self.client.get(reverse("openid_login_callback")) - # assert - self.assertEqual(response.status_code, 401) - self.assertTemplateUsed(response, "401.html") - self.assertIn("Unauthorized", response.content.decode("utf-8")) + with less_console_noise(): + # mock + mock_client.callback.side_effect = self.user_info + mock_auth.return_value = None + # test + with patch("djangooidc.views.requires_step_up_auth", return_value=False), less_console_noise(): + response = self.client.get(reverse("openid_login_callback")) + # assert + self.assertEqual(response.status_code, 401) + self.assertTemplateUsed(response, "401.html") + self.assertIn("Unauthorized", response.content.decode("utf-8")) def test_logout_redirect_url(self, mock_client): - # setup - session = self.client.session - session["state"] = "TEST" # nosec B105 - session.save() - # mock - mock_client.callback.side_effect = self.user_info - mock_client.registration_response = {"post_logout_redirect_uris": ["http://example.com/back"]} - mock_client.provider_info = {"end_session_endpoint": "http://example.com/log_me_out"} - mock_client.client_id = "TEST" - # test with less_console_noise(): - response = self.client.get(reverse("logout")) - # assert - expected = ( - "http://example.com/log_me_out?client_id=TEST&state" - "=TEST&post_logout_redirect_uri=http%3A%2F%2Fexample.com%2Fback" - ) - actual = response.url - self.assertEqual(response.status_code, 302) - self.assertEqual(actual, expected) + # setup + session = self.client.session + session["state"] = "TEST" # nosec B105 + session.save() + # mock + mock_client.callback.side_effect = self.user_info + mock_client.registration_response = {"post_logout_redirect_uris": ["http://example.com/back"]} + mock_client.provider_info = {"end_session_endpoint": "http://example.com/log_me_out"} + mock_client.client_id = "TEST" + # test + with less_console_noise(): + response = self.client.get(reverse("logout")) + # assert + expected = ( + "http://example.com/log_me_out?client_id=TEST&state" + "=TEST&post_logout_redirect_uri=http%3A%2F%2Fexample.com%2Fback" + ) + actual = response.url + self.assertEqual(response.status_code, 302) + self.assertEqual(actual, expected) @patch("djangooidc.views.auth_logout") def test_logout_always_logs_out(self, mock_logout, _): @@ -175,12 +194,13 @@ class ViewsTest(TestCase): self.assertTrue(mock_logout.called) def test_logout_callback_redirects(self, _): - # setup - session = self.client.session - session["next"] = reverse("logout") - session.save() - # test - response = self.client.get(reverse("openid_logout_callback")) - # assert - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, reverse("logout")) + with less_console_noise(): + # setup + session = self.client.session + session["next"] = reverse("logout") + session.save() + # test + response = self.client.get(reverse("openid_logout_callback")) + # assert + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, reverse("logout")) diff --git a/src/djangooidc/views.py b/src/djangooidc/views.py index b5905df48..2fc2a0363 100644 --- a/src/djangooidc/views.py +++ b/src/djangooidc/views.py @@ -55,6 +55,10 @@ def error_page(request, error): def openid(request): """Redirect the user to an authentication provider (OP).""" + + # If the session reset because of a server restart, attempt to login again + request.session["acr_value"] = CLIENT.get_default_acr_value() + request.session["next"] = request.GET.get("next", "/") try: @@ -78,9 +82,13 @@ def login_callback(request): if user: login(request, user) logger.info("Successfully logged in user %s" % user) + # Double login bug (1507)? return redirect(request.session.get("next", "/")) else: raise o_e.BannedUser() + except o_e.NoStateDefined as nsd_err: + logger.warning(f"No State Defined: {nsd_err}") + return redirect(request.session.get("next", "/")) except Exception as err: return error_page(request, err) diff --git a/src/docker-compose.yml b/src/docker-compose.yml index ba6530674..fdf069f56 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -25,6 +25,8 @@ services: - DJANGO_SECRET_KEY=really-long-random-string-BNPecI7+s8jMahQcGHZ3XQ5yUfRrSibdapVLIz0UemdktVPofDKcoy # Run Django in debug mode on local - DJANGO_DEBUG=True + # Set DJANGO_LOG_LEVEL in env + - DJANGO_LOG_LEVEL # Run Django without production flags - IS_PRODUCTION=False # Tell Django where it is being hosted diff --git a/src/epplibwrapper/tests/common.py b/src/epplibwrapper/tests/common.py new file mode 100644 index 000000000..122965ae8 --- /dev/null +++ b/src/epplibwrapper/tests/common.py @@ -0,0 +1,51 @@ +import os +import logging + +from contextlib import contextmanager + + +def get_handlers(): + """Obtain pointers to all StreamHandlers.""" + handlers = {} + + rootlogger = logging.getLogger() + for h in rootlogger.handlers: + if isinstance(h, logging.StreamHandler): + handlers[h.name] = h + + for logger in logging.Logger.manager.loggerDict.values(): + if not isinstance(logger, logging.PlaceHolder): + for h in logger.handlers: + if isinstance(h, logging.StreamHandler): + handlers[h.name] = h + + return handlers + + +@contextmanager +def less_console_noise(): + """ + Context manager to use in tests to silence console logging. + + This is helpful on tests which trigger console messages + (such as errors) which are normal and expected. + + It can easily be removed to debug a failing test. + """ + restore = {} + handlers = get_handlers() + devnull = open(os.devnull, "w") + + # redirect all the streams + for handler in handlers.values(): + prior = handler.setStream(devnull) + restore[handler.name] = prior + try: + # run the test + yield + finally: + # restore the streams + for handler in handlers.values(): + handler.setStream(restore[handler.name]) + # close the file we opened + devnull.close() diff --git a/src/epplibwrapper/tests/test_pool.py b/src/epplibwrapper/tests/test_pool.py index 1c36d26da..f8e556445 100644 --- a/src/epplibwrapper/tests/test_pool.py +++ b/src/epplibwrapper/tests/test_pool.py @@ -9,7 +9,7 @@ from epplibwrapper.socket import Socket from epplibwrapper.utility.pool import EPPConnectionPool from registrar.models.domain import registry from contextlib import ExitStack - +from .common import less_console_noise import logging try: @@ -135,23 +135,26 @@ class TestConnectionPool(TestCase): stack.enter_context(patch.object(EPPConnectionPool, "kill_all_connections", do_nothing)) stack.enter_context(patch.object(SocketTransport, "send", self.fake_send)) stack.enter_context(patch.object(SocketTransport, "receive", fake_receive)) - # Restart the connection pool - registry.start_connection_pool() - # Pool should be running, and be the right size - self.assertEqual(registry.pool_status.connection_success, True) - self.assertEqual(registry.pool_status.pool_running, True) + with less_console_noise(): + # Restart the connection pool + registry.start_connection_pool() + # Pool should be running, and be the right size + self.assertEqual(registry.pool_status.connection_success, True) + self.assertEqual(registry.pool_status.pool_running, True) - # Send a command - result = registry.send(commands.InfoDomain(name="test.gov"), cleaned=True) + # Send a command + result = registry.send(commands.InfoDomain(name="test.gov"), cleaned=True) - # Should this ever fail, it either means that the schema has changed, - # or the pool is broken. - # If the schema has changed: Update the associated infoDomain.xml file - self.assertEqual(result.__dict__, expected_result) + # Should this ever fail, it either means that the schema has changed, + # or the pool is broken. + # If the schema has changed: Update the associated infoDomain.xml file + self.assertEqual(result.__dict__, expected_result) - # The number of open pools should match the number of requested ones. - # If it is 0, then they failed to open - self.assertEqual(len(registry._pool.conn), self.pool_options["size"]) + # The number of open pools should match the number of requested ones. + # If it is 0, then they failed to open + self.assertEqual(len(registry._pool.conn), self.pool_options["size"]) + # Kill the connection pool + registry.kill_pool() @patch.object(EPPLibWrapper, "_test_registry_connection_success", patch_success) def test_pool_restarts_on_send(self): @@ -198,35 +201,43 @@ class TestConnectionPool(TestCase): xml = (location).read_bytes() return xml + def do_nothing(command): + pass + # Mock what happens inside the "with" with ExitStack() as stack: stack.enter_context(patch.object(EPPConnectionPool, "_create_socket", self.fake_socket)) stack.enter_context(patch.object(Socket, "connect", self.fake_client)) + stack.enter_context(patch.object(EPPConnectionPool, "kill_all_connections", do_nothing)) stack.enter_context(patch.object(SocketTransport, "send", self.fake_send)) stack.enter_context(patch.object(SocketTransport, "receive", fake_receive)) - # Kill the connection pool - registry.kill_pool() + with less_console_noise(): + # Start the connection pool + registry.start_connection_pool() + # Kill the connection pool + registry.kill_pool() - self.assertEqual(registry.pool_status.connection_success, False) - self.assertEqual(registry.pool_status.pool_running, False) + self.assertEqual(registry.pool_status.pool_running, False) - # An exception should be raised as end user will be informed - # that they cannot connect to EPP - with self.assertRaises(RegistryError): - expected = "InfoDomain failed to execute due to a connection error." + # An exception should be raised as end user will be informed + # that they cannot connect to EPP + with self.assertRaises(RegistryError): + expected = "InfoDomain failed to execute due to a connection error." + result = registry.send(commands.InfoDomain(name="test.gov"), cleaned=True) + self.assertEqual(result, expected) + + # A subsequent command should be successful, as the pool restarts result = registry.send(commands.InfoDomain(name="test.gov"), cleaned=True) - self.assertEqual(result, expected) + # Should this ever fail, it either means that the schema has changed, + # or the pool is broken. + # If the schema has changed: Update the associated infoDomain.xml file + self.assertEqual(result.__dict__, expected_result) - # A subsequent command should be successful, as the pool restarts - result = registry.send(commands.InfoDomain(name="test.gov"), cleaned=True) - # Should this ever fail, it either means that the schema has changed, - # or the pool is broken. - # If the schema has changed: Update the associated infoDomain.xml file - self.assertEqual(result.__dict__, expected_result) - - # The number of open pools should match the number of requested ones. - # If it is 0, then they failed to open - self.assertEqual(len(registry._pool.conn), self.pool_options["size"]) + # The number of open pools should match the number of requested ones. + # If it is 0, then they failed to open + self.assertEqual(len(registry._pool.conn), self.pool_options["size"]) + # Kill the connection pool + registry.kill_pool() @patch.object(EPPLibWrapper, "_test_registry_connection_success", patch_success) def test_raises_connection_error(self): @@ -236,13 +247,16 @@ class TestConnectionPool(TestCase): with ExitStack() as stack: stack.enter_context(patch.object(EPPConnectionPool, "_create_socket", self.fake_socket)) stack.enter_context(patch.object(Socket, "connect", self.fake_client)) + with less_console_noise(): + # Start the connection pool + registry.start_connection_pool() - # Pool should be running - self.assertEqual(registry.pool_status.connection_success, True) - self.assertEqual(registry.pool_status.pool_running, True) + # Pool should be running + self.assertEqual(registry.pool_status.connection_success, True) + self.assertEqual(registry.pool_status.pool_running, True) - # Try to send a command out - should fail - with self.assertRaises(RegistryError): - expected = "InfoDomain failed to execute due to a connection error." - result = registry.send(commands.InfoDomain(name="test.gov"), cleaned=True) - self.assertEqual(result, expected) + # Try to send a command out - should fail + with self.assertRaises(RegistryError): + expected = "InfoDomain failed to execute due to a connection error." + result = registry.send(commands.InfoDomain(name="test.gov"), cleaned=True) + self.assertEqual(result, expected) diff --git a/src/epplibwrapper/utility/pool.py b/src/epplibwrapper/utility/pool.py index 93edb2782..4f54e14ce 100644 --- a/src/epplibwrapper/utility/pool.py +++ b/src/epplibwrapper/utility/pool.py @@ -85,6 +85,21 @@ class EPPConnectionPool(ConnectionPool): logger.error(message, exc_info=True) raise PoolError(code=PoolErrorCodes.KEEP_ALIVE_FAILED) from err + def _keepalive_periodic(self): + """Overriding _keepalive_periodic from geventconnpool so that PoolErrors + are properly handled, as opposed to printing to stdout""" + delay = float(self.keepalive) / self.size + while 1: + try: + with self.get() as c: + self._keepalive(c) + except PoolError as err: + logger.error(err.message, exc_info=True) + except self.exc_classes: + # Nothing to do, the pool will generate a new connection later + pass + gevent.sleep(delay) + def _create_socket(self, client, login) -> Socket: """Creates and returns a socket instance""" socket = Socket(client, login) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 79fc93d95..1bfda0b84 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -20,6 +20,8 @@ from . import models from auditlog.models import LogEntry # type: ignore from auditlog.admin import LogEntryAdmin # type: ignore from django_fsm import TransitionNotAllowed # type: ignore +from django.utils.safestring import mark_safe +from django.utils.html import escape logger = logging.getLogger(__name__) @@ -452,6 +454,60 @@ class ContactAdmin(ListHeaderAdmin): readonly_fields.extend([field for field in self.analyst_readonly_fields]) return readonly_fields # Read-only fields for analysts + def change_view(self, request, object_id, form_url="", extra_context=None): + """Extend the change_view for Contact objects in django admin. + Customize to display related objects to the Contact. These will be passed + through the messages construct to the template for display to the user.""" + + # Fetch the Contact instance + contact = models.Contact.objects.get(pk=object_id) + + # initialize related_objects array + related_objects = [] + # for all defined fields in the model + for related_field in contact._meta.get_fields(): + # if the field is a relation to another object + if related_field.is_relation: + # Check if the related field is not None + related_manager = getattr(contact, related_field.name) + if related_manager is not None: + # Check if it's a ManyToManyField/reverse ForeignKey or a OneToOneField + # Do this by checking for get_queryset method on the related_manager + if hasattr(related_manager, "get_queryset"): + # Handles ManyToManyRel and ManyToOneRel + queryset = related_manager.get_queryset() + else: + # Handles OneToOne rels, ie. User + queryset = [related_manager] + + for obj in queryset: + # for each object, build the edit url in this view and add as tuple + # to the related_objects array + app_label = obj._meta.app_label + model_name = obj._meta.model_name + obj_id = obj.id + change_url = reverse("admin:%s_%s_change" % (app_label, model_name), args=[obj_id]) + related_objects.append((change_url, obj)) + + if related_objects: + message = "
" + if len(related_objects) > 5: + related_objects_over_five = len(related_objects) - 5 + message += f"And {related_objects_over_five} more...
" + + message_html = mark_safe(message) # nosec + messages.warning( + request, + message_html, + ) + + return super().change_view(request, object_id, form_url, extra_context=extra_context) + class WebsiteAdmin(ListHeaderAdmin): """Custom website admin class.""" @@ -570,7 +626,7 @@ class DomainInformationAdmin(ListHeaderAdmin): search_help_text = "Search by domain." fieldsets = [ - (None, {"fields": ["creator", "domain_application"]}), + (None, {"fields": ["creator", "domain_application", "notes"]}), ( "Type of organization", { @@ -738,7 +794,7 @@ class DomainApplicationAdmin(ListHeaderAdmin): # Detail view form = DomainApplicationAdminForm fieldsets = [ - (None, {"fields": ["status", "investigator", "creator", "approved_domain"]}), + (None, {"fields": ["status", "investigator", "creator", "approved_domain", "notes"]}), ( "Type of organization", { @@ -993,6 +1049,13 @@ class DomainAdmin(ListHeaderAdmin): "deleted", ] + fieldsets = ( + ( + None, + {"fields": ["name", "state", "expiration_date", "first_ready", "deleted"]}, + ), + ) + # this ordering effects the ordering of results # in autocomplete_fields for domain ordering = ["name"] @@ -1241,6 +1304,29 @@ class DraftDomainAdmin(ListHeaderAdmin): search_help_text = "Search by draft domain name." +class VerifiedByStaffAdmin(ListHeaderAdmin): + list_display = ("email", "requestor", "truncated_notes", "created_at") + search_fields = ["email"] + search_help_text = "Search by email." + list_filter = [ + "requestor", + ] + readonly_fields = [ + "requestor", + ] + + def truncated_notes(self, obj): + # Truncate the 'notes' field to 50 characters + return str(obj.notes)[:50] + + truncated_notes.short_description = "Notes (Truncated)" # type: ignore + + def save_model(self, request, obj, form, change): + # Set the user field to the current admin user + obj.requestor = request.user if request.user.is_authenticated else None + super().save_model(request, obj, form, change) + + admin.site.unregister(LogEntry) # Unregister the default registration admin.site.register(LogEntry, CustomLogEntryAdmin) admin.site.register(models.User, MyUserAdmin) @@ -1261,3 +1347,4 @@ admin.site.register(models.Website, WebsiteAdmin) admin.site.register(models.PublicContact, AuditedAdmin) admin.site.register(models.DomainApplication, DomainApplicationAdmin) admin.site.register(models.TransitionDomain, TransitionDomainAdmin) +admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index cdbbc83ee..866c7bd7d 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -283,19 +283,20 @@ function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk, (function (){ // Get the current date in the format YYYY-MM-DD - var currentDate = new Date().toISOString().split('T')[0]; + let currentDate = new Date().toISOString().split('T')[0]; // Default the value of the start date input field to the current date let startDateInput =document.getElementById('start'); - startDateInput.value = currentDate; - + // Default the value of the end date input field to the current date let endDateInput =document.getElementById('end'); - endDateInput.value = currentDate; let exportGrowthReportButton = document.getElementById('exportLink'); if (exportGrowthReportButton) { + startDateInput.value = currentDate; + endDateInput.value = currentDate; + exportGrowthReportButton.addEventListener('click', function() { // Get the selected start and end dates let startDate = startDateInput.value; diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index de7ef6172..587b95305 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -130,7 +130,7 @@ function inlineToast(el, id, style, msg) { } } -function _checkDomainAvailability(el) { +function checkDomainAvailability(el) { const callback = (response) => { toggleInputValidity(el, (response && response.available), msg=response.message); announce(el.id, response.message); @@ -154,9 +154,6 @@ function _checkDomainAvailability(el) { fetchJSON(`available/?domain=${el.value}`, callback); } -/** Call the API to see if the domain is good. */ -const checkDomainAvailability = debounce(_checkDomainAvailability); - /** Hides the toast message and clears the aira live region. */ function clearDomainAvailability(el) { el.classList.remove('usa-input--success'); @@ -206,13 +203,33 @@ function handleInputValidation(e) { } /** On button click, handles running any associated validators. */ -function handleValidationClick(e) { +function validateFieldInput(e) { const attribute = e.target.getAttribute("validate-for") || ""; if (!attribute.length) return; const input = document.getElementById(attribute); + removeFormErrors(input, true); runValidators(input); } + +function validateFormsetInputs(e, availabilityButton) { + + // Collect input IDs from the repeatable forms + let inputs = Array.from(document.querySelectorAll('.repeatable-form input')) + + // Run validators for each input + inputs.forEach(input => { + removeFormErrors(input, true); + runValidators(input); + }); + + // Set the validate-for attribute on the button with the collected input IDs + // Not needed for functionality but nice for accessibility + inputs = inputs.map(input => input.id).join(', '); + availabilityButton.setAttribute('validate-for', inputs); + +} + // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> // Initialization code. @@ -232,14 +249,64 @@ function handleValidationClick(e) { for(const input of needsValidation) { input.addEventListener('input', handleInputValidation); } + const alternativeDomainsAvailability = document.getElementById('validate-alt-domains-availability'); const activatesValidation = document.querySelectorAll('[validate-for]'); + for(const button of activatesValidation) { - button.addEventListener('click', handleValidationClick); + // Adds multi-field validation for alternative domains + if (button === alternativeDomainsAvailability) { + button.addEventListener('click', (e) => { + validateFormsetInputs(e, alternativeDomainsAvailability) + }); + } else { + button.addEventListener('click', validateFieldInput); + } } })(); /** - * Delete method for formsets that diff in the view and delete in the model (Nameservers, DS Data) + * Removes form errors surrounding a form input + */ +function removeFormErrors(input, removeStaleAlerts=false){ + // Remove error message + let errorMessage = document.getElementById(`${input.id}__error-message`); + if (errorMessage) { + errorMessage.remove(); + }else{ + return + } + + // Remove error classes + if (input.classList.contains('usa-input--error')) { + input.classList.remove('usa-input--error'); + } + + // Get the form label + let label = document.querySelector(`label[for="${input.id}"]`); + if (label) { + label.classList.remove('usa-label--error'); + + // Remove error classes from parent div + let parentDiv = label.parentElement; + if (parentDiv) { + parentDiv.classList.remove('usa-form-group--error'); + } + } + + if (removeStaleAlerts){ + let staleAlerts = document.querySelectorAll(".usa-alert--error") + for (let alert of staleAlerts){ + // Don't remove the error associated with the input + if (alert.id !== `${input.id}--toast`) { + alert.remove() + } + } + } +} + +/** + * Prepare the namerservers and DS data forms delete buttons + * We will call this on the forms init, and also every time we add a form * */ function removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier){ @@ -460,6 +527,7 @@ function hideDeletedForms() { let isNameserversForm = document.querySelector(".nameservers-form"); let isOtherContactsForm = document.querySelector(".other-contacts-form"); let isDsDataForm = document.querySelector(".ds-data-form"); + let isDotgovDomain = document.querySelector(".dotgov-domain-form"); // The Nameservers formset features 2 required and 11 optionals if (isNameserversForm) { cloneIndex = 2; @@ -472,6 +540,8 @@ function hideDeletedForms() { formLabel = "Organization contact"; container = document.querySelector("#other-employees"); formIdentifier = "other_contacts" + } else if (isDotgovDomain) { + formIdentifier = "dotgov_domain" } let totalForms = document.querySelector(`#id_${formIdentifier}-TOTAL_FORMS`); @@ -554,6 +624,7 @@ function hideDeletedForms() { // Reset the values of each input to blank inputs.forEach((input) => { input.classList.remove("usa-input--error"); + input.classList.remove("usa-input--success"); if (input.type === "text" || input.type === "number" || input.type === "password" || input.type === "email" || input.type === "tel") { input.value = ""; // Set the value to an empty string @@ -566,22 +637,25 @@ function hideDeletedForms() { let selects = newForm.querySelectorAll("select"); selects.forEach((select) => { select.classList.remove("usa-input--error"); + select.classList.remove("usa-input--success"); select.selectedIndex = 0; // Set the value to an empty string }); let labels = newForm.querySelectorAll("label"); labels.forEach((label) => { label.classList.remove("usa-label--error"); + label.classList.remove("usa-label--success"); }); let usaFormGroups = newForm.querySelectorAll(".usa-form-group"); usaFormGroups.forEach((usaFormGroup) => { usaFormGroup.classList.remove("usa-form-group--error"); + usaFormGroup.classList.remove("usa-form-group--success"); }); - // Remove any existing error messages - let usaErrorMessages = newForm.querySelectorAll(".usa-error-message"); - usaErrorMessages.forEach((usaErrorMessage) => { + // Remove any existing error and success messages + let usaMessages = newForm.querySelectorAll(".usa-error-message, .usa-alert"); + usaMessages.forEach((usaErrorMessage) => { let parentDiv = usaErrorMessage.closest('div'); if (parentDiv) { parentDiv.remove(); // Remove the parent div if it exists @@ -592,7 +666,8 @@ function hideDeletedForms() { // Attach click event listener on the delete buttons of the new form let newDeleteButton = newForm.querySelector(".delete-record"); - prepareNewDeleteButton(newDeleteButton, formLabel); + if (newDeleteButton) + prepareNewDeleteButton(newDeleteButton, formLabel); // Disable the add more button if we have 13 forms if (isNameserversForm && formNum == 13) { diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index a3d631243..760c4f13a 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -143,7 +143,7 @@ h1, h2, h3, .module h3 { padding: 0; - color: var(--primary); + color: var(--link-fg); margin: units(2) 0 units(1) 0; } @@ -258,3 +258,15 @@ h1, h2, h3, #select2-id_user-results { width: 100%; } + +// Content list inside of a DjA alert, unstyled +.messagelist_content-list--unstyled { + padding-left: 0; + li { + font-family: "Source Sans Pro Web", "Helvetica Neue", Helvetica, Roboto, Arial, sans-serif; + font-size: 13.92px!important; + background: none!important; + padding: 0!important; + margin: 0!important; + } +} diff --git a/src/registrar/assets/sass/_theme/_buttons.scss b/src/registrar/assets/sass/_theme/_buttons.scss index 02089ec6d..2f4121399 100644 --- a/src/registrar/assets/sass/_theme/_buttons.scss +++ b/src/registrar/assets/sass/_theme/_buttons.scss @@ -44,6 +44,22 @@ a.usa-button.disabled-link:focus { color: #454545 !important } +a.usa-button--unstyled.disabled-link, +a.usa-button--unstyled.disabled-link:hover, +a.usa-button--unstyled.disabled-link:focus { + cursor: not-allowed !important; + outline: none !important; + text-decoration: none !important; +} + +.usa-button--unstyled.disabled-button, +.usa-button--unstyled.disabled-link:hover, +.usa-button--unstyled.disabled-link:focus { + cursor: not-allowed !important; + outline: none !important; + text-decoration: none !important; +} + a.usa-button:not(.usa-button--unstyled, .usa-button--outline) { color: color('white'); } diff --git a/src/registrar/assets/sass/_theme/_lists.scss b/src/registrar/assets/sass/_theme/_lists.scss new file mode 100644 index 000000000..b97abd569 --- /dev/null +++ b/src/registrar/assets/sass/_theme/_lists.scss @@ -0,0 +1,14 @@ +@use "uswds-core" as *; + +dt { + color: color('primary-dark'); + margin-top: units(2); + font-weight: font-weight('semibold'); + // The units mixin can only get us close, so it's between + // hardcoding the value and using in markup + font-size: 16.96px; +} +dd { + margin-left: 0; +} + diff --git a/src/registrar/assets/sass/_theme/_register-form.scss b/src/registrar/assets/sass/_theme/_register-form.scss index 6d268d155..7c93f0a10 100644 --- a/src/registrar/assets/sass/_theme/_register-form.scss +++ b/src/registrar/assets/sass/_theme/_register-form.scss @@ -6,12 +6,14 @@ margin-top: units(-1); } - //Tighter spacing when H2 is immediatly after H1 +// Tighter spacing when h2 is immediatly after h1 .register-form-step .usa-fieldset:first-of-type h2:first-of-type, .register-form-step h1 + h2 { margin-top: units(1); } +// register-form-review-header is used on the summary page and +// should not be styled like the register form headers .register-form-step h3 { color: color('primary-dark'); letter-spacing: $letter-space--xs; @@ -23,6 +25,16 @@ } } +h3.register-form-review-header { + color: color('primary-dark'); + margin-top: units(2); + margin-bottom: 0; + font-weight: font-weight('semibold'); + // The units mixin can only get us close, so it's between + // hardcoding the value and using in markup + font-size: 16.96px; +} + .register-form-step h4 { margin-bottom: 0; diff --git a/src/registrar/assets/sass/_theme/styles.scss b/src/registrar/assets/sass/_theme/styles.scss index 8a2e1d2d3..0239199e7 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 "lists"; @forward "buttons"; @forward "forms"; @forward "fieldsets"; diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index efa512f22..372434887 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -660,7 +660,6 @@ ALLOWED_HOSTS = [ "getgov-rb.app.cloud.gov", "getgov-ko.app.cloud.gov", "getgov-ab.app.cloud.gov", - "getgov-bl.app.cloud.gov", "getgov-rjm.app.cloud.gov", "getgov-dk.app.cloud.gov", "manage.get.gov", diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index ba5ae22cc..f6378b555 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -142,6 +142,11 @@ urlpatterns = [ views.DomainApplicationDeleteView.as_view(http_method_names=["post"]), name="application-delete", ), + path( + "domain/Names that uniquely apply to your organization are likely to be approved over names that could also apply to other organizations. In most instances, this requires including your state’s two-letter abbreviation.
+Names that uniquely apply to your organization are likely to be approved over names that could also apply to other organizations. + {% if not is_federal %}In most instances, this requires including your state’s two-letter abbreviation.{% endif %}
Requests for your organization’s initials or an abbreviated name might not be approved, but we encourage you to request the name you want.
@@ -48,16 +49,15 @@ {% endwith %} {% endwith %} {{ forms.1.management_form }} - {% endblock %} diff --git a/src/registrar/templates/application_review.html b/src/registrar/templates/application_review.html index 974830e91..4af6b758f 100644 --- a/src/registrar/templates/application_review.html +++ b/src/registrar/templates/application_review.html @@ -20,108 +20,158 @@ {% block form_fields %} {% for step in steps.all|slice:":-1" %} -Federally-recognized tribe
{% endif %} - {% if application.state_recognized_tribe %}State-recognized tribe
{% endif %} + {% endif %} + + {% if step == Step.TRIBAL_GOVERNMENT %} + {% namespaced_url 'application' step as application_url %} + {% with title=form_titles|get_item:step value=application.tribe_name|default:"Incomplete" %} + {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url %} + {% endwith %} + {% if application.federally_recognized_tribe %}Federally-recognized tribe
{% endif %} + {% if application.state_recognized_tribe %}State-recognized tribe
{% endif %} + {% endif %} + + + {% if step == Step.ORGANIZATION_FEDERAL %} + {% namespaced_url 'application' step as application_url %} + {% with title=form_titles|get_item:step value=application.get_federal_type_display|default:"Incomplete" %} + {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url %} + {% endwith %} + {% endif %} + + {% if step == Step.ORGANIZATION_ELECTION %} + {% namespaced_url 'application' step as application_url %} + {% with title=form_titles|get_item:step value=application.is_election_board|yesno:"Yes,No,Incomplete" %} + {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url %} + {% endwith %} + {% endif %} + + {% if step == Step.ORGANIZATION_CONTACT %} + {% namespaced_url 'application' step as application_url %} + {% if application.organization_name %} + {% with title=form_titles|get_item:step value=application %} + {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url address='true' %} + {% endwith %} + {% else %} + {% with title=form_titles|get_item:step value='Incomplete' %} + {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url %} + {% endwith %} {% endif %} - {% if step == Step.ORGANIZATION_FEDERAL %} - {{ application.get_federal_type_display|default:"Incomplete" }} + {% endif %} + + {% if step == Step.ABOUT_YOUR_ORGANIZATION %} + {% namespaced_url 'application' step as application_url %} + {% with title=form_titles|get_item:step value=application.about_your_organization|default:"Incomplete" %} + {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url %} + {% endwith %} + {% endif %} + + {% if step == Step.AUTHORIZING_OFFICIAL %} + {% namespaced_url 'application' step as application_url %} + {% if application.authorizing_official is not None %} + {% with title=form_titles|get_item:step value=application.authorizing_official %} + {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url contact='true' %} + {% endwith %} + {% else %} + {% with title=form_titles|get_item:step value="Incomplete" %} + {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url %} + {% endwith %} {% endif %} - {% if step == Step.ORGANIZATION_ELECTION %} - {{ application.is_election_board|yesno:"Yes,No,Incomplete" }} + {% endif %} + + {% if step == Step.CURRENT_SITES %} + {% namespaced_url 'application' step as application_url %} + {% if application.current_websites.all %} + {% with title=form_titles|get_item:step value=application.current_websites.all %} + {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url list='true' %} + {% endwith %} + {% else %} + {% with title=form_titles|get_item:step value='None' %} + {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=application_url %} + {% endwith %} {% endif %} - {% if step == Step.ORGANIZATION_CONTACT %} - {% if application.organization_name %} - {% include "includes/organization_address.html" with organization=application %} - {% else %} - Incomplete - {% endif %} - {% endif %} - {% if step == Step.ABOUT_YOUR_ORGANIZATION %} -{{ application.about_your_organization|default:"Incomplete" }}
- {% endif %} - {% if step == Step.AUTHORIZING_OFFICIAL %} - {% if application.authorizing_official %} -Contact {{ forloop.counter }}
- {% include "includes/contact.html" with contact=other %} -No other employees from your organization?
- {{ application.no_other_contacts_rationale|default:"Incomplete" }} -