Merge remote-tracking branch 'origin/main' into rjm/811-approved-rejected-approved

This commit is contained in:
Rachid Mrad 2023-09-07 13:56:21 -04:00
commit d859a8be03
No known key found for this signature in database
GPG key ID: EF38E4CEC4A8F3CF
13 changed files with 364 additions and 29 deletions

View file

@ -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."""

View file

@ -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();
})();

View file

@ -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;
}
}
}

View file

@ -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",
},

View file

@ -60,12 +60,11 @@ Load our custom filters to extract info from the django generated markup.
<tr><td colspan="{{ result|length }}">{{ result.form.non_field_errors }}</td></tr>
{% endif %}
<tr>
{% with result_value=result.0|extract_value %}
{% with result_label=result.1|extract_a_text %}
<td>
<input type="checkbox" name="_selected_action" value="{{ result_value|default:'value' }}" id="{{ result_label|default:result_value }}" class="action-select">
<label class="usa-sr-only" for="{{ result_label|default:result_value }}">{{ result_label|default:'label' }}</label>
<input type="checkbox" name="_selected_action" value="{{ result_value|default:'value' }}" id="{{ result_value|default:'value' }}-{{ result_label|default:'label' }}" class="action-select">
<label class="usa-sr-only" for="{{ result_value|default:'value' }}-{{ result_label|default:'label' }}">{{ result_label|default:'label' }}</label>
</td>
{% endwith %}
{% endwith %}

View file

@ -1,7 +1,14 @@
{% extends 'admin/change_form.html' %}
{% load i18n static %}
{% block extrahead %}
{{ block.super }}
<script type="application/javascript" src="{% static 'js/get-gov-admin.js' %}" defer></script>
{% endblock %}
{% block field_sets %}
<div class="submit-row">
<input id="manageDomainSubmitButton" type="submit" value="Manage Domain" name="_edit_domain">
<input type="submit" value="Place hold" name="_place_client_hold">
</div>
{{ block.super }}

View file

@ -5,12 +5,14 @@
{% block content %}
<div class="grid-container">
<div class="grid-row">
<p class="font-body-md margin-top-0 margin-bottom-2
<div class="grid-row">
{% if not is_analyst_or_superuser or not analyst_action or analyst_action_location != domain.pk %}
<p class="font-body-md margin-top-0 margin-bottom-2
text-primary-darker text-semibold"
>
<span class="usa-sr-only"> Domain name:</span> {{ domain.name }}
<span class="usa-sr-only"> Domain name:</span> {{ domain.name }}
</p>
{% endif %}
</div>
<div class="grid-row grid-gap">
<div class="tablet:grid-col-3">
@ -20,15 +22,26 @@
<div class="tablet:grid-col-9">
<main id="main-content" class="grid-container">
<a href="{% url 'home' %}" class="breadcrumb__back">
{% if is_analyst_or_superuser and analyst_action == 'edit' and analyst_action_location == domain.pk %}
<div class="usa-alert usa-alert--warning margin-bottom-2">
<div class="usa-alert__body">
<h4 class="usa-alert__heading larger-font-sizing">Attention!</h4>
<p class="usa-alert__text ">
You are making changes to a registrants domain. When finished making changes, close this tab and inform the registrant of your updates.
</p>
</div>
</div>
{% else %}
<a href="{% url 'home' %}" class="breadcrumb__back">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
</svg>
<p class="margin-left-05 margin-top-0 margin-bottom-0 line-height-sans-1">
Back to manage your domains
Back to manage your domains
</p>
</a>
{% endif %}
{# messages block is under the back breadcrumb link #}
{% if messages %}
{% for message in messages %}

View file

@ -7,7 +7,8 @@
<h1>Domain contact information</h1>
<p>If youd like us to use a different name, email, or phone number you can make those changes below. Changing your contact information here wont affect your Login.gov account information.</p>
<p>If youd like us to use a different name, email, or phone number you can make those changes below. <strong>Updating your contact information here will update the contact information for all domains in your account.</strong> However, it wont affect your Login.gov account information.
</p>
{% include "includes/required_fields.html" %}

View file

@ -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

View file

@ -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,
)

View file

@ -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())

View file

@ -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):

View file

@ -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