diff --git a/src/registrar/admin.py b/src/registrar/admin.py index a46873f51..5ccba7cad 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -176,8 +176,9 @@ class DomainAdmin(ListHeaderAdmin): readonly_fields = ["state"] def response_change(self, request, obj): - ACTION_BUTTON = "_place_client_hold" - if ACTION_BUTTON in request.POST: + PLACE_HOLD = "_place_client_hold" + EDIT_DOMAIN = "_edit_domain" + if PLACE_HOLD in request.POST: try: obj.place_client_hold() except Exception as err: @@ -192,9 +193,30 @@ class DomainAdmin(ListHeaderAdmin): % obj.name, ) return HttpResponseRedirect(".") - + elif EDIT_DOMAIN in request.POST: + # We want to know, globally, when an edit action occurs + request.session["analyst_action"] = "edit" + # Restricts this action to this domain (pk) only + request.session["analyst_action_location"] = obj.id + return HttpResponseRedirect(reverse("domain", args=(obj.id,))) return super().response_change(request, obj) + def change_view(self, request, object_id): + # If the analyst was recently editing a domain page, + # delete any associated session values + if "analyst_action" in request.session: + del request.session["analyst_action"] + del request.session["analyst_action_location"] + return super().change_view(request, object_id) + + def has_change_permission(self, request, obj=None): + # Fixes a bug wherein users which are only is_staff + # can access 'change' when GET, + # but cannot access this page when it is a request of type POST. + if request.user.is_staff: + return True + return super().has_change_permission(request, obj) + class ContactAdmin(ListHeaderAdmin): """Custom contact admin class to add search.""" diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js new file mode 100644 index 000000000..3b9f19a49 --- /dev/null +++ b/src/registrar/assets/js/get-gov-admin.js @@ -0,0 +1,50 @@ +/** + * @file get-gov-admin.js includes custom code for the .gov registrar admin portal. + * + * Constants and helper functions are at the top. + * Event handlers are in the middle. + * Initialization (run-on-load) stuff goes at the bottom. + */ + +// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> +// Helper functions. +/** Either sets attribute target="_blank" to a given element, or removes it */ +function openInNewTab(el, removeAttribute = false){ + if(removeAttribute){ + el.setAttribute("target", "_blank"); + }else{ + el.removeAttribute("target", "_blank"); + } +}; + +// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> +// Event handlers. + +// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> +// Initialization code. + +/** An IIFE for pages in DjangoAdmin which may need custom JS implementation. + * Currently only appends target="_blank" to the domain_form object, + * but this can be expanded. +*/ +(function (){ + /* + On mouseover, appends target="_blank" on domain_form under the Domain page. + The reason for this is that the template has a form that contains multiple buttons. + The structure of that template complicates seperating those buttons + out of the form (while maintaining the same position on the page). + However, if we want to open one of those submit actions to a new tab - + such as the manage domain button - we need to dynamically append target. + As there is no built-in django method which handles this, we do it here. + */ + function prepareDjangoAdmin() { + let domainFormElement = document.getElementById("domain_form"); + let domainSubmitButton = document.getElementById("manageDomainSubmitButton"); + if(domainSubmitButton && domainFormElement){ + domainSubmitButton.addEventListener("mouseover", () => openInNewTab(domainFormElement, true)); + domainSubmitButton.addEventListener("mouseout", () => openInNewTab(domainFormElement, false)); + } + } + + prepareDjangoAdmin(); +})(); \ No newline at end of file diff --git a/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss b/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss index 905ab872c..4878235a9 100644 --- a/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss +++ b/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss @@ -432,3 +432,21 @@ abbr[title] { height: units('mobile'); } } + +// Fixes some font size disparities with the Figma +// for usa-alert alert elements +.usa-alert { + .usa-alert__heading.larger-font-sizing { + font-size: units(3); + } +} + +// The icon was off center for some reason +// Fixes that issue +@media (min-width: 64em){ + .usa-alert--warning{ + .usa-alert__body::before { + left: 1rem !important; + } + } +} diff --git a/src/registrar/fixtures.py b/src/registrar/fixtures.py index 3fea25033..76b01abf7 100644 --- a/src/registrar/fixtures.py +++ b/src/registrar/fixtures.py @@ -63,7 +63,7 @@ class UserFixture: "last_name": "Adkinson", }, { - "username": "bb21f687-c773-4df3-9243-111cfd4c0be4", + "username": "2bf518c2-485a-4c42-ab1a-f5a8b0a08484", "first_name": "Paul", "last_name": "Kuykendall", }, diff --git a/src/registrar/templates/admin/change_list_results.html b/src/registrar/templates/admin/change_list_results.html index 831350888..4ced73eb3 100644 --- a/src/registrar/templates/admin/change_list_results.html +++ b/src/registrar/templates/admin/change_list_results.html @@ -60,12 +60,11 @@ Load our custom filters to extract info from the django generated markup. {{ result.form.non_field_errors }} {% endif %} - {% with result_value=result.0|extract_value %} {% with result_label=result.1|extract_a_text %} - - + + {% endwith %} {% endwith %} diff --git a/src/registrar/templates/django/admin/domain_change_form.html b/src/registrar/templates/django/admin/domain_change_form.html index 5fa89f20a..09d724881 100644 --- a/src/registrar/templates/django/admin/domain_change_form.html +++ b/src/registrar/templates/django/admin/domain_change_form.html @@ -1,7 +1,14 @@ {% extends 'admin/change_form.html' %} +{% load i18n static %} + +{% block extrahead %} +{{ block.super }} + +{% endblock %} {% block field_sets %}
+
{{ block.super }} diff --git a/src/registrar/templates/domain_base.html b/src/registrar/templates/domain_base.html index ac1f8c7a9..d2870a82c 100644 --- a/src/registrar/templates/domain_base.html +++ b/src/registrar/templates/domain_base.html @@ -5,12 +5,14 @@ {% block content %}
-
-

