mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-21 02:06:03 +02:00
Merge branch 'main' of https://github.com/cisagov/manage.get.gov into es/2913-domain-request-screenreader
This commit is contained in:
commit
2c04e02c5b
15 changed files with 698 additions and 93 deletions
|
@ -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();
|
||||||
|
|
|
@ -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'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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 can’t 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):
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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 %}
|
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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 ""
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"))
|
||||||
|
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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/
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue