merge main

This commit is contained in:
David Kennedy 2025-03-06 14:08:11 -05:00
commit 0c2a1fb773
No known key found for this signature in database
GPG key ID: 6528A5386E66B96B
53 changed files with 894 additions and 340 deletions

View file

@ -176,6 +176,18 @@ class MyUserAdminForm(UserChangeForm):
"user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False),
}
# Loads "tabtitle" for this admin page so that on render the <title>
# element will only have the model name instead of
# the default string loaded by native Django admin code.
# (Eg. instead of "Select contact to change", display "Contacts")
# see "base_site.html" for the <title> code.
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title()
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
def __init__(self, *args, **kwargs):
"""Custom init to modify the user form"""
super(MyUserAdminForm, self).__init__(*args, **kwargs)
@ -205,38 +217,177 @@ class MyUserAdminForm(UserChangeForm):
)
class UserPortfolioPermissionsForm(forms.ModelForm):
class Meta:
model = models.UserPortfolioPermission
fields = "__all__"
widgets = {
"roles": FilteredSelectMultipleArrayWidget(
"roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
),
"additional_permissions": FilteredSelectMultipleArrayWidget(
"additional_permissions",
is_stacked=False,
choices=UserPortfolioPermissionChoices.choices,
),
}
class PortfolioPermissionsForm(forms.ModelForm):
"""
Form for managing portfolio permissions in Django admin. This form class is used
for both UserPortfolioPermission and PortfolioInvitation models.
Allows selecting a portfolio, assigning a role, and managing specific permissions
related to requests, domains, and members.
"""
# Define available permissions for requests, domains, and members
REQUEST_PERMISSIONS = [
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
]
DOMAIN_PERMISSIONS = [
UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
]
MEMBER_PERMISSIONS = [
UserPortfolioPermissionChoices.VIEW_MEMBERS,
]
# Dropdown to select a portfolio
portfolio = forms.ModelChoiceField(
queryset=models.Portfolio.objects.all(),
label="Portfolio",
widget=AutocompleteSelectWithPlaceholder(
models.PortfolioInvitation._meta.get_field("portfolio"),
admin.site,
attrs={"data-placeholder": "---------"}, # Customize placeholder
),
)
# Dropdown for selecting the user role (e.g., Admin or Basic)
role = forms.ChoiceField(
choices=[("", "---------")] + UserPortfolioRoleChoices.choices,
required=True,
widget=forms.Select(attrs={"class": "admin-dropdown"}),
label="Member access",
help_text="Only admins can manage member permissions and organization metadata.",
)
# Dropdown for selecting request permissions, with a default "No access" option
request_permissions = forms.ChoiceField(
choices=[(None, "No access")] + [(perm.value, perm.label) for perm in REQUEST_PERMISSIONS],
required=False,
widget=forms.Select(attrs={"class": "admin-dropdown"}),
label="Domain requests",
)
# Dropdown for selecting domain permissions
domain_permissions = forms.ChoiceField(
choices=[(perm.value, perm.label) for perm in DOMAIN_PERMISSIONS],
required=False,
widget=forms.Select(attrs={"class": "admin-dropdown"}),
label="Domains",
)
# Dropdown for selecting member permissions, with a default "No access" option
member_permissions = forms.ChoiceField(
choices=[(None, "No access")] + [(perm.value, perm.label) for perm in MEMBER_PERMISSIONS],
required=False,
widget=forms.Select(attrs={"class": "admin-dropdown"}),
label="Members",
)
def __init__(self, *args, **kwargs):
"""
Initialize the form and set default values based on the existing instance.
"""
super().__init__(*args, **kwargs)
# If an instance exists, populate the form fields with existing data
if self.instance and self.instance.pk:
# Set the initial value for the role field
if self.instance.roles:
self.fields["role"].initial = self.instance.roles[0] # Assuming a single role per user
# Set the initial values for permissions based on the instance data
if self.instance.additional_permissions:
for perm in self.instance.additional_permissions:
if perm in self.REQUEST_PERMISSIONS:
self.fields["request_permissions"].initial = perm
elif perm in self.DOMAIN_PERMISSIONS:
self.fields["domain_permissions"].initial = perm
elif perm in self.MEMBER_PERMISSIONS:
self.fields["member_permissions"].initial = perm
def clean(self):
"""
Custom validation and processing of form data before saving.
"""
cleaned_data = super().clean()
# Store the selected role as a list (assuming single role assignment)
self.instance.roles = [cleaned_data.get("role")] if cleaned_data.get("role") else []
cleaned_data["roles"] = self.instance.roles
# If the selected role is "organization_member," store additional permissions
if self.instance.roles == [UserPortfolioRoleChoices.ORGANIZATION_MEMBER]:
self.instance.additional_permissions = list(
filter(
None,
[
cleaned_data.get("request_permissions"),
cleaned_data.get("domain_permissions"),
cleaned_data.get("member_permissions"),
],
)
)
else:
# If the user is an admin, clear any additional permissions
self.instance.additional_permissions = []
cleaned_data["additional_permissions"] = self.instance.additional_permissions
return cleaned_data
class PortfolioInvitationAdminForm(UserChangeForm):
"""This form utilizes the custom widget for its class's ManyToMany UIs."""
class UserPortfolioPermissionsForm(PortfolioPermissionsForm):
"""
Form for managing user portfolio permissions in Django admin.
Extends PortfolioPermissionsForm to include a user field, allowing administrators
to assign roles and permissions to specific users within a portfolio.
"""
# Dropdown to select a user from the database
user = forms.ModelChoiceField(
queryset=models.User.objects.all(),
label="User",
widget=AutocompleteSelectWithPlaceholder(
models.UserPortfolioPermission._meta.get_field("user"),
admin.site,
attrs={"data-placeholder": "---------"}, # Customize placeholder
),
)
class Meta:
model = models.PortfolioInvitation
fields = "__all__"
widgets = {
"roles": FilteredSelectMultipleArrayWidget(
"roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
),
"additional_permissions": FilteredSelectMultipleArrayWidget(
"additional_permissions",
is_stacked=False,
choices=UserPortfolioPermissionChoices.choices,
),
}
"""
Meta class defining the model and fields to be used in the form.
"""
model = models.UserPortfolioPermission # Uses the UserPortfolioPermission model
fields = ["user", "portfolio", "role", "domain_permissions", "request_permissions", "member_permissions"]
class PortfolioInvitationForm(PortfolioPermissionsForm):
"""
Form for sending portfolio invitations in Django admin.
Extends PortfolioPermissionsForm to include an email field for inviting users,
allowing them to be assigned a role and permissions within a portfolio before they join.
"""
class Meta:
"""
Meta class defining the model and fields to be used in the form.
"""
model = models.PortfolioInvitation # Uses the PortfolioInvitation model
fields = [
"email",
"portfolio",
"role",
"domain_permissions",
"request_permissions",
"member_permissions",
"status",
]
class DomainInformationAdminForm(forms.ModelForm):
@ -536,6 +687,18 @@ class CustomLogEntryAdmin(LogEntryAdmin):
"user_url",
]
# Loads "tabtitle" for this admin page so that on render the <title>
# element will only have the model name instead of
# the default string loaded by native Django admin code.
# (Eg. instead of "Select contact to change", display "Contacts")
# see "base_site.html" for the <title> code.
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title()
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
# We name the custom prop 'resource' because linter
# is not allowing a short_description attr on it
# This gets around the linter limitation, for now.
@ -555,13 +718,6 @@ class CustomLogEntryAdmin(LogEntryAdmin):
change_form_template = "admin/change_form_no_submit.html"
add_form_template = "admin/change_form_no_submit.html"
# Select log entry to change -> Log entries
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Log entries"
return super().changelist_view(request, extra_context=extra_context)
# #786: Skipping on updating audit log tab titles for now
# def change_view(self, request, object_id, form_url="", extra_context=None):
# if extra_context is None:
@ -642,6 +798,18 @@ class AdminSortFields:
class AuditedAdmin(admin.ModelAdmin):
"""Custom admin to make auditing easier."""
# Loads "tabtitle" for this admin page so that on render the <title>
# element will only have the model name instead of
# the default string loaded by native Django admin code.
# (Eg. instead of "Select contact to change", display "Contacts")
# see "base_site.html" for the <title> code.
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title()
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
def history_view(self, request, object_id, extra_context=None):
"""On clicking 'History', take admin to the auditlog view for an object."""
return HttpResponseRedirect(
@ -1042,6 +1210,18 @@ class MyUserAdmin(BaseUserAdmin, ImportExportRegistrarModelAdmin):
extra_context = {"domain_requests": domain_requests, "domains": domains, "portfolios": portfolios}
return super().change_view(request, object_id, form_url, extra_context)
# Loads "tabtitle" for this admin page so that on render the <title>
# element will only have the model name instead of
# the default string loaded by native Django admin code.
# (Eg. instead of "Select contact to change", display "Contacts")
# see "base_site.html" for the <title> code.
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title()
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
class HostIPInline(admin.StackedInline):
"""Edit an ip address on the host page."""
@ -1066,14 +1246,6 @@ class MyHostAdmin(AuditedAdmin, ImportExportRegistrarModelAdmin):
search_help_text = "Search by domain or host name."
inlines = [HostIPInline]
# Select host to change -> Host
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Host"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
class HostIpResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
@ -1089,14 +1261,6 @@ class HostIpAdmin(AuditedAdmin, ImportExportRegistrarModelAdmin):
resource_classes = [HostIpResource]
model = models.HostIP
# Select host ip to change -> Host ip
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Host IP"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
class ContactResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
@ -1218,14 +1382,6 @@ class ContactAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
return super().change_view(request, object_id, form_url, extra_context=extra_context)
# Select contact to change -> Contacts
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Contacts"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
def save_model(self, request, obj, form, change):
# Clear warning messages before saving
storage = messages.get_messages(request)
@ -1419,12 +1575,13 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin):
change_form_template = "django/admin/user_portfolio_permission_change_form.html"
delete_confirmation_template = "django/admin/user_portfolio_permission_delete_confirmation.html"
delete_selected_confirmation_template = "django/admin/user_portfolio_permission_delete_selected_confirmation.html"
def get_roles(self, obj):
readable_roles = obj.get_readable_roles()
return ", ".join(readable_roles)
get_roles.short_description = "Roles" # type: ignore
get_roles.short_description = "Member access" # type: ignore
def delete_queryset(self, request, queryset):
"""We override the delete method in the model.
@ -1774,7 +1931,7 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
class PortfolioInvitationAdmin(BaseInvitationAdmin):
"""Custom portfolio invitation admin class."""
form = PortfolioInvitationAdminForm
form = PortfolioInvitationForm
class Meta:
model = models.PortfolioInvitation
@ -1786,8 +1943,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin):
list_display = [
"email",
"portfolio",
"roles",
"additional_permissions",
"get_roles",
"status",
]
@ -1812,14 +1968,13 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin):
change_form_template = "django/admin/portfolio_invitation_change_form.html"
delete_confirmation_template = "django/admin/portfolio_invitation_delete_confirmation.html"
delete_selected_confirmation_template = "django/admin/portfolio_invitation_delete_selected_confirmation.html"
# Select portfolio invitations to change -> Portfolio invitations
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Portfolio invitations"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
def get_roles(self, obj):
readable_roles = obj.get_readable_roles()
return ", ".join(readable_roles)
get_roles.short_description = "Member access" # type: ignore
def save_model(self, request, obj, form, change):
"""
@ -2210,14 +2365,6 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
readonly_fields.extend([field for field in self.analyst_readonly_fields])
return readonly_fields # Read-only fields for analysts
# Select domain information to change -> Domain information
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Domain information"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
def formfield_for_foreignkey(self, db_field, request, **kwargs):
"""Customize the behavior of formfields with foreign key relationships. This will customize
the behavior of selects. Customized behavior includes sorting of objects in list."""
@ -3121,11 +3268,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
if next_char.isdigit():
should_apply_default_filter = True
# Select domain request to change -> Domain requests
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Domain requests"
if should_apply_default_filter:
# modify the GET of the request to set the selected filter
modified_get = copy.deepcopy(request.GET)
@ -4296,14 +4438,6 @@ class DraftDomainAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
# If no redirection is needed, return the original response
return response
# Select draft domain to change -> Draft domains
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Draft domains"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
class PublicContactResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
@ -4602,23 +4736,23 @@ class PortfolioAdmin(ListHeaderAdmin):
return format_html(f"{admin_count} administrators")
url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}"
# Create a clickable link with the count
return format_html(f'<a href="{url}">{admin_count} administrators</a>')
return "No administrators found."
return format_html(f'<a href="{url}">{admin_count} admins</a>')
return "No admins found."
display_admins.short_description = "Administrators" # type: ignore
display_admins.short_description = "Admins" # type: ignore
def display_members(self, obj):
"""Returns the number of members for this portfolio"""
"""Returns the number of basic members for this portfolio"""
member_count = len(self.get_user_portfolio_permission_non_admins(obj))
if member_count > 0:
if self.is_omb_analyst:
return format_html(f"{member_count} members")
url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}"
# Create a clickable link with the count
return format_html(f'<a href="{url}">{member_count} members</a>')
return "No additional members found."
return format_html(f'<a href="{url}">{member_count} basic members</a>')
return "No basic members found."
display_members.short_description = "Members" # type: ignore
display_members.short_description = "Basic members" # type: ignore
# Creates select2 fields (with search bars)
autocomplete_fields = [
@ -4878,14 +5012,6 @@ class UserGroupAdmin(AuditedAdmin):
def user_group(self, obj):
return obj.name
# Select user groups to change -> User groups
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "User groups"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
class WaffleFlagAdmin(FlagAdmin):
"""Custom admin implementation of django-waffle's Flag class"""
@ -4902,6 +5028,13 @@ class WaffleFlagAdmin(FlagAdmin):
if extra_context is None:
extra_context = {}
extra_context["dns_prototype_flag"] = flag_is_active_for_user(request.user, "dns_prototype_flag")
# Loads "tabtitle" for this admin page so that on render the <title>
# element will only have the model name instead of
# the default string loaded by native Django admin code.
# (Eg. instead of "Select waffle flags to change", display "Waffle Flags")
# see "base_site.html" for the <title> code.
extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title()
return super().changelist_view(request, extra_context=extra_context)

View file

@ -5284,7 +5284,10 @@ const setUpModal = baseComponent => {
overlayDiv.classList.add(OVERLAY_CLASSNAME);
// Set attributes
modalWrapper.setAttribute("role", "dialog");
// DOTGOV
// Removes the dialog role as this causes a double readout bug with screenreaders
// modalWrapper.setAttribute("role", "dialog");
// END DOTGOV
modalWrapper.setAttribute("id", modalID);
if (ariaLabelledBy) {
modalWrapper.setAttribute("aria-labelledby", ariaLabelledBy);

View file

@ -1,4 +1,4 @@
import { hideElement, showElement, addOrRemoveSessionBoolean } from './helpers-admin.js';
import { hideElement, showElement, addOrRemoveSessionBoolean, announceForScreenReaders } from './helpers-admin.js';
import { handlePortfolioSelection } from './helpers-portfolio-dynamic-fields.js';
function displayModalOnDropdownClick(linkClickedDisplaysModal, statusDropdown, actionButton, valueToCheck){
@ -684,3 +684,33 @@ export function initDynamicDomainRequestFields(){
handleSuborgFieldsAndButtons();
}
}
export function initFilterFocusListeners() {
document.addEventListener("DOMContentLoaded", function() {
let filters = document.querySelectorAll("#changelist-filter li a"); // Get list of all filter links
let clickedFilter = false; // Used to determine if we are truly navigating away or not
// Restore focus from localStorage
let lastClickedFilterId = localStorage.getItem("admin_filter_focus_id");
if (lastClickedFilterId) {
let focusedElement = document.getElementById(lastClickedFilterId);
if (focusedElement) {
//Focus the element
focusedElement.setAttribute("tabindex", "0");
focusedElement.focus({ preventScroll: true });
// Announce focus change for screen readers
announceForScreenReaders("Filter refocused on " + focusedElement.textContent);
localStorage.removeItem("admin_filter_focus_id");
}
}
// Capture clicked filter and store its ID
filters.forEach(filter => {
filter.addEventListener("click", function() {
localStorage.setItem("admin_filter_focus_id", this.id);
clickedFilter = true; // Mark that a filter was clicked
});
});
});
}

View file

@ -32,3 +32,22 @@ export function getParameterByName(name, url) {
if (!results[2]) return '';
return decodeURIComponent(results[2].replace(/\+/g, ' '));
}
/**
* Creates a temporary live region to announce messages for screen readers.
*/
export function announceForScreenReaders(message) {
let liveRegion = document.createElement("div");
liveRegion.setAttribute("aria-live", "assertive");
liveRegion.setAttribute("role", "alert");
liveRegion.setAttribute("class", "usa-sr-only");
document.body.appendChild(liveRegion);
// Delay the update slightly to ensure it's recognized
setTimeout(() => {
liveRegion.textContent = message;
setTimeout(() => {
document.body.removeChild(liveRegion);
}, 1000);
}, 100);
}

View file

@ -10,9 +10,11 @@ import {
initRejectedEmail,
initApprovedDomain,
initCopyRequestSummary,
initDynamicDomainRequestFields } from './domain-request-form.js';
initDynamicDomainRequestFields,
initFilterFocusListeners } from './domain-request-form.js';
import { initDomainFormTargetBlankButtons } from './domain-form.js';
import { initDynamicPortfolioFields } from './portfolio-form.js';
import { initDynamicPortfolioPermissionFields } from './portfolio-permissions-form.js'
import { initDynamicDomainInformationFields } from './domain-information-form.js';
import { initDynamicDomainFields } from './domain-form.js';
import { initAnalyticsDashboard } from './analytics.js';
@ -34,6 +36,7 @@ initRejectedEmail();
initApprovedDomain();
initCopyRequestSummary();
initDynamicDomainRequestFields();
initFilterFocusListeners();
// Domain
initDomainFormTargetBlankButtons();
@ -42,6 +45,9 @@ initDynamicDomainFields();
// Portfolio
initDynamicPortfolioFields();
// Portfolio permissions
initDynamicPortfolioPermissionFields();
// Domain information
initDynamicDomainInformationFields();

View file

@ -0,0 +1,67 @@
import { hideElement, showElement } from './helpers-admin.js';
/**
* A function for dynamically changing fields on the UserPortfolioPermissions
* and PortfolioInvitation admin forms
*/
function handlePortfolioPermissionFields(){
const roleDropdown = document.getElementById("id_role");
const domainPermissionsField = document.querySelector(".field-domain_permissions");
const domainRequestPermissionsField = document.querySelector(".field-request_permissions");
const memberPermissionsField = document.querySelector(".field-member_permissions");
/**
* Updates the visibility of portfolio permissions fields based on the selected role.
*
* This function checks the value of the role dropdown (`roleDropdown`):
* - If the selected role is "organization_member":
* - Shows the domain permissions field (`domainPermissionsField`).
* - Shows the domain request permissions field (`domainRequestPermissionsField`).
* - Shows the member permissions field (`memberPermissionsField`).
* - Otherwise:
* - Hides all the above fields.
*
* The function ensures that the appropriate fields are dynamically displayed
* or hidden depending on the role selection in the form.
*/
function updatePortfolioPermissionsFormVisibility() {
if (roleDropdown && domainPermissionsField && domainRequestPermissionsField && memberPermissionsField) {
if (roleDropdown.value === "organization_member") {
showElement(domainPermissionsField);
showElement(domainRequestPermissionsField);
showElement(memberPermissionsField);
} else {
hideElement(domainPermissionsField);
hideElement(domainRequestPermissionsField);
hideElement(memberPermissionsField);
}
}
}
/**
* Sets event listeners for key UI elements.
*/
function setEventListeners() {
if (roleDropdown) {
roleDropdown.addEventListener("change", function() {
updatePortfolioPermissionsFormVisibility();
})
}
}
// Run initial setup functions
updatePortfolioPermissionsFormVisibility();
setEventListeners();
}
export function initDynamicPortfolioPermissionFields() {
document.addEventListener('DOMContentLoaded', function() {
let isPortfolioPermissionPage = document.getElementById("userportfoliopermission_form");
let isPortfolioInvitationPage = document.getElementById("portfolioinvitation_form")
if (isPortfolioPermissionPage || isPortfolioInvitationPage) {
handlePortfolioPermissionFields();
}
});
}

View file

@ -292,7 +292,18 @@ export function initFormsetsForms() {
// For the other contacts form, we need to update the fieldset headers based on what's visible vs hidden,
// since the form on the backend employs Django's DELETE widget.
let totalShownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`).length;
newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${totalShownForms + 1}`);
let newFormCount = totalShownForms + 1;
// update the header
let header = newForm.querySelector('legend h3');
header.textContent = `${formLabel} ${newFormCount}`;
header.id = `org-contact-${newFormCount}`;
// update accessibility elements on the delete buttons
let deleteDescription = newForm.querySelector('.delete-button-description');
deleteDescription.textContent = 'Delete new contact';
deleteDescription.id = `org-contact-${newFormCount}__name`;
let deleteButton = newForm.querySelector('button');
deleteButton.setAttribute("aria-labelledby", header.id);
deleteButton.setAttribute("aria-describedby", deleteDescription.id);
} else {
// Nameservers form is cloned from index 2 which has the word optional on init, does not have the word optional
// if indices 0 or 1 have been deleted

View file

@ -15,6 +15,7 @@ import { initDomainManagersPage } from './domain-managers.js';
import { initDomainDSData } from './domain-dsdata.js';
import { initDomainDNSSEC } from './domain-dnssec.js';
import { initFormErrorHandling } from './form-errors.js';
import { initButtonLinks } from '../getgov-admin/button-utils.js';
initDomainValidators();
@ -49,3 +50,5 @@ initFormErrorHandling();
initPortfolioMemberPageRadio();
initPortfolioNewMemberPageToggle();
initAddNewMemberPageListeners();
initButtonLinks();

View file

@ -25,7 +25,6 @@
// Note, width is determined by a custom width class on one of the children
position: absolute;
z-index: 1;
left: 0;
border-radius: 4px;
border: solid 1px color('base-lighter');
padding: units(2) units(2) units(3) units(2);
@ -42,6 +41,14 @@
}
}
// This will work in responsive tables if we overwrite the overflow value on the table container
// Works with styles in _tables
@include at-media(desktop) {
.usa-accordion--more-actions .usa-accordion__content {
left: 0;
}
}
.usa-accordion--select .usa-accordion__content {
top: 33.88px;
}
@ -59,10 +66,12 @@
// This won't work on the Members table rows because that table has show-more rows
// Currently, that's not an issue since that Members table is not wrapped in the
// reponsive wrapper.
tr:last-of-type .usa-accordion--more-actions .usa-accordion__content {
top: auto;
bottom: -10px;
right: 30px;
@include at-media-max("desktop") {
tr:last-of-type .usa-accordion--more-actions .usa-accordion__content {
top: auto;
bottom: -10px;
right: 30px;
}
}
// A CSS only show-more/show-less based on usa-accordion

View file

@ -226,11 +226,6 @@ abbr[title] {
}
}
// Boost this USWDS utility class for the accordions in the portfolio requests table
.left-auto {
left: auto!important;
}
.usa-banner__inner--widescreen {
max-width: $widescreen-max-width;
}

