Merge branch 'main' of https://github.com/cisagov/manage.get.gov into es/2913-domain-request-screenreader

This commit is contained in:
Erin Song 2024-12-20 15:47:39 -08:00
commit 2c04e02c5b
No known key found for this signature in database
15 changed files with 698 additions and 93 deletions

View file

@ -10,8 +10,7 @@ import { initDomainRequestsTable } from './table-domain-requests.js';
import { initMembersTable } from './table-members.js'; import { initMembersTable } from './table-members.js';
import { initMemberDomainsTable } from './table-member-domains.js'; import { initMemberDomainsTable } from './table-member-domains.js';
import { initEditMemberDomainsTable } from './table-edit-member-domains.js'; import { initEditMemberDomainsTable } from './table-edit-member-domains.js';
import { initPortfolioMemberPageToggle } from './portfolio-member-page.js'; import { initPortfolioNewMemberPageToggle, initAddNewMemberPageListeners, initPortfolioMemberPageRadio } from './portfolio-member-page.js';
import { initAddNewMemberPageListeners } from './portfolio-member-page.js';
initDomainValidators(); initDomainValidators();
@ -21,13 +20,6 @@ nameserversFormListener();
hookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees'); hookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees');
hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null); hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null);
hookupRadioTogglerListener(
'member_access_level',
{
'admin': 'new-member-admin-permissions',
'basic': 'new-member-basic-permissions'
}
);
hookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null); hookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null);
initializeUrbanizationToggle(); initializeUrbanizationToggle();
@ -44,5 +36,7 @@ initMembersTable();
initMemberDomainsTable(); initMemberDomainsTable();
initEditMemberDomainsTable(); initEditMemberDomainsTable();
initPortfolioMemberPageToggle(); // Init the portfolio new member page
initPortfolioMemberPageRadio();
initPortfolioNewMemberPageToggle();
initAddNewMemberPageListeners(); initAddNewMemberPageListeners();

View file

@ -2,9 +2,10 @@ import { uswdsInitializeModals } from './helpers-uswds.js';
import { getCsrfToken } from './helpers.js'; import { getCsrfToken } from './helpers.js';
import { generateKebabHTML } from './table-base.js'; import { generateKebabHTML } from './table-base.js';
import { MembersTable } from './table-members.js'; import { MembersTable } from './table-members.js';
import { hookupRadioTogglerListener } from './radios.js';
// This is specifically for the Member Profile (Manage Member) Page member/invitation removal // This is specifically for the Member Profile (Manage Member) Page member/invitation removal
export function initPortfolioMemberPageToggle() { export function initPortfolioNewMemberPageToggle() {
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const wrapperDeleteAction = document.getElementById("wrapper-delete-action") const wrapperDeleteAction = document.getElementById("wrapper-delete-action")
if (wrapperDeleteAction) { if (wrapperDeleteAction) {
@ -170,3 +171,28 @@ export function initAddNewMemberPageListeners() {
} }
} }
// Initalize the radio for the member pages
export function initPortfolioMemberPageRadio() {
document.addEventListener("DOMContentLoaded", () => {
let memberForm = document.getElementById("member_form");
let newMemberForm = document.getElementById("add_member_form")
if (memberForm) {
hookupRadioTogglerListener(
'role',
{
'organization_admin': 'member-admin-permissions',
'organization_member': 'member-basic-permissions'
}
);
}else if (newMemberForm){
hookupRadioTogglerListener(
'member_access_level',
{
'admin': 'new-member-admin-permissions',
'basic': 'new-member-basic-permissions'
}
);
}
});
}

View file

@ -38,14 +38,14 @@ export function hookupYesNoListener(radioButtonName, elementIdToShowIfYes, eleme
**/ **/
export function hookupRadioTogglerListener(radioButtonName, valueToElementMap) { export function hookupRadioTogglerListener(radioButtonName, valueToElementMap) {
// Get the radio buttons // Get the radio buttons
let radioButtons = document.querySelectorAll('input[name="'+radioButtonName+'"]'); let radioButtons = document.querySelectorAll(`input[name="${radioButtonName}"]`);
// Extract the list of all element IDs from the valueToElementMap // Extract the list of all element IDs from the valueToElementMap
let allElementIds = Object.values(valueToElementMap); let allElementIds = Object.values(valueToElementMap);
function handleRadioButtonChange() { function handleRadioButtonChange() {
// Find the checked radio button // Find the checked radio button
let radioButtonChecked = document.querySelector('input[name="'+radioButtonName+'"]:checked'); let radioButtonChecked = document.querySelector(`input[name="${radioButtonName}"]:checked`);
let selectedValue = radioButtonChecked ? radioButtonChecked.value : null; let selectedValue = radioButtonChecked ? radioButtonChecked.value : null;
// Hide all elements by default // Hide all elements by default
@ -65,7 +65,7 @@ export function hookupRadioTogglerListener(radioButtonName, valueToElementMap) {
} }
} }
if (radioButtons.length) { if (radioButtons && radioButtons.length) {
// Add event listener to each radio button // Add event listener to each radio button
radioButtons.forEach(function (radioButton) { radioButtons.forEach(function (radioButton) {
radioButton.addEventListener('change', handleRadioButtonChange); radioButton.addEventListener('change', handleRadioButtonChange);

View file

@ -4,6 +4,7 @@ import logging
from django import forms from django import forms
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.core.validators import MaxLengthValidator from django.core.validators import MaxLengthValidator
from django.utils.safestring import mark_safe
from registrar.models import ( from registrar.models import (
PortfolioInvitation, PortfolioInvitation,
@ -271,3 +272,210 @@ class NewMemberForm(forms.ModelForm):
if admin_member_error in self.errors: if admin_member_error in self.errors:
del self.errors[admin_member_error] del self.errors[admin_member_error]
return cleaned_data return cleaned_data
class BasePortfolioMemberForm(forms.Form):
"""Base form for the PortfolioMemberForm and PortfolioInvitedMemberForm"""
# The label for each of these has a red "required" star. We can just embed that here for simplicity.
required_star = '<abbr class="usa-hint usa-hint--required" title="required">*</abbr>'
role = forms.ChoiceField(
choices=[
# Uses .value because the choice has a different label (on /admin)
(UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, "Admin access"),
(UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "Basic access"),
],
widget=forms.RadioSelect,
required=True,
error_messages={
"required": "Member access level is required",
},
)
domain_request_permission_admin = forms.ChoiceField(
label=mark_safe(f"Select permission {required_star}"), # nosec
choices=[
(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"),
(UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "View all requests plus create requests"),
],
widget=forms.RadioSelect,
required=False,
error_messages={
"required": "Admin domain request permission is required",
},
)
member_permission_admin = forms.ChoiceField(
label=mark_safe(f"Select permission {required_star}"), # nosec
choices=[
(UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "View all members"),
(UserPortfolioPermissionChoices.EDIT_MEMBERS.value, "View all members plus manage members"),
],
widget=forms.RadioSelect,
required=False,
error_messages={
"required": "Admin member permission is required",
},
)
domain_request_permission_member = forms.ChoiceField(
label=mark_safe(f"Select permission {required_star}"), # nosec
choices=[
(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"),
(UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "View all requests plus create requests"),
("no_access", "No access"),
],
widget=forms.RadioSelect,
required=False,
error_messages={
"required": "Basic member permission is required",
},
)
# Tracks what form elements are required for a given role choice.
# All of the fields included here have "required=False" by default as they are conditionally required.
# see def clean() for more details.
ROLE_REQUIRED_FIELDS = {
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
"domain_request_permission_admin",
"member_permission_admin",
],
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
"domain_request_permission_member",
],
}
def __init__(self, *args, instance=None, **kwargs):
"""Initialize self.instance, self.initial, and descriptions under each radio button.
Uses map_instance_to_initial to set the initial dictionary."""
super().__init__(*args, **kwargs)
if instance:
self.instance = instance
self.initial = self.map_instance_to_initial(self.instance)
# Adds a <p> description beneath each role option
self.fields["role"].descriptions = {
"organization_admin": UserPortfolioRoleChoices.get_role_description(
UserPortfolioRoleChoices.ORGANIZATION_ADMIN
),
"organization_member": UserPortfolioRoleChoices.get_role_description(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER
),
}
def save(self):
"""Saves self.instance by grabbing data from self.cleaned_data.
Uses map_cleaned_data_to_instance.
"""
self.instance = self.map_cleaned_data_to_instance(self.cleaned_data, self.instance)
self.instance.save()
return self.instance
def clean(self):
"""Validates form data based on selected role and its required fields."""
cleaned_data = super().clean()
role = cleaned_data.get("role")
# Get required fields for the selected role. Then validate all required fields for the role.
required_fields = self.ROLE_REQUIRED_FIELDS.get(role, [])
for field_name in required_fields:
# Helpful error for if this breaks
if field_name not in self.fields:
raise ValueError(f"ROLE_REQUIRED_FIELDS referenced a non-existent field: {field_name}.")
if not cleaned_data.get(field_name):
self.add_error(field_name, self.fields.get(field_name).error_messages.get("required"))
# Edgecase: Member uses a special form value for None called "no_access".
if cleaned_data.get("domain_request_permission_member") == "no_access":
cleaned_data["domain_request_permission_member"] = None
return cleaned_data
# Explanation of how map_instance_to_initial / map_cleaned_data_to_instance work:
# map_instance_to_initial => called on init to set self.initial.
# Converts the incoming object (usually PortfolioInvitation or UserPortfolioPermission)
# into a dictionary representation for the form to use automatically.
# map_cleaned_data_to_instance => called on save() to save the instance to the db.
# Takes the self.cleaned_data dict, and converts this dict back to the object.
def map_instance_to_initial(self, instance):
"""
Maps self.instance to self.initial, handling roles and permissions.
Returns form data dictionary with appropriate permission levels based on user role:
{
"role": "organization_admin" or "organization_member",
"member_permission_admin": permission level if admin,
"domain_request_permission_admin": permission level if admin,
"domain_request_permission_member": permission level if member
}
"""
# Function variables
form_data = {}
perms = UserPortfolioPermission.get_portfolio_permissions(
instance.roles, instance.additional_permissions, get_list=False
)
# Get the available options for roles, domains, and member.
roles = [
UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
]
domain_perms = [
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
]
member_perms = [
UserPortfolioPermissionChoices.EDIT_MEMBERS,
UserPortfolioPermissionChoices.VIEW_MEMBERS,
]
# Build form data based on role (which options are available).
# Get which one should be "selected" by assuming that EDIT takes precedence over view,
# and ADMIN takes precedence over MEMBER.
roles = instance.roles or []
selected_role = next((role for role in roles if role in roles), None)
form_data = {"role": selected_role}
is_admin = selected_role == UserPortfolioRoleChoices.ORGANIZATION_ADMIN
if is_admin:
selected_domain_permission = next((perm for perm in domain_perms if perm in perms), None)
selected_member_permission = next((perm for perm in member_perms if perm in perms), None)
form_data["domain_request_permission_admin"] = selected_domain_permission
form_data["member_permission_admin"] = selected_member_permission
else:
# Edgecase: Member uses a special form value for None called "no_access". This ensures a form selection.
selected_domain_permission = next((perm for perm in domain_perms if perm in perms), "no_access")
form_data["domain_request_permission_member"] = selected_domain_permission
return form_data
def map_cleaned_data_to_instance(self, cleaned_data, instance):
"""
Maps self.cleaned_data to self.instance, setting roles and permissions.
Args:
cleaned_data (dict): Cleaned data containing role and permission choices
instance: Instance to update
Returns:
instance: Updated instance
"""
role = cleaned_data.get("role")
# Handle roles
instance.roles = [role]
# Handle additional_permissions
valid_fields = self.ROLE_REQUIRED_FIELDS.get(role, [])
additional_permissions = {cleaned_data.get(field) for field in valid_fields if cleaned_data.get(field)}
# Handle EDIT permissions (should be accompanied with a view permission)
if UserPortfolioPermissionChoices.EDIT_MEMBERS in additional_permissions:
additional_permissions.add(UserPortfolioPermissionChoices.VIEW_MEMBERS)
if UserPortfolioPermissionChoices.EDIT_REQUESTS in additional_permissions:
additional_permissions.add(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)
# Only set unique permissions not already defined in the base role
role_permissions = UserPortfolioPermission.get_portfolio_permissions(instance.roles, [], get_list=False)
instance.additional_permissions = list(additional_permissions - role_permissions)
return instance

View file

@ -110,8 +110,13 @@ class UserPortfolioPermission(TimeStampedModel):
return self.get_portfolio_permissions(self.roles, self.additional_permissions) return self.get_portfolio_permissions(self.roles, self.additional_permissions)
@classmethod @classmethod
def get_portfolio_permissions(cls, roles, additional_permissions): def get_portfolio_permissions(cls, roles, additional_permissions, get_list=True):
"""Class method to return a list of permissions based on roles and addtl permissions""" """Class method to return a list of permissions based on roles and addtl permissions.
Params:
roles => An array of roles
additional_permissions => An array of additional_permissions
get_list => If true, returns a list of perms. If false, returns a set of perms.
"""
# Use a set to avoid duplicate permissions # Use a set to avoid duplicate permissions
portfolio_permissions = set() portfolio_permissions = set()
if roles: if roles:
@ -119,7 +124,7 @@ class UserPortfolioPermission(TimeStampedModel):
portfolio_permissions.update(cls.PORTFOLIO_ROLE_PERMISSIONS.get(role, [])) portfolio_permissions.update(cls.PORTFOLIO_ROLE_PERMISSIONS.get(role, []))
if additional_permissions: if additional_permissions:
portfolio_permissions.update(additional_permissions) portfolio_permissions.update(additional_permissions)
return list(portfolio_permissions) return list(portfolio_permissions) if get_list else portfolio_permissions
@classmethod @classmethod
def get_domain_request_permission_display(cls, roles, additional_permissions): def get_domain_request_permission_display(cls, roles, additional_permissions):

View file

@ -4,6 +4,9 @@ from django.apps import apps
from django.forms import ValidationError from django.forms import ValidationError
from registrar.utility.waffle import flag_is_active_for_user from registrar.utility.waffle import flag_is_active_for_user
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
import logging
logger = logging.getLogger(__name__)
class UserPortfolioRoleChoices(models.TextChoices): class UserPortfolioRoleChoices(models.TextChoices):
@ -16,7 +19,28 @@ class UserPortfolioRoleChoices(models.TextChoices):
@classmethod @classmethod
def get_user_portfolio_role_label(cls, user_portfolio_role): def get_user_portfolio_role_label(cls, user_portfolio_role):
try:
return cls(user_portfolio_role).label if user_portfolio_role else None return cls(user_portfolio_role).label if user_portfolio_role else None
except ValueError:
logger.warning(f"Invalid portfolio role: {user_portfolio_role}")
return f"Unknown ({user_portfolio_role})"
@classmethod
def get_role_description(cls, user_portfolio_role):
"""Returns a detailed description for a given role."""
descriptions = {
cls.ORGANIZATION_ADMIN: (
"Grants this member access to the organization-wide information "
"on domains, domain requests, and members. Domain management can be assigned separately."
),
cls.ORGANIZATION_MEMBER: (
"Grants this member access to the organization. They can be given extra permissions to view all "
"organization domain requests and submit domain requests on behalf of the organization. Basic access "
"members cant view all members of an organization or manage them. "
"Domain management can be assigned separately."
),
}
return descriptions.get(user_portfolio_role)
class UserPortfolioPermissionChoices(models.TextChoices): class UserPortfolioPermissionChoices(models.TextChoices):

View file

@ -1,3 +1,5 @@
{% load static custom_filters %}
<div class="{{ uswds_input_class }}"> <div class="{{ uswds_input_class }}">
{% for group, options, index in widget.optgroups %} {% for group, options, index in widget.optgroups %}
{% if group %}<div><label>{{ group }}</label>{% endif %} {% if group %}<div><label>{{ group }}</label>{% endif %}
@ -13,7 +15,17 @@
<label <label
class="{{ uswds_input_class }}__label{% if label_classes %} {{ label_classes }}{% endif %}" class="{{ uswds_input_class }}__label{% if label_classes %} {{ label_classes }}{% endif %}"
for="{{ option.attrs.id }}" for="{{ option.attrs.id }}"
>{{ option.label }}</label> >
{{ option.label }}
{% comment %} Add a description on each, if available {% endcomment %}
{% if field and field.field and field.field.descriptions %}
{% with description=field.field.descriptions|get_dict_value:option.value %}
{% if description %}
<p class="margin-0 margin-top-1">{{ description }}</p>
{% endif %}
{% endwith %}
{% endif %}
</label>
{% endfor %} {% endfor %}
{% if group %}</div>{% endif %} {% if group %}</div>{% endif %}
{% endfor %} {% endfor %}

View file

@ -1,42 +1,132 @@
{% extends 'portfolio_base.html' %} {% extends 'portfolio_base.html' %}
{% load static field_helpers%} {% load static url_helpers %}
{% load field_helpers %}
{% block title %}Organization member{% endblock %} {% block title %}Organization member{% endblock %}
{% load static %} {% block wrapper_class %}
{{ block.super }} dashboard--grey-1
{% endblock %}
{% block portfolio_content %} {% block portfolio_content %}
<div class="grid-row grid-gap"> {% include "includes/form_errors.html" with form=form %}
<div class="tablet:grid-col-9" id="main-content">
{% block messages %} <!-- Navigation breadcrumbs -->
{% include "includes/form_messages.html" %} <nav class="usa-breadcrumb padding-top-0" aria-label="Domain request breadcrumb">
{% endblock %} <ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
<h1>Manage member</h1> <a href="{% url 'members' %}" class="usa-breadcrumb__link"><span>Members</span></a>
</li>
<p> <li class="usa-breadcrumb__list-item">
{% if member %} {% if member %}
{{ member.email }} {% url 'member' pk=member.pk as back_url %}
{% elif invitation %} {% elif invitation %}
{% url 'invitedmember' pk=invitation.pk as back_url %}
{% endif %}
<a href="{{ back_url }}" class="usa-breadcrumb__link"><span>Manage member</span></a>
</li>
{% comment %} Manage members {% endcomment %}
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Member access and permissions</span>
</li>
</ol>
</nav>
<!-- Page header -->
<h1>Member access and permissions</h1>
{% include "includes/required_fields.html" with remove_margin_top=True %}
<form class="usa-form usa-form--large" method="post" id="member_form" novalidate>
{% csrf_token %}
<fieldset class="usa-fieldset">
<legend>
{% if member and member.email or invitation and invitation.email %}
<h2 class="margin-top-1">Member email</h2>
{% else %}
<h2 class="margin-top-1">Member</h2>
{% endif %}
</legend>
<p class="margin-top-0">
{% comment %}
Show member email if possible, then invitation email.
If neither of these are true, show the name or as a last resort just "None".
{% endcomment %}
{% if member %}
{% if member.email %}
{{ member.email }}
{% else %}
{{ member.get_formatted_name }}
{% endif %}
{% elif invitation %}
{% if invitation.email %}
{{ invitation.email }} {{ invitation.email }}
{% else %}
None
{% endif %}
{% endif %} {% endif %}
</p> </p>
<!-- Member email -->
</fieldset>
<hr> <!-- Member access radio buttons (Toggles other sections) -->
<fieldset class="usa-fieldset">
<legend>
<h2 class="margin-top-0">Member Access</h2>
</legend>
<form class="usa-form usa-form--large" method="post" novalidate> <em>Select the level of access for this member. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
{% csrf_token %}
{% input_with_errors form.roles %} {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% input_with_errors form.additional_permissions %} {% input_with_errors form.role %}
<button {% endwith %}
type="submit"
class="usa-button" </fieldset>
>Submit</button>
<!-- Admin access form -->
<div id="member-admin-permissions" class="margin-top-2">
<h2>Admin access permissions</h2>
<p>Member permissions available for admin-level acccess.</p>
<h3 class="summary-item__title
text-primary-dark
margin-bottom-0">Organization domain requests</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.domain_request_permission_admin %}
{% endwith %}
<h3 class="summary-item__title
text-primary-dark
margin-bottom-0
margin-top-3">Organization members</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.member_permission_admin %}
{% endwith %}
</div>
<!-- Basic access form -->
<div id="member-basic-permissions" class="margin-top-2">
<h2>Basic member permissions</h2>
<p>Member permissions available for basic-level acccess.</p>
<h3 class="margin-bottom-0 summary-item__title text-primary-dark">Organization domain requests</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.domain_request_permission_member %}
{% endwith %}
</div>
<!-- Submit/cancel buttons -->
<div class="margin-top-3">
<a
type="button"
href="{{ back_url }}"
class="usa-button usa-button--outline"
name="btn-cancel-click"
aria-label="Cancel editing member"
>
Cancel
</a>
<button type="submit" class="usa-button">Update Member</button>
</div>
</form> </form>
{% endblock portfolio_content%}
</div>
</div>
{% endblock %}

View file

@ -5,12 +5,6 @@
{% block title %} Domains | {% endblock %} {% block title %} Domains | {% endblock %}
{% block portfolio_content %} {% block portfolio_content %}
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
<div id="main-content"> <div id="main-content">
<h1 id="domains-header">Domains</h1> <h1 id="domains-header">Domains</h1>
<section class="section-outlined"> <section class="section-outlined">

View file

@ -282,3 +282,11 @@ def display_requesting_entity(domain_request):
) )
return display return display
@register.filter
def get_dict_value(dictionary, key):
"""Get a value from a dictionary. Returns a string on empty."""
if isinstance(dictionary, dict):
return dictionary.get(key, "")
return ""

View file

@ -71,8 +71,8 @@ class CsvReportsTest(MockDbForSharedTests):
fake_open = mock_open() fake_open = mock_open()
expected_file_content = [ expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"),
call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"),
call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
] ]
@ -93,8 +93,8 @@ class CsvReportsTest(MockDbForSharedTests):
fake_open = mock_open() fake_open = mock_open()
expected_file_content = [ expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"),
call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"),
call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("zdomain12.gov,Interstate,,,,,(blank)\r\n"), call("zdomain12.gov,Interstate,,,,,(blank)\r\n"),
@ -493,17 +493,17 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
# sorted alphabetially by domain name # sorted alphabetially by domain name
expected_content = ( expected_content = (
"Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n"
"defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n"
"cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n"
"ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n"
"zdomain12.gov,Interstate,,,,,(blank)\n" "zdomain12.gov,Interstate,,,,,(blank)\n"
) )
# Normalize line endings and remove commas, # Normalize line endings and remove commas,
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator @less_console_noise_decorator
@ -533,16 +533,16 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
# sorted alphabetially by domain name # sorted alphabetially by domain name
expected_content = ( expected_content = (
"Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n"
"defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n"
"cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n"
"ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n"
) )
# Normalize line endings and remove commas, # Normalize line endings and remove commas,
# spaces and leading/trailing whitespace # spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator @less_console_noise_decorator

View file

@ -2642,3 +2642,160 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
# Validate Database has not changed # Validate Database has not changed
invite_count_after = PortfolioInvitation.objects.count() invite_count_after = PortfolioInvitation.objects.count()
self.assertEqual(invite_count_after, invite_count_before) self.assertEqual(invite_count_after, invite_count_before)
class TestEditPortfolioMemberView(WebTest):
"""Tests for the edit member page on portfolios"""
def setUp(self):
self.user = create_user()
# Create Portfolio
self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio")
# Add an invited member who has been invited to manage domains
self.invited_member_email = "invited@example.com"
self.invitation = PortfolioInvitation.objects.create(
email=self.invited_member_email,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
],
)
# Assign permissions to the user making requests
UserPortfolioPermission.objects.create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
def tearDown(self):
PortfolioInvitation.objects.all().delete()
UserPortfolioPermission.objects.all().delete()
Portfolio.objects.all().delete()
User.objects.all().delete()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_edit_member_permissions_basic_to_admin(self):
"""Tests converting a basic member to admin with full permissions."""
self.client.force_login(self.user)
# Create a basic member to edit
basic_member = create_test_user()
basic_permission = UserPortfolioPermission.objects.create(
user=basic_member,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS],
)
response = self.client.post(
reverse("member-permissions", kwargs={"pk": basic_permission.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
"domain_request_permission_admin": UserPortfolioPermissionChoices.EDIT_REQUESTS,
"member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS,
},
)
# Verify redirect and success message
self.assertEqual(response.status_code, 302)
# Verify database changes
basic_permission.refresh_from_db()
self.assertEqual(basic_permission.roles, [UserPortfolioRoleChoices.ORGANIZATION_ADMIN])
self.assertEqual(
set(basic_permission.additional_permissions),
{
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
},
)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_edit_member_permissions_validation(self):
"""Tests form validation for required fields based on role."""
self.client.force_login(self.user)
member = create_test_user()
permission = UserPortfolioPermission.objects.create(
user=member, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
)
# Test missing required admin permissions
response = self.client.post(
reverse("member-permissions", kwargs={"pk": permission.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
# Missing required admin fields
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.context["form"].errors["domain_request_permission_admin"][0],
"Admin domain request permission is required",
)
self.assertEqual(
response.context["form"].errors["member_permission_admin"][0], "Admin member permission is required"
)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_edit_invited_member_permissions(self):
"""Tests editing permissions for an invited (but not yet joined) member."""
self.client.force_login(self.user)
# Test updating invitation permissions
response = self.client.post(
reverse("invitedmember-permissions", kwargs={"pk": self.invitation.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
"domain_request_permission_admin": UserPortfolioPermissionChoices.EDIT_REQUESTS,
"member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS,
},
)
self.assertEqual(response.status_code, 302)
# Verify invitation was updated
updated_invitation = PortfolioInvitation.objects.get(pk=self.invitation.id)
self.assertEqual(updated_invitation.roles, [UserPortfolioRoleChoices.ORGANIZATION_ADMIN])
self.assertEqual(
set(updated_invitation.additional_permissions),
{
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
},
)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_admin_removing_own_admin_role(self):
"""Tests an admin removing their own admin role redirects to home."""
self.client.force_login(self.user)
# Get the user's admin permission
admin_permission = UserPortfolioPermission.objects.get(user=self.user, portfolio=self.portfolio)
response = self.client.post(
reverse("member-permissions", kwargs={"pk": admin_permission.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response["Location"], reverse("home"))

View file

@ -744,30 +744,45 @@ class DomainExport(BaseExport):
): ):
security_contact_email = "(blank)" security_contact_email = "(blank)"
model["status"] = human_readable_status
model["first_ready_on"] = first_ready_on
model["expiration_date"] = expiration_date
model["domain_type"] = domain_type
model["security_contact_email"] = security_contact_email
# create a dictionary of fields which can be included in output. # create a dictionary of fields which can be included in output.
# "extra_fields" are precomputed fields (generated in the DB or parsed). # "extra_fields" are precomputed fields (generated in the DB or parsed).
FIELDS = cls.get_fields(model)
row = [FIELDS.get(column, "") for column in columns]
return row
# NOTE - this override is temporary.
# We are running into a problem where DomainDataFull and DomainDataFederal are
# pulling the wrong data.
# For example, the portfolio name, rather than the suborganization name.
# This can be removed after that gets fixed.
@classmethod
def get_fields(cls, model):
FIELDS = { FIELDS = {
"Domain name": model.get("domain__name"), "Domain name": model.get("domain__name"),
"Status": human_readable_status, "Status": model.get("status"),
"First ready on": first_ready_on, "First ready on": model.get("first_ready_on"),
"Expiration date": expiration_date, "Expiration date": model.get("expiration_date"),
"Domain type": domain_type, "Domain type": model.get("domain_type"),
"Agency": model.get("converted_federal_agency"), "Agency": model.get("converted_federal_agency"),
"Organization name": model.get("converted_organization_name"), "Organization name": model.get("converted_organization_name"),
"City": model.get("converted_city"), "City": model.get("converted_city"),
"State": model.get("converted_state_territory"), "State": model.get("converted_state_territory"),
"SO": model.get("converted_so_name"), "SO": model.get("converted_so_name"),
"SO email": model.get("converted_so_email"), "SO email": model.get("converted_so_email"),
"Security contact email": security_contact_email, "Security contact email": model.get("security_contact_email"),
"Created at": model.get("domain__created_at"), "Created at": model.get("domain__created_at"),
"Deleted": model.get("domain__deleted"), "Deleted": model.get("domain__deleted"),
"Domain managers": model.get("managers"), "Domain managers": model.get("managers"),
"Invited domain managers": model.get("invited_users"), "Invited domain managers": model.get("invited_users"),
} }
return FIELDS
row = [FIELDS.get(column, "") for column in columns]
return row
def get_filtered_domain_infos_by_org(domain_infos_to_filter, org_to_filter_by): def get_filtered_domain_infos_by_org(domain_infos_to_filter, org_to_filter_by):
"""Returns a list of Domain Requests that has been filtered by the given organization value.""" """Returns a list of Domain Requests that has been filtered by the given organization value."""
@ -1077,6 +1092,39 @@ class DomainDataFull(DomainExport):
Inherits from BaseExport -> DomainExport Inherits from BaseExport -> DomainExport
""" """
# NOTE - this override is temporary.
# We are running into a problem where DomainDataFull is
# pulling the wrong data.
# For example, the portfolio name, rather than the suborganization name.
# This can be removed after that gets fixed.
# The following fields are changed from DomainExport:
# converted_organization_name => organization_name
# converted_city => city
# converted_state_territory => state_territory
# converted_so_name => so_name
# converted_so_email => senior_official__email
@classmethod
def get_fields(cls, model):
FIELDS = {
"Domain name": model.get("domain__name"),
"Status": model.get("status"),
"First ready on": model.get("first_ready_on"),
"Expiration date": model.get("expiration_date"),
"Domain type": model.get("domain_type"),
"Agency": model.get("federal_agency__agency"),
"Organization name": model.get("organization_name"),
"City": model.get("city"),
"State": model.get("state_territory"),
"SO": model.get("so_name"),
"SO email": model.get("senior_official__email"),
"Security contact email": model.get("security_contact_email"),
"Created at": model.get("domain__created_at"),
"Deleted": model.get("domain__deleted"),
"Domain managers": model.get("managers"),
"Invited domain managers": model.get("invited_users"),
}
return FIELDS
@classmethod @classmethod
def get_columns(cls): def get_columns(cls):
""" """
@ -1106,9 +1154,9 @@ class DomainDataFull(DomainExport):
""" """
# Coalesce is used to replace federal_type of None with ZZZZZ # Coalesce is used to replace federal_type of None with ZZZZZ
return [ return [
"converted_generic_org_type", "organization_type",
Coalesce("converted_federal_type", Value("ZZZZZ")), Coalesce("federal_type", Value("ZZZZZ")),
"converted_federal_agency", "federal_agency",
"domain__name", "domain__name",
] ]
@ -1164,6 +1212,39 @@ class DomainDataFederal(DomainExport):
Inherits from BaseExport -> DomainExport Inherits from BaseExport -> DomainExport
""" """
# NOTE - this override is temporary.
# We are running into a problem where DomainDataFull is
# pulling the wrong data.
# For example, the portfolio name, rather than the suborganization name.
# This can be removed after that gets fixed.
# The following fields are changed from DomainExport:
# converted_organization_name => organization_name
# converted_city => city
# converted_state_territory => state_territory
# converted_so_name => so_name
# converted_so_email => senior_official__email
@classmethod
def get_fields(cls, model):
FIELDS = {
"Domain name": model.get("domain__name"),
"Status": model.get("status"),
"First ready on": model.get("first_ready_on"),
"Expiration date": model.get("expiration_date"),
"Domain type": model.get("domain_type"),
"Agency": model.get("federal_agency__agency"),
"Organization name": model.get("organization_name"),
"City": model.get("city"),
"State": model.get("state_territory"),
"SO": model.get("so_name"),
"SO email": model.get("senior_official__email"),
"Security contact email": model.get("security_contact_email"),
"Created at": model.get("domain__created_at"),
"Deleted": model.get("domain__deleted"),
"Domain managers": model.get("managers"),
"Invited domain managers": model.get("invited_users"),
}
return FIELDS
@classmethod @classmethod
def get_columns(cls): def get_columns(cls):
""" """
@ -1193,9 +1274,9 @@ class DomainDataFederal(DomainExport):
""" """
# Coalesce is used to replace federal_type of None with ZZZZZ # Coalesce is used to replace federal_type of None with ZZZZZ
return [ return [
"converted_generic_org_type", "organization_type",
Coalesce("converted_federal_type", Value("ZZZZZ")), Coalesce("federal_type", Value("ZZZZZ")),
"converted_federal_agency", "federal_agency",
"domain__name", "domain__name",
] ]

View file

@ -6,7 +6,6 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.contrib import messages from django.contrib import messages
from registrar.forms import portfolio as portfolioForms from registrar.forms import portfolio as portfolioForms
from registrar.models import Portfolio, User from registrar.models import Portfolio, User
from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.portfolio_invitation import PortfolioInvitation
@ -144,7 +143,7 @@ class PortfolioMemberDeleteView(PortfolioMemberPermission, View):
class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View): class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
template_name = "portfolio_member_permissions.html" template_name = "portfolio_member_permissions.html"
form_class = portfolioForms.PortfolioMemberForm form_class = portfolioForms.BasePortfolioMemberForm
def get(self, request, pk): def get(self, request, pk):
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk) portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
@ -164,12 +163,17 @@ class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
def post(self, request, pk): def post(self, request, pk):
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk) portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
user = portfolio_permission.user user = portfolio_permission.user
form = self.form_class(request.POST, instance=portfolio_permission) form = self.form_class(request.POST, instance=portfolio_permission)
if form.is_valid(): if form.is_valid():
# Check if user is removing their own admin or edit role
removing_admin_role_on_self = (
request.user == user
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_permission.roles
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in form.cleaned_data.get("role", [])
)
form.save() form.save()
return redirect("member", pk=pk) messages.success(self.request, "The member access and permission changes have been saved.")
return redirect("member", pk=pk) if not removing_admin_role_on_self else redirect("home")
return render( return render(
request, request,
@ -278,7 +282,7 @@ class PortfolioInvitedMemberDeleteView(PortfolioMemberPermission, View):
class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View): class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View):
template_name = "portfolio_member_permissions.html" template_name = "portfolio_member_permissions.html"
form_class = portfolioForms.PortfolioInvitedMemberForm form_class = portfolioForms.BasePortfolioMemberForm
def get(self, request, pk): def get(self, request, pk):
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk) portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
@ -298,6 +302,7 @@ class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View):
form = self.form_class(request.POST, instance=portfolio_invitation) form = self.form_class(request.POST, instance=portfolio_invitation)
if form.is_valid(): if form.is_valid():
form.save() form.save()
messages.success(self.request, "The member access and permission changes have been saved.")
return redirect("invitedmember", pk=pk) return redirect("invitedmember", pk=pk)
return render( return render(

View file

@ -70,6 +70,7 @@
10038 OUTOFSCOPE http://app:8080/org-name-address 10038 OUTOFSCOPE http://app:8080/org-name-address
10038 OUTOFSCOPE http://app:8080/domain_requests/ 10038 OUTOFSCOPE http://app:8080/domain_requests/
10038 OUTOFSCOPE http://app:8080/domains/ 10038 OUTOFSCOPE http://app:8080/domains/
10038 OUTOFSCOPE http://app:8080/domains/edit
10038 OUTOFSCOPE http://app:8080/organization/ 10038 OUTOFSCOPE http://app:8080/organization/
10038 OUTOFSCOPE http://app:8080/permissions 10038 OUTOFSCOPE http://app:8080/permissions
10038 OUTOFSCOPE http://app:8080/suborganization/ 10038 OUTOFSCOPE http://app:8080/suborganization/