Merge pull request #2536 from cisagov/za/portfolio-user-can-view-and-update-suborgs

Ticket #2352: Edit suborganization on portfolio domains
This commit is contained in:
zandercymatics 2024-08-12 11:15:25 -06:00 committed by GitHub
commit df4a02f8ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 465 additions and 10 deletions

View file

@ -1985,3 +1985,122 @@ document.addEventListener('DOMContentLoaded', function() {
showInputOnErrorFields();
})();
/**
* An IIFE that changes the default clear behavior on comboboxes to the input field.
* We want the search bar to act soley as a search bar.
*/
(function loadInitialValuesForComboBoxes() {
var overrideDefaultClearButton = true;
var isTyping = false;
document.addEventListener('DOMContentLoaded', (event) => {
handleAllComboBoxElements();
});
function handleAllComboBoxElements() {
const comboBoxElements = document.querySelectorAll(".usa-combo-box");
comboBoxElements.forEach(comboBox => {
const input = comboBox.querySelector("input");
const select = comboBox.querySelector("select");
if (!input || !select) {
console.warn("No combobox element found");
return;
}
// Set the initial value of the combobox
let initialValue = select.getAttribute("data-default-value");
let clearInputButton = comboBox.querySelector(".usa-combo-box__clear-input");
if (!clearInputButton) {
console.warn("No clear element found");
return;
}
// Override the default clear button behavior such that it no longer clears the input,
// it just resets to the data-initial-value.
// Due to the nature of how uswds works, this is slightly hacky.
// Use a MutationObserver to watch for changes in the dropdown list
const dropdownList = document.querySelector(`#${input.id}--list`);
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === "childList") {
addBlankOption(clearInputButton, dropdownList, initialValue);
}
});
});
// Configure the observer to watch for changes in the dropdown list
const config = { childList: true, subtree: true };
observer.observe(dropdownList, config);
// Input event listener to detect typing
input.addEventListener("input", () => {
isTyping = true;
});
// Blur event listener to reset typing state
input.addEventListener("blur", () => {
isTyping = false;
});
// Hide the reset button when there is nothing to reset.
// Do this once on init, then everytime a change occurs.
updateClearButtonVisibility(select, initialValue, clearInputButton)
select.addEventListener("change", () => {
updateClearButtonVisibility(select, initialValue, clearInputButton)
});
// Change the default input behaviour - have it reset to the data default instead
clearInputButton.addEventListener("click", (e) => {
if (overrideDefaultClearButton && initialValue) {
e.preventDefault();
e.stopPropagation();
input.click();
// Find the dropdown option with the desired value
const dropdownOptions = document.querySelectorAll(".usa-combo-box__list-option");
if (dropdownOptions) {
dropdownOptions.forEach(option => {
if (option.getAttribute("data-value") === initialValue) {
// Simulate a click event on the dropdown option
option.click();
}
});
}
}
});
});
}
function updateClearButtonVisibility(select, initialValue, clearInputButton) {
if (select.value === initialValue) {
hideElement(clearInputButton);
}else {
showElement(clearInputButton)
}
}
function addBlankOption(clearInputButton, dropdownList, initialValue) {
if (dropdownList && !dropdownList.querySelector('[data-value=""]') && !isTyping) {
const blankOption = document.createElement("li");
blankOption.setAttribute("role", "option");
blankOption.setAttribute("data-value", "");
blankOption.classList.add("usa-combo-box__list-option");
if (!initialValue){
blankOption.classList.add("usa-combo-box__list-option--selected")
}
blankOption.textContent = "---------";
dropdownList.insertBefore(blankOption, dropdownList.firstChild);
blankOption.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
overrideDefaultClearButton = false;
// Trigger the default clear behavior
clearInputButton.click();
overrideDefaultClearButton = true;
});
}
}
})();

View file