View file

@ -152,3 +152,12 @@ th {
.usa-table--full-borderless th {
border: none !important;
}
// This is an override to overflow on certain tables (note the custom class)
// so that a popup menu can appear and starddle the edge of the table on large
// screen sizes. Works with styles in _accordions
@include at-media(desktop) {
.usa-table-container--scrollable.usa-table-container--override-overflow {
overflow-y: visible;
}
}

View file

@ -319,31 +319,23 @@ class DomainRequestFixture:
"""Creates DomainRequests given a list of users."""
total_domain_requests_to_make = len(users) # 100000
# Check if the database is already populated with the desired
# number of entries.
# (Prevents re-adding more entries to an already populated database,
# which happens when restarting Docker src)
domain_requests_already_made = DomainRequest.objects.count()
domain_requests_to_create = []
if domain_requests_already_made < total_domain_requests_to_make:
for user in users:
for request_data in cls.DOMAINREQUESTS:
# Prepare DomainRequest objects
try:
domain_request = DomainRequest(
creator=user,
organization_name=request_data["organization_name"],
)
cls._set_non_foreign_key_fields(domain_request, request_data)
cls._set_foreign_key_fields(domain_request, request_data, user)
domain_requests_to_create.append(domain_request)
except Exception as e:
logger.warning(e)
num_additional_requests_to_make = (
total_domain_requests_to_make - domain_requests_already_made - len(domain_requests_to_create)
)
for user in users:
for request_data in cls.DOMAINREQUESTS:
# Prepare DomainRequest objects
try:
domain_request = DomainRequest(
creator=user,
organization_name=request_data["organization_name"],
)
cls._set_non_foreign_key_fields(domain_request, request_data)
cls._set_foreign_key_fields(domain_request, request_data, user)
domain_requests_to_create.append(domain_request)
except Exception as e:
logger.warning(e)
num_additional_requests_to_make = total_domain_requests_to_make - len(domain_requests_to_create)
if num_additional_requests_to_make > 0:
for _ in range(num_additional_requests_to_make):
random_user = random.choice(users) # nosec

View file

@ -1,4 +1,4 @@
""" "
"""
Converts all ready and DNS needed domains with a non-default public contact
to disclose their public contact. Created for Issue#1535 to resolve
disclose issue of domains with missing security emails.

View file

@ -1,4 +1,4 @@
""" "
"""
Data migration: Renaming deprecated Federal Agencies to
their new updated names ie (U.S. Peace Corps to Peace Corps)
within Domain Information and Domain Requests

View file

@ -0,0 +1,86 @@
# Generated by Django 4.2.17 on 2025-02-28 17:11
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0140_alter_portfolioinvitation_additional_permissions_and_more"),
]
operations = [
migrations.AlterField(
model_name="portfolioinvitation",
name="additional_permissions",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("view_all_domains", "Viewer"),
("view_managed_domains", "Viewer, limited (domains they manage)"),
("view_members", "Viewer"),
("edit_members", "Manager"),
("view_all_requests", "Viewer"),
("edit_requests", "Creator"),
("view_portfolio", "Viewer"),
("edit_portfolio", "Manager"),
],
max_length=50,
),
blank=True,
help_text="Select one or more additional permissions.",
null=True,
size=None,
),
),
migrations.AlterField(
model_name="portfolioinvitation",
name="roles",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[("organization_admin", "Admin"), ("organization_member", "Basic")], max_length=50
),
blank=True,
help_text="Select one or more roles.",
null=True,
size=None,
),
),
migrations.AlterField(
model_name="userportfoliopermission",
name="additional_permissions",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("view_all_domains", "Viewer"),
("view_managed_domains", "Viewer, limited (domains they manage)"),
("view_members", "Viewer"),
("edit_members", "Manager"),
("view_all_requests", "Viewer"),
("edit_requests", "Creator"),
("view_portfolio", "Viewer"),
("edit_portfolio", "Manager"),
],
max_length=50,
),
blank=True,
help_text="Select one or more additional permissions.",
null=True,
size=None,
),
),
migrations.AlterField(
model_name="userportfoliopermission",
name="roles",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[("organization_admin", "Admin"), ("organization_member", "Basic")], max_length=50
),
blank=True,
help_text="Select one or more roles.",
null=True,
size=None,
),
),
]