+ {% if not is_analyst_or_superuser or not analyst_action or analyst_action_location != domain.pk %} +

- Domain name: {{ domain.name }} + Domain name: {{ domain.name }}

+ {% endif %}
@@ -20,15 +22,26 @@
- + {% if is_analyst_or_superuser and analyst_action == 'edit' and analyst_action_location == domain.pk %} +
+
+

Attention!

+

+ You are making changes to a registrant’s domain. When finished making changes, close this tab and inform the registrant of your updates. +

+
+
+ {% else %} +
+

- Back to manage your domains + Back to manage your domains

- + {% endif %} {# messages block is under the back breadcrumb link #} {% if messages %} {% for message in messages %} diff --git a/src/registrar/templates/domain_your_contact_information.html b/src/registrar/templates/domain_your_contact_information.html index e18deeb50..81c62584c 100644 --- a/src/registrar/templates/domain_your_contact_information.html +++ b/src/registrar/templates/domain_your_contact_information.html @@ -7,7 +7,8 @@

Domain contact information

-

If you’d like us to use a different name, email, or phone number you can make those changes below. Changing your contact information here won’t affect your Login.gov account information.

+

If you’d like us to use a different name, email, or phone number you can make those changes below. Updating your contact information here will update the contact information for all domains in your account. However, it won’t affect your Login.gov account information. +

{% include "includes/required_fields.html" %} diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index c4a2772b0..940183646 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -518,3 +518,11 @@ def multiple_unalphabetical_domain_objects( application = mock.create_full_dummy_domain_object(domain_type, object_name) applications.append(application) return applications + + +def generic_domain_object(domain_type, object_name): + """Returns a generic domain object of + domain_type 'application', 'information', or 'invitation'""" + mock = AuditedAdminMockData() + application = mock.create_full_dummy_domain_object(domain_type, object_name) + return application diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 2b347287d..2bf12b48c 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -2,8 +2,10 @@ from django.test import TestCase, RequestFactory, Client from django.contrib.admin.sites import AdminSite from contextlib import ExitStack from django.contrib import messages +from django.urls import reverse from registrar.admin import ( + DomainAdmin, DomainApplicationAdmin, ListHeaderAdmin, MyUserAdmin, @@ -15,15 +17,17 @@ from registrar.models import ( Domain, User, DomainInvitation, + Domain, ) from .common import ( completed_application, + generic_domain_object, mock_user, create_superuser, create_user, multiple_unalphabetical_domain_objects, ) - +from django.contrib.sessions.backends.db import SessionStore from django.contrib.auth import get_user_model from unittest.mock import patch @@ -928,3 +932,129 @@ class AuditedAdminTest(TestCase): DomainInformation.objects.all().delete() DomainApplication.objects.all().delete() DomainInvitation.objects.all().delete() + + +class DomainSessionVariableTest(TestCase): + """Test cases for session variables in Django Admin""" + + def setUp(self): + self.factory = RequestFactory() + self.admin = DomainAdmin(Domain, None) + self.client = Client(HTTP_HOST="localhost:8080") + + def test_session_vars_set_correctly(self): + """Checks if session variables are being set correctly""" + + p = "adminpass" + self.client.login(username="superuser", password=p) + + dummy_domain_information = generic_domain_object("information", "session") + request = self.get_factory_post_edit_domain(dummy_domain_information.domain.pk) + self.populate_session_values(request, dummy_domain_information.domain) + self.assertEqual(request.session["analyst_action"], "edit") + self.assertEqual( + request.session["analyst_action_location"], + dummy_domain_information.domain.pk, + ) + + def test_session_vars_set_correctly_hardcoded_domain(self): + """Checks if session variables are being set correctly""" + + p = "adminpass" + self.client.login(username="superuser", password=p) + + dummy_domain_information: Domain = generic_domain_object( + "information", "session" + ) + dummy_domain_information.domain.pk = 1 + + request = self.get_factory_post_edit_domain(dummy_domain_information.domain.pk) + self.populate_session_values(request, dummy_domain_information.domain) + self.assertEqual(request.session["analyst_action"], "edit") + self.assertEqual(request.session["analyst_action_location"], 1) + + def test_session_variables_reset_correctly(self): + """Checks if incorrect session variables get overridden""" + + p = "adminpass" + self.client.login(username="superuser", password=p) + + dummy_domain_information = generic_domain_object("information", "session") + request = self.get_factory_post_edit_domain(dummy_domain_information.domain.pk) + + self.populate_session_values( + request, dummy_domain_information.domain, preload_bad_data=True + ) + + self.assertEqual(request.session["analyst_action"], "edit") + self.assertEqual( + request.session["analyst_action_location"], + dummy_domain_information.domain.pk, + ) + + def test_session_variables_retain_information(self): + """Checks to see if session variables retain old information""" + + p = "adminpass" + self.client.login(username="superuser", password=p) + + dummy_domain_information_list = multiple_unalphabetical_domain_objects( + "information" + ) + for item in dummy_domain_information_list: + request = self.get_factory_post_edit_domain(item.domain.pk) + self.populate_session_values(request, item.domain) + + self.assertEqual(request.session["analyst_action"], "edit") + self.assertEqual(request.session["analyst_action_location"], item.domain.pk) + + def test_session_variables_concurrent_requests(self): + """Simulates two requests at once""" + + p = "adminpass" + self.client.login(username="superuser", password=p) + + info_first = generic_domain_object("information", "session") + info_second = generic_domain_object("information", "session2") + + request_first = self.get_factory_post_edit_domain(info_first.domain.pk) + request_second = self.get_factory_post_edit_domain(info_second.domain.pk) + + self.populate_session_values(request_first, info_first.domain, True) + self.populate_session_values(request_second, info_second.domain, True) + + # Check if anything got nulled out + self.assertNotEqual(request_first.session["analyst_action"], None) + self.assertNotEqual(request_second.session["analyst_action"], None) + self.assertNotEqual(request_first.session["analyst_action_location"], None) + self.assertNotEqual(request_second.session["analyst_action_location"], None) + + # Check if they are both the same action 'type' + self.assertEqual(request_first.session["analyst_action"], "edit") + self.assertEqual(request_second.session["analyst_action"], "edit") + + # Check their locations, and ensure they aren't the same across both + self.assertNotEqual( + request_first.session["analyst_action_location"], + request_second.session["analyst_action_location"], + ) + + def populate_session_values(self, request, domain_object, preload_bad_data=False): + """Boilerplate for creating mock sessions""" + request.user = self.client + request.session = SessionStore() + request.session.create() + if preload_bad_data: + request.session["analyst_action"] = "invalid" + request.session["analyst_action_location"] = "bad location" + self.admin.response_change(request, domain_object) + + def get_factory_post_edit_domain(self, primary_key): + """Posts to registrar domain change + with the edit domain button 'clicked', + then returns the factory object""" + return self.factory.post( + reverse("admin:registrar_domain_change", args=(primary_key,)), + {"_edit_domain": "true"}, + follow=True, + ) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 6a33ec994..f945bc443 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -79,6 +79,7 @@ class DomainOrgNameAddressView(DomainPermissionView, FormMixin): messages.success( self.request, "The organization name and mailing address has been updated." ) + # superclass has the redirect return super().form_valid(form) @@ -121,6 +122,7 @@ class DomainAuthorizingOfficialView(DomainPermissionView, FormMixin): messages.success( self.request, "The authorizing official for this domain has been updated." ) + # superclass has the redirect return super().form_valid(form) @@ -187,6 +189,7 @@ class DomainNameserversView(DomainPermissionView, FormMixin): messages.success( self.request, "The name servers for this domain have been updated." ) + # superclass has the redirect return super().form_valid(formset) @@ -227,6 +230,7 @@ class DomainYourContactInformationView(DomainPermissionView, FormMixin): messages.success( self.request, "Your contact information for this domain has been updated." ) + # superclass has the redirect return super().form_valid(form) @@ -272,6 +276,7 @@ class DomainSecurityEmailView(DomainPermissionView, FormMixin): messages.success( self.request, "The security email for this domain have been updated." ) + # superclass has the redirect return redirect(self.get_success_url()) @@ -347,6 +352,7 @@ class DomainAddUserView(DomainPermissionView, FormMixin): messages.success( self.request, f"Invited {email_address} to this domain." ) + return redirect(self.get_success_url()) def form_valid(self, form): @@ -368,6 +374,7 @@ class DomainAddUserView(DomainPermissionView, FormMixin): pass messages.success(self.request, f"Added user {requested_email}.") + return redirect(self.get_success_url()) diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index 363709a21..fd58b3475 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -2,7 +2,16 @@ from django.contrib.auth.mixins import PermissionRequiredMixin -from registrar.models import UserDomainRole, DomainApplication, DomainInvitation +from registrar.models import ( + DomainApplication, + DomainInvitation, + DomainInformation, + UserDomainRole, +) +import logging + + +logger = logging.getLogger(__name__) class PermissionsLoginMixin(PermissionRequiredMixin): @@ -25,27 +34,80 @@ class DomainPermission(PermissionsLoginMixin): up from the domain's primary key in self.kwargs["pk"] """ - # ticket 806 - # if self.request.user is staff or admin and - # domain.application__status = 'approved' or 'rejected' or 'action needed' - # return True - if not self.request.user.is_authenticated: return False - # user needs to have a role on the domain - if not UserDomainRole.objects.filter( - user=self.request.user, domain__id=self.kwargs["pk"] - ).exists(): - return False - - # The user has an ineligible flag if self.request.user.is_restricted(): return False + pk = self.kwargs["pk"] + # If pk is none then something went very wrong... + if pk is None: + raise ValueError("Primary key is None") + + if self.can_access_other_user_domains(pk): + return True + + # user needs to have a role on the domain + if not UserDomainRole.objects.filter( + user=self.request.user, domain__id=pk + ).exists(): + return False + # if we need to check more about the nature of role, do it here. return True + def can_access_other_user_domains(self, pk): + """Checks to see if an authorized user (staff or superuser) + can access a domain that they did not create or was invited to. + """ + + # Check if the user is permissioned... + user_is_analyst_or_superuser = ( + self.request.user.is_staff or self.request.user.is_superuser + ) + + if not user_is_analyst_or_superuser: + return False + + # Check if the user is attempting a valid edit action. + # In other words, if the analyst/admin did not click + # the 'Manage Domain' button in /admin, + # then they cannot access this page. + session = self.request.session + can_do_action = ( + "analyst_action" in session + and "analyst_action_location" in session + and session["analyst_action_location"] == pk + ) + + if not can_do_action: + return False + + # Analysts may manage domains, when they are in these statuses: + valid_domain_statuses = [ + DomainApplication.APPROVED, + DomainApplication.IN_REVIEW, + DomainApplication.REJECTED, + DomainApplication.ACTION_NEEDED, + # Edge case - some domains do not have + # a status or DomainInformation... aka a status of 'None'. + # It is necessary to access those to correct errors. + None, + ] + + requested_domain = None + if DomainInformation.objects.filter(id=pk).exists(): + requested_domain = DomainInformation.objects.get(id=pk) + + if requested_domain.domain_application.status not in valid_domain_statuses: + return False + + # Valid session keys exist, + # the user is permissioned, + # and it is in a valid status + return True + class DomainApplicationPermission(PermissionsLoginMixin): diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index 0ef4ff4e5..417ee8417 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -3,7 +3,6 @@ import abc # abstract base class from django.views.generic import DetailView, DeleteView, TemplateView - from registrar.models import Domain, DomainApplication, DomainInvitation from .mixins import ( @@ -12,6 +11,9 @@ from .mixins import ( DomainInvitationPermission, ApplicationWizardPermission, ) +import logging + +logger = logging.getLogger(__name__) class DomainPermissionView(DomainPermission, DetailView, abc.ABC): @@ -27,6 +29,22 @@ class DomainPermissionView(DomainPermission, DetailView, abc.ABC): # variable name in template context for the model object context_object_name = "domain" + # Adds context information for user permissions + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + user = self.request.user + context["is_analyst_or_superuser"] = user.is_staff or user.is_superuser + # Stored in a variable for the linter + action = "analyst_action" + action_location = "analyst_action_location" + # Flag to see if an analyst is attempting to make edits + if action in self.request.session: + context[action] = self.request.session[action] + if action_location in self.request.session: + context[action_location] = self.request.session[action_location] + + return context + # Abstract property enforces NotImplementedError on an attribute. @property @abc.abstractmethod