@ -192,6 +192,11 @@ urlpatterns = [
views.DomainOrgNameAddressView.as_view(),
name="domain-org-name-address",
),
path(
"domain/<int:pk>/suborganization",
views.DomainSubOrganizationView.as_view(),
name="domain-suborganization",
),
path(
"domain/<int:pk>/senior-official",
views.DomainSeniorOfficialView.as_view(),

View file

@ -9,6 +9,7 @@ from .domain import (
DomainDnssecForm,
DomainDsdataFormset,
DomainDsdataForm,
DomainSuborganizationForm,
)
from .portfolio import (
PortfolioOrgAddressForm,

View file

@ -6,6 +6,7 @@ from django.core.validators import MinValueValidator, MaxValueValidator, RegexVa
from django.forms import formset_factory
from registrar.models import DomainRequest
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from registrar.models.suborganization import Suborganization
from registrar.models.utility.domain_helper import DomainHelper
from registrar.utility.errors import (
NameserverError,
@ -153,6 +154,42 @@ class DomainNameserverForm(forms.Form):
self.add_error("ip", str(e))
class DomainSuborganizationForm(forms.ModelForm):
"""Form for updating the suborganization"""
sub_organization = forms.ModelChoiceField(
queryset=Suborganization.objects.none(),
required=False,
widget=forms.Select(),
)
class Meta:
model = DomainInformation
fields = [
"sub_organization",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
portfolio = self.instance.portfolio if self.instance else None
self.fields["sub_organization"].queryset = Suborganization.objects.filter(portfolio=portfolio)
# Set initial value
if self.instance and self.instance.sub_organization:
self.fields["sub_organization"].initial = self.instance.sub_organization
# Set custom form label
self.fields["sub_organization"].label = "Suborganization name"
# Use the combobox rather than the regular select widget
self.fields["sub_organization"].widget.template_name = "django/forms/widgets/combobox.html"
# Set data-default-value attribute
if self.instance and self.instance.sub_organization:
self.fields["sub_organization"].widget.attrs["data-default-value"] = self.instance.sub_organization.pk
class BaseNameserverFormset(forms.BaseFormSet):
def clean(self):
"""

View file

@ -0,0 +1,66 @@
# Generated by Django 4.2.10 on 2024-08-08 14:14
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0116_federalagency_initials_federalagency_is_fceb_and_more"),
]
operations = [
migrations.AlterField(
model_name="portfolioinvitation",
name="portfolio_additional_permissions",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("view_all_domains", "View all domains and domain reports"),
("view_managed_domains", "View managed domains"),
("view_member", "View members"),
("edit_member", "Create and edit members"),
("view_all_requests", "View all requests"),
("view_created_requests", "View created requests"),
("edit_requests", "Create and edit requests"),
("view_portfolio", "View organization"),
("edit_portfolio", "Edit organization"),
("view_suborganization", "View suborganization"),
("edit_suborganization", "Edit suborganization"),
],
max_length=50,
),
blank=True,
help_text="Select one or more additional permissions.",
null=True,
size=None,
),
),
migrations.AlterField(
model_name="user",
name="portfolio_additional_permissions",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("view_all_domains", "View all domains and domain reports"),
("view_managed_domains", "View managed domains"),
("view_member", "View members"),
("edit_member", "Create and edit members"),
("view_all_requests", "View all requests"),
("view_created_requests", "View created requests"),
("edit_requests", "Create and edit requests"),
("view_portfolio", "View organization"),
("edit_portfolio", "Edit organization"),
("view_suborganization", "View suborganization"),
("edit_suborganization", "Edit suborganization"),
],
max_length=50,
),
blank=True,
help_text="Select one or more additional permissions.",
null=True,
size=None,
),
),
]

View file

@ -74,12 +74,17 @@ class User(AbstractUser):
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
# Domain: field specific permissions
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION,
],
UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
UserPortfolioPermissionChoices.VIEW_MEMBER,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
# Domain: field specific permissions
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
],
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
@ -270,6 +275,13 @@ class User(AbstractUser):
"""Determines if the current user can view all available domains in a given portfolio"""
return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
# Field specific permission checks
def has_view_suborganization(self):
return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION)
def has_edit_suborganization(self):
return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION)
@classmethod
def needs_identity_verification(cls, email, uuid):
"""A method used by our oidc classes to test whether a user needs email/uuid verification

View file

@ -26,3 +26,7 @@ class UserPortfolioPermissionChoices(models.TextChoices):
VIEW_PORTFOLIO = "view_portfolio", "View organization"
EDIT_PORTFOLIO = "edit_portfolio", "Edit organization"
# Domain: field specific permissions
VIEW_SUBORGANIZATION = "view_suborganization", "View suborganization"
EDIT_SUBORGANIZATION = "edit_suborganization", "Edit suborganization"

View file

@ -0,0 +1,14 @@
{% comment %}
This is a custom widget for USWDS's comboboxes.
USWDS comboboxes are basically just selects with a "usa-combo-box" div wrapper.
We can further customize these by applying attributes to this parent element,
for now we just carry the attribute to both the parent element and the select.
{% endcomment %}
<div class="usa-combo-box"
{% for name, value in widget.attrs.items %}
{{ name }}="{{ value }}"
{% endfor %}
>
{% include "django/forms/widgets/select.html" %}
</div>

View file

@ -1,5 +1,6 @@
{% extends "domain_base.html" %}
{% load static url_helpers %}
{% load custom_filters %}
{% block domain_content %}
{{ block.super }}
@ -64,11 +65,9 @@
{% endif %}
{% endif %}
{% if portfolio %}
{% comment %} TODO - uncomment in #2352 and add to edit_link
{% if portfolio and has_domains_portfolio_permission and request.user.has_view_suborganization %}
{% url 'domain-suborganization' pk=domain.id as url %}
{% endcomment %}
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link="#" editable=is_editable %}
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:request.user.has_edit_suborganization %}
{% else %}
{% url 'domain-org-name-address' pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Organization name and mailing address' value=domain.domain_info address='true' edit_link=url editable=is_editable %}

View file

@ -60,9 +60,12 @@
{% if portfolio %}
{% with url="#" %}
{% include "includes/domain_sidenav_item.html" with item_text="Suborganization" %}
{% endwith %}
{% comment %} Only show this menu option if the user has the perms to do so {% endcomment %}
{% if has_domains_portfolio_permission and request.user.has_view_suborganization %}
{% with url_name="domain-suborganization" %}
{% include "includes/domain_sidenav_item.html" with item_text="Suborganization" %}
{% endwith %}
{% endif %}
{% else %}
{% with url_name="domain-senior-official" %}
{% include "includes/domain_sidenav_item.html" with item_text="Senior official" %}

View file

@ -0,0 +1,29 @@
{% extends "domain_base.html" %}
{% load static field_helpers%}
{% block title %}Suborganization{% endblock %}
{% block domain_content %}
{# this is right after the messages block in the parent template #}
{% include "includes/form_errors.html" with form=form %}
<h1>Suborganization</h1>
<p>
The name of your suborganization will be publicly listed as the domain registrant.
This list of suborganizations has been populated the .gov program.
If you believe there is an error please contact <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p>
{% if has_domains_portfolio_permission and request.user.has_edit_suborganization %}
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
{% csrf_token %}
{% input_with_errors form.sub_organization %}
<button type="submit" class="usa-button">Save</button>
</form>
{% else %}
{% with description="The suborganization for this domain can only be updated by a organization administrator."%}
{% include "includes/input_read_only.html" with field=form.sub_organization value=suborganization_name label_description=description%}
{% endwith %}
{% endif %}
{% endblock %}

View file

@ -152,7 +152,7 @@
<th data-sortable="name" scope="col" role="columnheader">Domain name</th>
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
<th data-sortable="state_display" scope="col" role="columnheader">Status</th>
{% if has_domains_portfolio_permission %}
{% if has_domains_portfolio_permission and request.user.has_view_suborganization %}
<th data-sortable="suborganization" scope="col" role="columnheader">Suborganization</th>
{% endif %}
<th

View file

@ -4,4 +4,11 @@ Template include for read-only form fields
<h4 class="read-only-label">{{ field.label }}</h4>
<p class="read-only-value">{{ field.value }}</p>
{% if label_description %}
<p class="usa-hint margin-top-0 margin-bottom-05">{{ label_description }}</p>
{% endif %}
{% comment %}
This allows us to customize the displayed value.
For instance, Select fields will display the id by default.
{% endcomment %}
<p class="read-only-value">{{ value|default:field.value }}</p>

View file

@ -150,3 +150,12 @@ def format_phone(value):
@register.filter
def in_path(url, path):
return url in path
@register.filter(name="and")
def and_filter(value, arg):
"""
Implements logical AND operation in templates.
Usage: {{ value|and:arg }}
"""
return bool(value and arg)

View file

@ -1526,6 +1526,110 @@ class TestDomainOrganization(TestDomainOverview):
class TestDomainSuborganization(TestDomainOverview):
"""Tests the Suborganization page for portfolio users"""
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
def test_edit_suborganization_field(self):
"""Ensure that org admins can edit the suborganization field"""
# Create a portfolio and two suborgs
portfolio = Portfolio.objects.create(creator=self.user, organization_name="Ice Cream")
suborg = Suborganization.objects.create(portfolio=portfolio, name="Vanilla")
suborg_2 = Suborganization.objects.create(portfolio=portfolio, name="Chocolate")
# Create an unrelated portfolio
unrelated_portfolio = Portfolio.objects.create(creator=self.user, organization_name="Fruit")
unrelated_suborg = Suborganization.objects.create(portfolio=unrelated_portfolio, name="Apple")
# Add the portfolio to the domain_information object
self.domain_information.portfolio = portfolio
self.domain_information.sub_organization = suborg
# Add a organization_name to test if the old value still displays
self.domain_information.organization_name = "Broccoli"
self.domain_information.save()
self.domain_information.refresh_from_db()
# Add portfolio perms to the user object
self.user.portfolio = portfolio
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
self.user.save()
self.user.refresh_from_db()
self.assertEqual(self.domain_information.sub_organization, suborg)
# Navigate to the suborganization page
page = self.app.get(reverse("domain-suborganization", kwargs={"pk": self.domain.id}))
# The page should contain the choices Vanilla and Chocolate
self.assertContains(page, "Vanilla")
self.assertContains(page, "Chocolate")
self.assertNotContains(page, unrelated_suborg.name)
# Assert that the right option is selected. This component uses data-default-value.
self.assertContains(page, f'data-default-value="{suborg.id}"')
# Try changing the suborg
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
page.form["sub_organization"] = suborg_2.id
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
page = page.form.submit().follow()
# The page should contain the choices Vanilla and Chocolate
self.assertContains(page, "Vanilla")
self.assertContains(page, "Chocolate")
self.assertNotContains(page, unrelated_suborg.name)
# Assert that the right option is selected
self.assertContains(page, f'data-default-value="{suborg_2.id}"')
self.domain_information.refresh_from_db()
self.assertEqual(self.domain_information.sub_organization, suborg_2)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
def test_view_suborganization_field(self):
"""Only org admins can edit the suborg field, ensure that others cannot"""
# Create a portfolio and two suborgs
portfolio = Portfolio.objects.create(creator=self.user, organization_name="Ice Cream")
suborg = Suborganization.objects.create(portfolio=portfolio, name="Vanilla")
Suborganization.objects.create(portfolio=portfolio, name="Chocolate")
# Create an unrelated portfolio
unrelated_portfolio = Portfolio.objects.create(creator=self.user, organization_name="Fruit")
unrelated_suborg = Suborganization.objects.create(portfolio=unrelated_portfolio, name="Apple")
# Add the portfolio to the domain_information object
self.domain_information.portfolio = portfolio
self.domain_information.sub_organization = suborg
# Add a organization_name to test if the old value still displays
self.domain_information.organization_name = "Broccoli"
self.domain_information.save()
self.domain_information.refresh_from_db()
# Add portfolio perms to the user object
self.user.portfolio = portfolio
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY]
self.user.save()
self.user.refresh_from_db()
self.assertEqual(self.domain_information.sub_organization, suborg)
# Navigate to the suborganization page
page = self.app.get(reverse("domain-suborganization", kwargs={"pk": self.domain.id}))
# The page should display the readonly option
self.assertContains(page, "Vanilla")
# The page shouldn't contain these choices
self.assertNotContains(page, "Chocolate")
self.assertNotContains(page, unrelated_suborg.name)
self.assertNotContains(page, "Save")
self.assertContains(
page, "The suborganization for this domain can only be updated by a organization administrator."
)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
def test_has_suborganization_field_on_overview_with_flag(self):

View file

@ -3,6 +3,7 @@ from .domain import (
DomainView,
DomainSeniorOfficialView,
DomainOrgNameAddressView,
DomainSubOrganizationView,
DomainDNSView,
DomainNameserversView,
DomainDNSSECView,

View file

@ -15,7 +15,7 @@ from django.shortcuts import redirect
from django.urls import reverse
from django.views.generic.edit import FormMixin
from django.conf import settings
from registrar.forms.domain import DomainSuborganizationForm
from registrar.models import (
Domain,
DomainRequest,
@ -242,6 +242,51 @@ class DomainOrgNameAddressView(DomainFormBaseView):
return super().has_permission()
class DomainSubOrganizationView(DomainFormBaseView):
"""Suborganization view"""
model = Domain
template_name = "domain_suborganization.html"
context_object_name = "domain"
form_class = DomainSuborganizationForm
def has_permission(self):
"""Override for the has_permission class to exclude non-portfolio users"""
# non-org users shouldn't have access to this page
is_org_user = self.request.user.is_org_user(self.request)
if self.request.user.portfolio and is_org_user:
return super().has_permission()
else:
return False
def get_context_data(self, **kwargs):
"""Adds custom context."""
context = super().get_context_data(**kwargs)
if self.object and self.object.domain_info and self.object.domain_info.sub_organization:
context["suborganization_name"] = self.object.domain_info.sub_organization.name
return context
def get_form_kwargs(self, *args, **kwargs):
"""Add domain_info.organization_name instance to make a bound form."""
form_kwargs = super().get_form_kwargs(*args, **kwargs)
form_kwargs["instance"] = self.object.domain_info
return form_kwargs
def get_success_url(self):
"""Redirect to the overview page for the domain."""
return reverse("domain-suborganization", kwargs={"pk": self.object.pk})
def form_valid(self, form):
"""The form is valid, save the organization name and mailing address."""
form.save()
messages.success(self.request, "The suborganization name for this domain has been updated.")
# superclass has the redirect
return super().form_valid(form)
class DomainSeniorOfficialView(DomainFormBaseView):
"""Domain senior official editing view."""