View file

@ -26,7 +26,7 @@ def create_groups(apps, schema_editor) -> Any:
class Migration(migrations.Migration):
dependencies = [
("registrar", "0140_alter_portfolioinvitation_additional_permissions_and_more"),
("registrar", "0141_alter_portfolioinvitation_additional_permissions_and_more"),
]
operations = [

View file

@ -15,6 +15,7 @@ from .utility.portfolio_helper import (
get_domains_display,
get_members_description_display,
get_members_display,
get_readable_roles,
get_role_display,
validate_portfolio_invitation,
) # type: ignore
@ -78,6 +79,10 @@ class PortfolioInvitation(TimeStampedModel):
def __str__(self):
return f"Invitation for {self.email} on {self.portfolio} is {self.status}"
def get_readable_roles(self):
"""Returns a readable list of self.roles"""
return get_readable_roles(self.roles)
def get_managed_domains_count(self):
"""Return the count of domain invitations managed by the invited user for this portfolio."""
# Filter the UserDomainRole model to get domains where the user has a manager role

View file

@ -12,6 +12,7 @@ from registrar.models.utility.portfolio_helper import (
get_domains_description_display,
get_members_display,
get_members_description_display,
get_readable_roles,
get_role_display,
validate_user_portfolio_permission,
)
@ -94,12 +95,7 @@ class UserPortfolioPermission(TimeStampedModel):
def get_readable_roles(self):
"""Returns a readable list of self.roles"""
readable_roles = []
if self.roles:
readable_roles = sorted(
[UserPortfolioRoleChoices.get_user_portfolio_role_label(role) for role in self.roles]
)
return readable_roles
return get_readable_roles(self.roles)
def get_managed_domains_count(self):
"""Return the count of domains managed by the user for this portfolio."""
@ -275,7 +271,12 @@ class UserPortfolioPermission(TimeStampedModel):
def clean(self):
"""Extends clean method to perform additional validation, which can raise errors in django admin."""
super().clean()
validate_user_portfolio_permission(self)
# Ensure user exists before running further validation
# In django admin, this clean method is called before form validation checks
# for required fields. Since validation below requires user, skip if user does
# not exist
if self.user_id:
validate_user_portfolio_permission(self)
def delete(self, *args, **kwargs):

View file

@ -16,7 +16,7 @@ class UserPortfolioRoleChoices(models.TextChoices):
"""
ORGANIZATION_ADMIN = "organization_admin", "Admin"
ORGANIZATION_MEMBER = "organization_member", "Member"
ORGANIZATION_MEMBER = "organization_member", "Basic"
@classmethod
def get_user_portfolio_role_label(cls, user_portfolio_role):
@ -30,17 +30,17 @@ class UserPortfolioRoleChoices(models.TextChoices):
class UserPortfolioPermissionChoices(models.TextChoices):
""" """
VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports"
VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains"
VIEW_ALL_DOMAINS = "view_all_domains", "Viewer"
VIEW_MANAGED_DOMAINS = "view_managed_domains", "Viewer, limited (domains they manage)"
VIEW_MEMBERS = "view_members", "View members"
EDIT_MEMBERS = "edit_members", "Create and edit members"
VIEW_MEMBERS = "view_members", "Viewer"
EDIT_MEMBERS = "edit_members", "Manager"
VIEW_ALL_REQUESTS = "view_all_requests", "View all requests"
EDIT_REQUESTS = "edit_requests", "Create and edit requests"
VIEW_ALL_REQUESTS = "view_all_requests", "Viewer"
EDIT_REQUESTS = "edit_requests", "Creator"
VIEW_PORTFOLIO = "view_portfolio", "View organization"
EDIT_PORTFOLIO = "edit_portfolio", "Edit organization"
VIEW_PORTFOLIO = "view_portfolio", "Viewer"
EDIT_PORTFOLIO = "edit_portfolio", "Manager"
@classmethod
def get_user_portfolio_permission_label(cls, user_portfolio_permission):
@ -79,6 +79,13 @@ class MemberPermissionDisplay(StrEnum):
NONE = "None"
def get_readable_roles(roles):
readable_roles = []
if roles:
readable_roles = sorted([UserPortfolioRoleChoices.get_user_portfolio_role_label(role) for role in roles])
return readable_roles
def get_role_display(roles):
"""
Returns a user-friendly display name for a given list of user roles.

View file

@ -2,6 +2,10 @@
{% load static %}
{% load i18n %}
{% block title %}
Registrar Analytics | Django admin
{% endblock %}
{% block content_title %}<h1>Registrar Analytics</h1>{% endblock %}
{% block breadcrumbs %}

View file

@ -33,8 +33,8 @@
{{ tabtitle }} |
{% else %}
{{ title }} |
{% endif %}
{{ site_title|default:_('Django site admin') }}
{% endif %}
Django admin
{% endblock %}
{% block extrastyle %}{{ block.super }}

View file

@ -0,0 +1,13 @@
{% comment %} Override of this file: https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/filter.html {% endcomment %}
{% load i18n %}
<details data-filter-title="{{ title }}" open>
<summary>
{% blocktranslate with filter_title=title %} By {{ filter_title }} {% endblocktranslate %}
</summary>
<ul>
{% for choice in choices %}
<li {% if choice.selected %} class="selected"{% endif %}>
<a id="{{ title|lower|cut:' ' }}-filter-{{ choice.display|slugify }}" href="{{ choice.query_string|iriencode }}">{{ choice.display }}</a></li>
{% endfor %}
</ul>
</details>

View file

@ -30,6 +30,8 @@
{% include "django/admin/includes/descriptions/verified_by_staff_description.html" %}
{% elif opts.model_name == 'website' %}
{% include "django/admin/includes/descriptions/website_description.html" %}
{% elif opts.model_name == 'userportfoliopermission' %}
{% include "django/admin/includes/descriptions/user_portfolio_permission_description.html" %}
{% elif opts.model_name == 'portfolioinvitation' %}
{% include "django/admin/includes/descriptions/portfolio_invitation_description.html" %}
{% elif opts.model_name == 'allowedemail' %}

View file

@ -6,7 +6,11 @@
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you add someone to a domain here, it will trigger emails to the invitee and all managers of the domain when you click "save." If you don't want to trigger those emails, use the <a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">User domain roles permissions table</a> instead.
If you invite someone to a domain here, it will trigger email notifications. If you don't want to trigger emails, use the
<a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">
User Domain Roles
</a>
table instead.
</p>
</div>
</div>

View file

@ -5,10 +5,12 @@
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you cancel the domain invitation here, it won't trigger any emails. It also won't remove
their domain management privileges if they already have that role assigned. Go to the
<a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">User Domain Roles table</a>
if you want to remove the user from a domain.
If you cancel the domain invitation here, it won't trigger any email notifications.
It also won't remove the user's domain management privileges if they already logged in. Go to the
<a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">
User Domain Roles
</a>
table if you want to remove their domain management privileges.
</p>
</div>
</div>

View file

@ -5,10 +5,12 @@
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you cancel the domain invitation here, it won't trigger any emails. It also won't remove
their domain management privileges if they already have that role assigned. Go to the
<a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">User Domain Roles table</a>
if you want to remove the user from a domain.
If you cancel the domain invitation here, it won't trigger any email notifications.
It also won't remove the user's domain management privileges if they already logged in. Go to the
<a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">
User Domain Roles
</a>
table if you want to remove their domain management privileges.
</p>
</div>
</div>

View file

@ -1,16 +1,14 @@
<p>
Domain invitations contain all individuals who have been invited to manage a .gov domain.
Invitations are sent via email, and the recipient must log in to the registrar to officially
accept and become a domain manager.
This table contains all individuals who have been invited to manage a .gov domain.
These individuals must log in to the registrar to officially accept and become a domain manager.
</p>
<p>
An “invited” status indicates that the recipient has not logged in to the registrar since the invitation was sent. Deleting an invitation with an "invited" status will prevent the user from signing in.
A “received” status indicates that the recipient has logged in. Deleting an invitation with a "received" status will not revoke that user's access from the domain. To remove a user who has already signed in, go to <a class="text-underline" href="{% url 'admin:registrar_userdomainrole_changelist' %}">User domain roles</a> and delete the role for the correct domain/manager combination.
An “invited” status indicates that the recipient has not logged in to the registrar since the invitation was sent.
A “received” status indicates that the recipient has logged in.
</p>
<p>
If an invitation is created in this table, an email will not be sent.
To have an email sent, go to the domain in <a class="text-underline" href="{% url 'admin:registrar_domain_changelist' %}">Domains</a>,
click the “Manage domain” button, and add a domain manager.
If you invite someone to a domain by using this table, theyll receive an email notification.
The existing managers of the domain will also be notified. However, canceling an invitation here wont trigger any emails.
</p>

View file

@ -1,11 +1,15 @@
<p>
Portfolio invitations contain all individuals who have been invited to become members of an organization.
Invitations are sent via email, and the recipient must log in to the registrar to officially
accept and become a member.
This table contains all individuals who have been invited to become members of a portfolio.
These individuals must log in to the registrar to officially accept and become a member.
</p>
<p>
An “invited” status indicates that the recipient has not logged in to the registrar since the invitation was sent
or that the recipient has logged in but is already a member of an organization.
A “received” status indicates that the recipient has logged in.
An “invited” status indicates that the recipient has not logged in to the registrar since the invitation
was sent or that the recipient has logged in but is already a member of another portfolio. A “received”
status indicates that the recipient has logged in.
</p>
<p>
If you invite someone to a portfolio by using this table, theyll receive an email notification.
If you assign them "admin" access, the existing portfolio admins will also be notified. However, canceling an invitation here wont trigger any emails.
</p>

View file

@ -1,10 +1,13 @@
<p>
This table represents the managers who are assigned to each domain in the registrar.
There are separate records for each domain/manager combination.
Managers can update information related to a domain, such as DNS data and security contact.
This table represents the managers who are assigned to each domain in the registrar. There are separate records for each domain/manager combination.
Managers can update information related to a domain, such as DNS data and security contact.
</p>
<p>
The creator of an approved domain request automatically becomes a manager for that domain.
Anyone who retrieves a domain invitation is also assigned the manager role.
The creator of an approved domain request automatically becomes a manager for that domain.
Anyone who retrieves a domain invitation will also appear in this table as a manager.
</p>
<p>
If you add or remove someone to a domain by using this table, those actions wont trigger notification emails.
</p>

View file

@ -0,0 +1,11 @@
<p>
This table represents the members of each portfolio in the registrar. There are separate records for each member/portfolio combination.
</p>
<p>
Each member is assigned one of two access levels: admin or basic. Only admins can manage member permissions and organization metadata.
</p>
<p>
If you add or remove someone to a portfolio by using this table, those actions wont trigger notification emails.
</p>

View file

@ -9,16 +9,12 @@
{% for choice in choices %}
{% if choice.reset %}
<li{% if choice.selected %} class="selected"{% endif %}">
<a href="{{ choice.query_string|iriencode }}" title="{{ choice.display }}">{{ choice.display }}</a>
<a id="{{ title|lower|cut:' ' }}-filter-{{ choice.display|slugify }}" href="{{ choice.query_string|iriencode }}" title="{{ choice.display }}">{{ choice.display }}</a>
</li>
{% endif %}
{% endfor %}
{% for choice in choices %}
{% if not choice.reset %}
<li{% if choice.selected %} class="selected"{% endif %}">
{% else %}
<li{% if choice.selected %} class="selected"{% endif %}>
{% if choice.selected and choice.exclude_query_string %}
<a role="menuitemcheckbox" class="choice-filter choice-filter--checked" href="{{ choice.exclude_query_string|iriencode }}">{{ choice.display }}
<a id="{{ title|lower|cut:' ' }}-filter-{{ choice.display|slugify }}" role="menuitemcheckbox" class="choice-filter choice-filter--checked" href="{{ choice.exclude_query_string|iriencode }}">{{ choice.display }}
<svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#check_box_outline_blank"></use>
</svg>
@ -26,9 +22,8 @@
<use xlink:href="{%static 'img/sprite.svg'%}#check"></use>
</svg>
</a>
{% endif %}
{% if not choice.selected and choice.include_query_string %}
<a role="menuitemcheckbox" class="choice-filter" href="{{ choice.include_query_string|iriencode }}">{{ choice.display }}
{% elif not choice.selected and choice.include_query_string %}
<a id="{{ title|lower|cut:' ' }}-filter-{{ choice.display|slugify }}" role="menuitemcheckbox" class="choice-filter" href="{{ choice.include_query_string|iriencode }}">{{ choice.display }}
<svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#check_box_outline_blank"></use>
</svg>
@ -38,4 +33,4 @@
{% endif %}
{% endfor %}
</ul>
</details>
</details>

View file

@ -6,7 +6,11 @@
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you add someone to a portfolio here, it will trigger an invitation email when you click "save." If you don't want to trigger an email, use the <a class="usa-link" href="{% url 'admin:registrar_userportfoliopermission_changelist' %}">User portfolio permissions table</a> instead.
If you invite someone to a portfolio here, it will trigger email notifications. If you don't want to trigger emails, use the
<a class="usa-link" href="{% url 'admin:registrar_userportfoliopermission_changelist' %}">
User Portfolio Permissions
</a>
table instead.
</p>
</div>
</div>

View file

@ -4,12 +4,12 @@
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you cancel the portfolio invitation here, it won't trigger any emails. It also won't remove the user's
portfolio access if they already logged in. Go to the
If you cancel the portfolio invitation here, it won't trigger any email notifications.
It also won't remove the user's portfolio access if they already logged in. Go to the
<a href="{% url 'admin:registrar_userportfoliopermission_changelist' %}">
User Portfolio Permissions
</a>
table if you want to remove the user from a portfolio.
table if you want to remove their portfolio access.
</p>
</div>
</div>

View file

@ -0,0 +1,17 @@
{% extends "admin/delete_selected_confirmation.html" %}
{% block content_subtitle %}
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you cancel the portfolio invitation here, it won't trigger any email notifications.
It also won't remove the user's portfolio access if they already logged in. Go to the
<a href="{% url 'admin:registrar_userportfoliopermission_changelist' %}">
User Portfolio Permissions
</a>
table if you want to remove their portfolio access.
</p>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -6,7 +6,10 @@
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you add someone to a domain here, it will not trigger any emails. To trigger emails, use the <a class="usa-link" href="{% url 'admin:registrar_domaininvitation_changelist' %}">User Domain Role invitations table</a> instead.
If you add someone to a domain here, it won't trigger any email notifications. To trigger emails, use the
<a class="usa-link" href="{% url 'admin:registrar_domaininvitation_changelist' %}">
Domain Invitations
</a> table instead.
</p>
</div>
</div>

View file

@ -5,7 +5,7 @@
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you remove someone from a domain here, it won't trigger any emails when you click "save."
If you remove someone from a domain here, it won't trigger any email notifications.
</p>
</div>
</div>

View file

@ -5,7 +5,7 @@
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you remove someone from a domain here, it won't trigger any emails when you click "save."
If you remove someone from a domain here, it won't trigger any email notifications.
</p>
</div>
</div>

View file

@ -6,7 +6,11 @@
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you add someone to a portfolio here, it will not trigger an invitation email. To trigger an email, use the <a class="usa-link" href="{% url 'admin:registrar_portfolioinvitation_changelist' %}">Portfolio invitations table</a> instead.
If you add someone to a portfolio here, it won't trigger any email notifications. To trigger emails, use the
<a class="usa-link" href="{% url 'admin:registrar_portfolioinvitation_changelist' %}">
Portfolio Invitations
</a>
table instead.
</p>
</div>
</div>

View file

@ -4,7 +4,7 @@
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you remove someone from a portfolio here, it will not send any emails when you click "Save".
If you remove someone from a portfolio here, it won't trigger any email notifications.
</p>
</div>
</div>

View file

@ -0,0 +1,12 @@
{% extends "admin/delete_selected_confirmation.html" %}
{% block content_subtitle %}
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you remove someone from a portfolio here, it won't trigger any email notifications.
</p>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -29,7 +29,10 @@
{% csrf_token %}
{% if domain.domain_info.generic_org_type == 'federal' %}
{% input_with_errors form.federal_agency %}
<h4 class="margin-bottom-05">Federal Agency</h4>
<p class="margin-top-0">
{{ domain.domain_info.federal_agency }}
</p>
{% endif %}
{% input_with_errors form.organization_name %}

View file

@ -61,7 +61,7 @@
<fieldset class="usa-fieldset margin-top-1 dotgov-domain-form" id="form-container">
<legend>
<h2>Alternative domains (optional)</h2>
<h2 id="alternative-domains-title">Alternative domains (optional)</h2>
</legend>
<p id="alt_domain_instructions" class="margin-top-05">Are there other domains youd like if we cant give
@ -79,19 +79,23 @@
{% endfor %}
{% endwith %}
{% endwith %}
<button type="button" value="save" class="usa-button usa-button--unstyled usa-button--with-icon" id="add-form">
<div class="usa-sr-only" id="alternative-domains__add-another-alternative">Add another alternative domain</div>
<button aria-labelledby="alternative-domains-title" aria-describedby="alternative-domains__add-another-alternative" type="button" value="save" class="usa-button usa-button--unstyled usa-button--with-icon" id="add-form">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add another alternative</span>
</button>
<div class="margin-bottom-3">
<div class="usa-sr-only" id="alternative-domains__check-availability">Check domain availability</div>
<button
id="validate-alt-domains-availability"
type="button"
class="usa-button usa-button--outline"
validate-for="{{ forms.1.requested_domain.auto_id }}"
aria-labelledby="alternative-domains-title"
aria-describedby="alternative-domains__check-availability"
>Check availability</button>
</div>

View file

@ -31,10 +31,14 @@
<fieldset class="usa-fieldset repeatable-form padding-y-1">
<legend class="float-left-tablet">
<h3 class="margin-top-05">Organization contact {{ forloop.counter }}</h2>
<h3 class="margin-top-05" id="org-contact-{{ forloop.counter }}">Organization contact {{ forloop.counter }}</h2>
</legend>
<button type="button" class="usa-button usa-button--unstyled display-block float-right-tablet delete-record margin-top-1 text-secondary line-height-sans-5 usa-button--with-icon">
{% if form.first_name or form.last_name %}
<span class="usa-sr-only delete-button-description" id="org-contact-{{ forloop.counter }}__name">Delete {{form.first_name.value }} {{ form.last_name.value }}</span>
{% else %}
<span class="usa-sr-only" id="org-contact-{{ forloop.counter }}__name">Delete new contact</span>
{% endif %}
<button aria-labelledby="org-contact-{{ forloop.counter }}" aria-describedby="org-contact-{{ forloop.counter }}__name" type="button" class="usa-button usa-button--unstyled display-block float-right-tablet delete-record margin-top-1 text-secondary line-height-sans-5 usa-button--with-icon">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
</svg>Delete

View file

@ -18,10 +18,10 @@
<h1>Manage your domains</h1>
<p class="margin-top-4">
<a href="{% url 'domain-request:start' %}" class="usa-button"
<button data-href="{% url 'domain-request:start' %}" class="usa-button use-button-as-link"
>
Start a new domain request
</a>
</button>
</p>
{% include "includes/domains_table.html" with user_domain_count=user_domain_count %}

View file

@ -14,22 +14,15 @@
{% endif %}
<div class="section-outlined__search section-outlined__search--widescreen {% if portfolio %}mobile:grid-col-12 desktop:grid-col-6{% endif %}">
<section aria-label="Domain requests search component" class="margin-top-2">
<section aria-label="Domain requests search component" id="domain-requests-search-component" class="margin-top-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="domain-requests__reset-search" type="button">
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="domain-requests__reset-search" type="button" aria-labelledby="domain-requests-search-component">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
Reset
</button>
<label id="domain-requests__search-label" class="usa-sr-only" for="domain-requests__search-field">
{% if portfolio %}
Search by domain name or creator
{% else %}
Search by domain name
{% endif %}
</label>
</button>
<input
class="usa-input"
id="domain-requests__search-field"
@ -40,8 +33,10 @@
{% else %}
placeholder="Search by domain name"
{% endif %}
aria-labelledby="domain-requests-search-component"
/>
<button class="usa-button" type="submit" id="domain-requests__search-field-submit" aria-labelledby="domain-requests__search-label">
<div class="usa-sr-only" id="domain-requests-search-button__description">Click to search</div>
<button class="usa-button" type="submit" id="domain-requests__search-field-submit" aria-labelledby="domain-requests-search-component" aria-describedby="domain-requests-search-button__description">
<img
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
class="usa-search__submit-icon"
@ -163,7 +158,7 @@
</div>
{% endif %}
<div class="display-none usa-table-container--scrollable margin-top-0" tabindex="0" id="domain-requests__table-wrapper">
<div class="display-none usa-table-container--scrollable usa-table-container--override-overflow margin-top-0" tabindex="0" id="domain-requests__table-wrapper">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
<caption class="sr-only">Your domain requests</caption>
<thead>

View file

@ -34,24 +34,25 @@
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
{% endif %}
<div class="section-outlined__search section-outlined__search--widescreen {% if portfolio %}mobile:grid-col-12 desktop:grid-col-6{% endif %}">
<section aria-label="Domains search component" class="margin-top-2">
<section aria-label="Domains search component" class="margin-top-2" id="domains-search-component">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="domains__reset-search" type="button">
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="domains__reset-search" type="button" aria-labelledby="domains-search-component">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
Reset
</button>
<label id="domains__search-label" class="usa-sr-only" for="domains__search-field">Search by domain name</label>
<input
class="usa-input"
id="domains__search-field"
type="search"
name="domains-search"
placeholder="Search by domain name"
aria-labelledby="domains-search-component"
/>
<button class="usa-button" type="submit" id="domains__search-field-submit" aria-labelledby="domains__search-label">
<div class="usa-sr-only" id="domains-search-button__description">Click to search</div>
<button class="usa-button" type="submit" id="domains__search-field-submit" aria-labelledby="domains-search-component" aria-describedby="domains-search-button__description">
<img
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
class="usa-search__submit-icon"
@ -63,12 +64,13 @@
</div>
{% if user_domain_count and user_domain_count > 0 %}
<div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
<section aria-label="Domains report component" class="margin-top-205">
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right">
<section aria-label="Domains report component" class="margin-top-205" id="domains-report-component">
<div class="usa-sr-only" id="domains-export-button__description">Click to export as csv</div>
<button data-href="{% url 'export_data_type_user' %}" class="use-button-as-link usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right" aria-labelledby="domains-report-component" aria-describedby="domains-export-button__description">
<svg class="usa-icon usa-icon--large" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg>Export as CSV
</a>
</button>
</section>
</div>
{% endif %}
@ -198,7 +200,7 @@
</svg>
</button>
</div>
<div class="display-none usa-table-container--scrollable margin-top-0" tabindex="0" id="domains__table-wrapper">
<div class="display-none usa-table-container--scrollable usa-table-container--override-overflow margin-top-0" tabindex="0" id="domains__table-wrapper">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
<caption class="sr-only">Your registered domains</caption>
<thead>

View file

@ -1,16 +1,18 @@
{% if form.errors %}
<div id="form-errors">
{% for error in form.non_field_errors %}
<div class="usa-alert usa-alert--error usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert usa-alert--error usa-alert--slim margin-bottom-2" role="alert" tabindex="0">
<div class="usa-alert__body">
{{ error|escape }}
<span class="usa-sr-only">Error:</span>
{{ error|escape }}
</div>
</div>
{% endfor %}
{% for field in form %}
{% for error in field.errors %}
<div class="usa-alert usa-alert--error usa-alert--slim margin-bottom-2">
<div class="usa-alert usa-alert--error usa-alert--slim margin-bottom-2" tabindex="0">
<div class="usa-alert__body">
<span class="usa-sr-only">Error:</span>
{{ error|escape }}
</div>
</div>

View file

@ -9,24 +9,25 @@
<div class="section-outlined__header margin-bottom-3 grid-row">
<!-- ---------- SEARCH ---------- -->
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-6 section-outlined__search--widescreen">
<section aria-label="Members search component" class="margin-top-2">
<section aria-label="Members search component" class="margin-top-2" id="members-search-component">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="members__reset-search" type="button">
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="members__reset-search" type="button" aria-labelledby="members-search-component">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
Reset
</button>
<label class="usa-sr-only" for="members__search-field">Search by member name</label>
<input
class="usa-input"
id="members__search-field"
type="search"
name="members-search"
placeholder="Search by member name"
aria-labelledby="members-search-component"
/>
<button class="usa-button" type="submit" id="members__search-field-submit">
<div class="usa-sr-only" id="members-search-button__description">Click to search</div>
<button class="usa-button" type="submit" id="members__search-field-submit" aria-labelledby="members-search-component" aria-describedby="members-search-button__description">
<img
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
class="usa-search__submit-icon"
@ -37,12 +38,13 @@
</section>
</div>
<div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
<section aria-label="Domains report component" class="margin-top-205">
<a href="{% url 'export_members_portfolio' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right">
<section aria-label="Members report component" class="margin-top-205" id="members-report-component">
<div class="usa-sr-only" id="members-export-button__description">Click to export as csv</div>
<button href="{% url 'export_members_portfolio' %}" class="use-button-as-link usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right" aria-labelledby="members-report-component" aria-describedby="members-export-button__description">
<svg class="usa-icon usa-icon--large" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg>Export as CSV
</a>
</button>
</section>
</div>
</div>

View file

@ -26,10 +26,10 @@
<div class="mobile:grid-col-12 tablet:grid-col-6">
<p class="float-right-tablet tablet:margin-y-0">
<a href="{% url 'domain-request:start' %}" class="usa-button"
<button data-href="{% url 'domain-request:start' %}" class="usa-button use-button-as-link"
>
Start a new domain request
</a>
</button>
</p>
</div>
{% else %}

View file

@ -2,6 +2,7 @@ from datetime import datetime
from django.utils import timezone
from django.test import TestCase, RequestFactory, Client
from django.contrib.admin.sites import AdminSite
from registrar import models
from registrar.utility.email import EmailSendingError
from registrar.utility.errors import MissingEmailError
from waffle.testutils import override_flag
@ -19,6 +20,7 @@ from registrar.admin import (
MyHostAdmin,
PortfolioInvitationAdmin,
UserDomainRoleAdmin,
UserPortfolioPermissionsForm,
VerifiedByStaffAdmin,
FsmModelResource,
WebsiteAdmin,
@ -175,7 +177,7 @@ class TestDomainInvitationAdmin(WebTest):
# Test for a description snippet
self.assertContains(
response, "Domain invitations contain all individuals who have been invited to manage a .gov domain."
response, "This table contains all individuals who have been invited to manage a .gov domain."
)
self.assertContains(response, "Show more")
@ -199,7 +201,7 @@ class TestDomainInvitationAdmin(WebTest):
# Test for a description snippet
self.assertContains(
response,
"If you add someone to a domain here, it will trigger emails to the invitee and all managers of the domain",
"If you invite someone to a domain here, it will trigger email notifications.",
)
@less_console_noise_decorator
@ -217,12 +219,12 @@ class TestDomainInvitationAdmin(WebTest):
# Assert that the filters are added
self.assertContains(response, "invited", count=5)
self.assertContains(response, "Invited", count=2)
self.assertContains(response, "retrieved", count=2)
self.assertContains(response, "retrieved", count=3)
self.assertContains(response, "Retrieved", count=2)
# Check for the HTML context specificially
invited_html = '<a href="?status__exact=invited">Invited</a>'
retrieved_html = '<a href="?status__exact=retrieved">Retrieved</a>'
invited_html = '<a id="status-filter-invited" href="?status__exact=invited">Invited</a>'
retrieved_html = '<a id="status-filter-retrieved" href="?status__exact=retrieved">Retrieved</a>'
self.assertContains(response, invited_html, count=1)
self.assertContains(response, retrieved_html, count=1)
@ -1166,7 +1168,7 @@ class TestUserPortfolioPermissionAdmin(TestCase):
# Test for a description snippet
self.assertContains(
response,
"If you add someone to a portfolio here, it will not trigger an invitation email.",
"If you add someone to a portfolio here, it won't trigger any email notifications.",
)
@less_console_noise_decorator
@ -1181,7 +1183,7 @@ class TestUserPortfolioPermissionAdmin(TestCase):
response = self.client.get(delete_url)
# Check if the response contains the expected static message
expected_message = "If you remove someone from a portfolio here, it will not send any emails"
expected_message = "If you remove someone from a portfolio here, it won't trigger any email notifications."
self.assertIn(expected_message, response.content.decode("utf-8"))
@ -1230,7 +1232,7 @@ class TestPortfolioInvitationAdmin(TestCase):
# Test for a description snippet
self.assertContains(
response,
"Portfolio invitations contain all individuals who have been invited to become members of an organization.",
"This table contains all individuals who have been invited to become members of a portfolio.",
)
self.assertContains(response, "Show more")
@ -1254,7 +1256,7 @@ class TestPortfolioInvitationAdmin(TestCase):
# Test for a description snippet
self.assertContains(
response,
"If you add someone to a portfolio here, it will trigger an invitation email when you click",
"If you invite someone to a portfolio here, it will trigger email notifications.",
)
@less_console_noise_decorator
@ -1269,14 +1271,14 @@ class TestPortfolioInvitationAdmin(TestCase):
)
# Assert that the filters are added
self.assertContains(response, "invited", count=4)
self.assertContains(response, "invited", count=5)
self.assertContains(response, "Invited", count=2)
self.assertContains(response, "retrieved", count=2)
self.assertContains(response, "retrieved", count=3)
self.assertContains(response, "Retrieved", count=2)
# Check for the HTML context specificially
invited_html = '<a href="?status__exact=invited">Invited</a>'
retrieved_html = '<a href="?status__exact=retrieved">Retrieved</a>'
invited_html = '<a id="status-filter-invited" href="?status__exact=invited">Invited</a>'
retrieved_html = '<a id="status-filter-retrieved" href="?status__exact=retrieved">Retrieved</a>'
self.assertContains(response, invited_html, count=1)
self.assertContains(response, retrieved_html, count=1)
@ -1638,6 +1640,143 @@ class TestPortfolioInvitationAdmin(TestCase):
self.assertIn(expected_message, response.content.decode("utf-8"))
class PortfolioPermissionsFormTest(TestCase):
def setUp(self):
# Create a mock portfolio for testing
self.user = create_test_user()
self.portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test Portfolio", creator=self.user)
def tearDown(self):
UserPortfolioPermission.objects.all().delete()
Portfolio.objects.all().delete()
User.objects.all().delete()
def test_form_valid_with_required_fields(self):
"""Test that the form is valid when required fields are filled correctly."""
# Mock the instance or use a test instance
test_instance = models.UserPortfolioPermission.objects.create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
],
)
form_data = {
"portfolio": self.portfolio.id,
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
"request_permissions": "view_all_requests",
"domain_permissions": "view_all_domains",
"member_permissions": "view_members",
"user": self.user.id,
}
form = UserPortfolioPermissionsForm(data=form_data, instance=test_instance)
self.assertTrue(form.is_valid())
def test_form_invalid_without_role(self):
"""Test that the form is invalid if role is missing."""
# Mock the instance or use a test instance
test_instance = models.UserPortfolioPermission.objects.create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
],
)
form_data = {
"portfolio": self.portfolio.id,
"role": "", # Missing role
}
form = UserPortfolioPermissionsForm(data=form_data, instance=test_instance)
self.assertFalse(form.is_valid())
self.assertIn("role", form.errors)
def test_member_role_preserves_permissions(self):
"""Ensure that selecting 'organization_member' keeps the additional permissions."""
# Mock the instance or use a test instance
test_instance = models.UserPortfolioPermission.objects.create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
],
)
form_data = {
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
"request_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
"domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
"member_permissions": UserPortfolioPermissionChoices.VIEW_MEMBERS,
"portfolio": self.portfolio.id,
"user": self.user.id,
}
form = UserPortfolioPermissionsForm(data=form_data, instance=test_instance)
# Check if form is valid
self.assertTrue(form.is_valid())
# Test if permissions are correctly preserved
cleaned_data = form.cleaned_data
self.assertIn(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, cleaned_data["request_permissions"])
self.assertIn(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS, cleaned_data["domain_permissions"])
def test_admin_role_clears_permissions(self):
"""Ensure that selecting 'organization_admin' clears additional permissions."""
# Mock the instance or use a test instance
test_instance = models.UserPortfolioPermission.objects.create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
],
)
form_data = {
"portfolio": self.portfolio.id,
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
"request_permissions": "view_all_requests",
"domain_permissions": "view_all_domains",
"member_permissions": "view_members",
"user": self.user.id,
}
form = UserPortfolioPermissionsForm(data=form_data, instance=test_instance)
self.assertTrue(form.is_valid())
# Simulate form save to check cleaned data behavior
cleaned_data = form.clean()
self.assertEqual(cleaned_data["role"], UserPortfolioRoleChoices.ORGANIZATION_ADMIN)
self.assertNotIn("request_permissions", cleaned_data["additional_permissions"]) # Permissions should be removed
self.assertNotIn("domain_permissions", cleaned_data["additional_permissions"])
self.assertNotIn("member_permissions", cleaned_data["additional_permissions"])
def test_invalid_permission_choice(self):
"""Ensure invalid permissions are not accepted."""
# Mock the instance or use a test instance
test_instance = models.UserPortfolioPermission.objects.create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
],
)
form_data = {
"portfolio": self.portfolio.id,
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
"request_permissions": "invalid_permission", # Invalid choice
}
form = UserPortfolioPermissionsForm(data=form_data, instance=test_instance)
self.assertFalse(form.is_valid())
self.assertIn("request_permissions", form.errors)
class TestHostAdmin(TestCase):
"""Tests for the HostAdmin class as super user
@ -2186,7 +2325,7 @@ class TestUserDomainRoleAdmin(WebTest):
# Test for a description snippet
self.assertContains(
response,
"If you add someone to a domain here, it will not trigger any emails.",
"If you add someone to a domain here, it won't trigger any email notifications.",
)
def test_domain_sortable(self):
@ -3560,10 +3699,10 @@ class TestPortfolioAdmin(TestCase):
display_admins = self.admin.display_admins(self.portfolio)
url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={self.portfolio.id}"
self.assertIn(f'<a href="{url}">2 administrators</a>', display_admins)
self.assertIn(f'<a href="{url}">2 admins</a>', display_admins)
display_members = self.admin.display_members(self.portfolio)
self.assertIn(f'<a href="{url}">2 members</a>', display_members)
self.assertIn(f'<a href="{url}">2 basic members</a>', display_members)
@less_console_noise_decorator
def test_senior_official_readonly_for_federal_org(self):

View file

@ -888,8 +888,8 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib):
csv_content = csv_file.read()
expected_content = (
# Header
"Email,Organization admin,Invited by,Joined date,Last active,Domain requests,"
"Member management,Domain management,Number of domains,Domains\n"
"Email,Member access,Invited by,Joined date,Last active,Domain requests,"
"Members,Domains,Number domains assigned,Domain assignments\n"
# Content
"big_lebowski@dude.co,False,help@get.gov,2022-04-01,Invalid date,None,"
"Viewer,True,1,cdomain1.gov\n"

View file

@ -712,7 +712,7 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
self.assertRedirects(response, reverse("domain", kwargs={"domain_pk": self.domain_with_ip.id}))
# Check for the updated expiration
formatted_new_expiration_date = self.expiration_date_one_year_out().strftime("%b. %-d, %Y")
formatted_new_expiration_date = self.expiration_date_one_year_out().strftime("%B %-d, %Y")
redirect_response = self.client.get(
reverse("domain", kwargs={"domain_pk": self.domain_with_ip.id}), follow=True
)
@ -2088,62 +2088,6 @@ class TestDomainOrganization(TestDomainOverview):
# Check for the value we want to update
self.assertContains(success_result_page, "Faketown")
@less_console_noise_decorator
def test_domain_org_name_address_form_federal(self):
"""
Submitting a change to federal_agency is blocked for federal domains
"""
fed_org_type = DomainInformation.OrganizationChoices.FEDERAL
self.domain_information.generic_org_type = fed_org_type
self.domain_information.save()
try:
federal_agency, _ = FederalAgency.objects.get_or_create(agency="AMTRAK")
self.domain_information.federal_agency = federal_agency
self.domain_information.save()
except ValueError as err:
self.fail(f"A ValueError was caught during the test: {err}")
self.assertEqual(self.domain_information.generic_org_type, fed_org_type)
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"domain_pk": self.domain.id}))
form = org_name_page.forms[0]
# Check the value of the input field
agency_input = form.fields["federal_agency"][0]
self.assertEqual(agency_input.value, str(federal_agency.id))
# Check if the input field is disabled
self.assertTrue("disabled" in agency_input.attrs)
self.assertEqual(agency_input.attrs.get("disabled"), "")
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
org_name_page.form["federal_agency"] = FederalAgency.objects.filter(agency="Department of State").get().id
org_name_page.form["city"] = "Faketown"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Make the change. The agency should be unchanged, but city should be modifiable.
success_result_page = org_name_page.form.submit()
self.assertEqual(success_result_page.status_code, 200)
# Check that the agency has not changed
self.assertEqual(self.domain_information.federal_agency.agency, "AMTRAK")
# Do another check on the form itself
form = success_result_page.forms[0]
# Check the value of the input field
organization_name_input = form.fields["federal_agency"][0]
self.assertEqual(organization_name_input.value, str(federal_agency.id))
# Check if the input field is disabled
self.assertTrue("disabled" in organization_name_input.attrs)
self.assertEqual(organization_name_input.attrs.get("disabled"), "")
# Check for the value we want to update
self.assertContains(success_result_page, "Faketown")
@less_console_noise_decorator
def test_federal_agency_submit_blocked(self):
"""

View file

@ -38,10 +38,15 @@ from django.contrib.admin.models import LogEntry, ADDITION
from django.contrib.contenttypes.models import ContentType
from registrar.models.utility.generic_helper import convert_queryset_to_dict
from registrar.models.utility.orm_helper import ArrayRemoveNull
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from registrar.templatetags.custom_filters import get_region
from registrar.utility.constants import BranchChoices
from registrar.utility.enums import DefaultEmail, DefaultUserValues
from registrar.models.utility.portfolio_helper import (
get_role_display,
get_domain_requests_display,
get_domains_display,
get_members_display,
)
logger = logging.getLogger(__name__)
@ -479,15 +484,15 @@ class MemberExport(BaseExport):
"""
return [
"Email",
"Organization admin",
"Member access",
"Invited by",
"Joined date",
"Last active",
"Domain requests",
"Member management",
"Domain management",
"Number of domains",
"Members",
"Domains",
"Number domains assigned",
"Domain assignments",
]
@classmethod
@ -503,15 +508,15 @@ class MemberExport(BaseExport):
length_user_managed_domains = len(user_managed_domains)
FIELDS = {
"Email": model.get("email_display"),
"Organization admin": bool(UserPortfolioRoleChoices.ORGANIZATION_ADMIN in roles),
"Member access": get_role_display(roles),
"Invited by": model.get("invited_by"),
"Joined date": model.get("joined_date"),
"Last active": model.get("last_active"),
"Domain requests": UserPortfolioPermission.get_domain_request_permission_display(roles, permissions),
"Member management": UserPortfolioPermission.get_member_permission_display(roles, permissions),
"Domain management": bool(length_user_managed_domains > 0),
"Number of domains": length_user_managed_domains,
"Domains": ",".join(user_managed_domains),
"Domain requests": f"{get_domain_requests_display(roles, permissions)}",
"Members": f"{get_members_display(roles, permissions)}",
"Domains": f"{get_domains_display(roles, permissions)}",
"Number domains assigned": length_user_managed_domains,
"Domain assignments": ", ".join(user_managed_domains),
}
return [FIELDS.get(column, "") for column in columns]