diff --git a/docs/developer/management_script_helpers.md b/docs/developer/management_script_helpers.md
index 104e4dc13..a43bb16aa 100644
--- a/docs/developer/management_script_helpers.md
+++ b/docs/developer/management_script_helpers.md
@@ -62,4 +62,5 @@ The class provides the following optional configuration variables:
The class also provides helper methods:
- `get_class_name`: Returns a display-friendly class name for the terminal prompt
- `get_failure_message`: Returns the message to display if a record fails to update
-- `should_skip_record`: Defines the condition for skipping a record (by default, no records are skipped)
\ No newline at end of file
+- `should_skip_record`: Defines the condition for skipping a record (by default, no records are skipped)
+- `custom_filter`: Allows for additional filters that cannot be expressed using django queryset field lookups
\ No newline at end of file
diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md
index 5914eb179..4301ca878 100644
--- a/docs/operations/data_migration.md
+++ b/docs/operations/data_migration.md
@@ -817,6 +817,28 @@ Example: `cf ssh getgov-za`
|:-:|:-------------------------- |:-----------------------------------------------------------------------------------|
| 1 | **federal_cio_csv_path** | Specifies where the federal CIO csv is |
+## Update First Ready Values
+This section outlines how to run the populate_first_ready script
+
+### Running on sandboxes
+
+#### Step 1: Login to CloudFoundry
+```cf login -a api.fr.cloud.gov --sso```
+
+#### Step 2: SSH into your environment
+```cf ssh getgov-{space}```
+
+Example: `cf ssh getgov-za`
+
+#### Step 3: Create a shell instance
+```/tmp/lifecycle/shell```
+
+#### Step 4: Running the script
+```./manage.py update_first_ready```
+
+### Running locally
+```docker-compose exec app ./manage.py update_first_ready```
+
## Populate Domain Request Dates
This section outlines how to run the populate_domain_request_dates script
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 640037847..fb830378c 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -11,6 +11,7 @@ from django.conf import settings
from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField
from registrar.models.domain_information import DomainInformation
+from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from waffle.decorators import flag_is_active
from django.contrib import admin, messages
@@ -2970,11 +2971,7 @@ class PortfolioAdmin(ListHeaderAdmin):
fieldsets = [
# created_on is the created_at field, and portfolio_type is f"{organization_type} - {federal_type}"
(None, {"fields": ["portfolio_type", "organization_name", "creator", "created_on", "notes"]}),
- # TODO - uncomment in #2521
- # ("Portfolio members", {
- # "classes": ("collapse", "closed"),
- # "fields": ["administrators", "members"]}
- # ),
+ ("Portfolio members", {"fields": ["display_admins", "display_members"]}),
("Portfolio domains", {"fields": ["domains", "domain_requests"]}),
("Type of organization", {"fields": ["organization_type", "federal_type"]}),
(
@@ -3022,15 +3019,118 @@ class PortfolioAdmin(ListHeaderAdmin):
readonly_fields = [
# This is the created_at field
"created_on",
- # Custom fields such as these must be defined as readonly.
+ # Django admin doesn't allow methods to be directly listed in fieldsets. We can
+ # display the custom methods display_admins amd display_members in the admin form if
+ # they are readonly.
"federal_type",
"domains",
"domain_requests",
"suborganizations",
"portfolio_type",
+ "display_admins",
+ "display_members",
"creator",
]
+ def get_admin_users(self, obj):
+ # Filter UserPortfolioPermission objects related to the portfolio
+ admin_permissions = UserPortfolioPermission.objects.filter(
+ portfolio=obj, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
+ )
+
+ # Get the user objects associated with these permissions
+ admin_users = User.objects.filter(portfolio_permissions__in=admin_permissions)
+
+ return admin_users
+
+ def get_non_admin_users(self, obj):
+ # Filter UserPortfolioPermission objects related to the portfolio that do NOT have the "Admin" role
+ non_admin_permissions = UserPortfolioPermission.objects.filter(portfolio=obj).exclude(
+ roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
+ )
+
+ # Get the user objects associated with these permissions
+ non_admin_users = User.objects.filter(portfolio_permissions__in=non_admin_permissions)
+
+ return non_admin_users
+
+ def display_admins(self, obj):
+ """Get joined users who are Admin, unpack and return an HTML block.
+
+ 'DJA readonly can't handle querysets, so we need to unpack and return html here.
+ Alternatively, we could return querysets in context but that would limit where this
+ data would display in a custom change form without extensive template customization.
+
+ Will be used in the field_readonly block"""
+ admins = self.get_admin_users(obj)
+ if not admins:
+ return format_html("
"
+ admin_details += f"{escape(portfolio_admin.phone)}"
+ admin_details += ""
+ return format_html(admin_details)
+
+ display_admins.short_description = "Administrators" # type: ignore
+
+ def display_members(self, obj):
+ """Get joined users who have roles/perms that are not Admin, unpack and return an HTML block.
+
+ DJA readonly can't handle querysets, so we need to unpack and return html here.
+ Alternatively, we could return querysets in context but that would limit where this
+ data would display in a custom change form without extensive template customization.
+
+ Will be used in the after_help_text block."""
+ members = self.get_non_admin_users(obj)
+ if not members:
+ return ""
+
+ member_details = (
+ "
Name
Title
Email
"
+ + "
Phone
Roles
"
+ )
+ for member in members:
+ full_name = member.get_formatted_name()
+ member_details += "
"
+ member_details += f"
{escape(full_name)}
"
+ member_details += f"
{escape(member.title)}
"
+ member_details += f"
{escape(member.email)}
"
+ member_details += f"
{escape(member.phone)}
"
+ member_details += "
"
+ for role in member.portfolio_role_summary(obj):
+ member_details += f"{escape(role)} "
+ member_details += "
"
+ member_details += "
"
+ return format_html(member_details)
+
+ display_members.short_description = "Members" # type: ignore
+
+ def display_members_summary(self, obj):
+ """Will be passed as context and used in the field_readonly block."""
+ members = self.get_non_admin_users(obj)
+ if not members:
+ return {}
+
+ return self.get_field_links_as_list(members, "user", separator=", ")
+
def federal_type(self, obj: models.Portfolio):
"""Returns the federal_type field"""
return BranchChoices.get_branch_label(obj.federal_type) if obj.federal_type else "-"
@@ -3090,7 +3190,7 @@ class PortfolioAdmin(ListHeaderAdmin):
]
def get_field_links_as_list(
- self, queryset, model_name, attribute_name=None, link_info_attribute=None, seperator=None
+ self, queryset, model_name, attribute_name=None, link_info_attribute=None, separator=None
):
"""
Generate HTML links for items in a queryset, using a specified attribute for link text.
@@ -3122,14 +3222,14 @@ class PortfolioAdmin(ListHeaderAdmin):
if link_info_attribute:
link += f" ({self.value_of_attribute(item, link_info_attribute)})"
- if seperator:
+ if separator:
links.append(link)
else:
links.append(f"
{link}
")
- # If no seperator is specified, just return an unordered list.
- if seperator:
- return format_html(seperator.join(links)) if links else "-"
+ # If no separator is specified, just return an unordered list.
+ if separator:
+ return format_html(separator.join(links)) if links else "-"
else:
links = "".join(links)
return format_html(f'
{links}
') if links else "-"
@@ -3172,8 +3272,12 @@ class PortfolioAdmin(ListHeaderAdmin):
return readonly_fields
def change_view(self, request, object_id, form_url="", extra_context=None):
- """Add related suborganizations and domain groups"""
- extra_context = {"skip_additional_contact_info": True}
+ """Add related suborganizations and domain groups.
+ Add the summary for the portfolio members field (list of members that link to change_forms)."""
+ obj = self.get_object(request, object_id)
+ extra_context = extra_context or {}
+ extra_context["skip_additional_contact_info"] = True
+ extra_context["display_members_summary"] = self.display_members_summary(obj)
return super().change_view(request, object_id, form_url, extra_context)
def save_model(self, request, obj, form, change):
diff --git a/src/registrar/assets/js/get-gov-admin-extra.js b/src/registrar/assets/js/get-gov-admin-extra.js
new file mode 100644
index 000000000..14059267b
--- /dev/null
+++ b/src/registrar/assets/js/get-gov-admin-extra.js
@@ -0,0 +1,14 @@
+// Use Django's jQuery with Select2 to make the user select on the user transfer view a combobox
+(function($) {
+ $(document).ready(function() {
+ if ($) {
+ $("#selected_user").select2({
+ width: 'resolve',
+ placeholder: 'Select a user',
+ allowClear: true
+ });
+ } else {
+ console.error('jQuery is not available');
+ }
+ });
+})(window.jQuery);
diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js
index b24e946dc..27ff1470b 100644
--- a/src/registrar/assets/js/get-gov-admin.js
+++ b/src/registrar/assets/js/get-gov-admin.js
@@ -172,40 +172,39 @@ function addOrRemoveSessionBoolean(name, add){
** To perform data operations on this - we need to use jQuery rather than vanilla js.
*/
(function (){
- let selector = django.jQuery("#id_investigator")
- let assignSelfButton = document.querySelector("#investigator__assign_self");
- if (!selector || !assignSelfButton) {
- return;
- }
-
- let currentUserId = assignSelfButton.getAttribute("data-user-id");
- let currentUserName = assignSelfButton.getAttribute("data-user-name");
- if (!currentUserId || !currentUserName){
- console.error("Could not assign current user: no values found.")
- return;
- }
-
- // Hook a click listener to the "Assign to me" button.
- // Logic borrowed from here: https://select2.org/programmatic-control/add-select-clear-items#create-if-not-exists
- assignSelfButton.addEventListener("click", function() {
- if (selector.find(`option[value='${currentUserId}']`).length) {
- // Select the value that is associated with the current user.
- selector.val(currentUserId).trigger("change");
- } else {
- // Create a DOM Option that matches the desired user. Then append it and select it.
- let userOption = new Option(currentUserName, currentUserId, true, true);
- selector.append(userOption).trigger("change");
+ if (document.getElementById("id_investigator") && django && django.jQuery) {
+ let selector = django.jQuery("#id_investigator")
+ let assignSelfButton = document.querySelector("#investigator__assign_self");
+ if (!selector || !assignSelfButton) {
+ return;
}
- });
- // Listen to any change events, and hide the parent container if investigator has a value.
- selector.on('change', function() {
- // The parent container has display type flex.
- assignSelfButton.parentElement.style.display = this.value === currentUserId ? "none" : "flex";
- });
-
-
+ let currentUserId = assignSelfButton.getAttribute("data-user-id");
+ let currentUserName = assignSelfButton.getAttribute("data-user-name");
+ if (!currentUserId || !currentUserName){
+ console.error("Could not assign current user: no values found.")
+ return;
+ }
+ // Hook a click listener to the "Assign to me" button.
+ // Logic borrowed from here: https://select2.org/programmatic-control/add-select-clear-items#create-if-not-exists
+ assignSelfButton.addEventListener("click", function() {
+ if (selector.find(`option[value='${currentUserId}']`).length) {
+ // Select the value that is associated with the current user.
+ selector.val(currentUserId).trigger("change");
+ } else {
+ // Create a DOM Option that matches the desired user. Then append it and select it.
+ let userOption = new Option(currentUserName, currentUserId, true, true);
+ selector.append(userOption).trigger("change");
+ }
+ });
+
+ // Listen to any change events, and hide the parent container if investigator has a value.
+ selector.on('change', function() {
+ // The parent container has display type flex.
+ assignSelfButton.parentElement.style.display = this.value === currentUserId ? "none" : "flex";
+ });
+ }
})();
/** An IIFE for pages in DjangoAdmin that use a clipboard button
@@ -215,7 +214,6 @@ function addOrRemoveSessionBoolean(name, add){
function copyToClipboardAndChangeIcon(button) {
// Assuming the input is the previous sibling of the button
let input = button.previousElementSibling;
- let userId = input.getAttribute("user-id")
// Copy input value to clipboard
if (input) {
navigator.clipboard.writeText(input.value).then(function() {
diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js
index 70659b009..7c523a12a 100644
--- a/src/registrar/assets/js/get-gov.js
+++ b/src/registrar/assets/js/get-gov.js
@@ -1220,7 +1220,7 @@ document.addEventListener('DOMContentLoaded', function() {
const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : '';
const expirationDateSortValue = expirationDate ? expirationDate.getTime() : '';
const actionUrl = domain.action_url;
- const suborganization = domain.suborganization ? domain.suborganization : '';
+ const suborganization = domain.domain_info__sub_organization ? domain.domain_info__sub_organization : '⎯';
const row = document.createElement('tr');
@@ -1229,7 +1229,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (!noPortfolioFlag) {
markupForSuborganizationRow = `
- ${suborganization}
+ ${suborganization}
`
}
@@ -1910,7 +1910,7 @@ document.addEventListener('DOMContentLoaded', function() {
let editableFormGroup = button.parentElement.parentElement.parentElement;
if (editableFormGroup){
- let readonlyField = editableFormGroup.querySelector(".input-with-edit-button__readonly-field")
+ let readonlyField = editableFormGroup.querySelector(".toggleable_input__readonly-field")
let inputField = document.getElementById(`id_${fieldName}`);
if (!inputField || !readonlyField) {
return;
@@ -1936,8 +1936,8 @@ document.addEventListener('DOMContentLoaded', function() {
// Keep the path before '#' and replace the part after '#' with 'invalid'
const newHref = parts[0] + '#error';
svg.setAttribute('xlink:href', newHref);
- fullNameField.classList.add("input-with-edit-button__error")
- label = fullNameField.querySelector(".input-with-edit-button__readonly-field")
+ fullNameField.classList.add("toggleable_input__error")
+ label = fullNameField.querySelector(".toggleable_input__readonly-field")
label.innerHTML = "Unknown";
}
}
@@ -2043,11 +2043,11 @@ document.addEventListener('DOMContentLoaded', function() {
// Due to the nature of how uswds works, this is slightly hacky.
// Use a MutationObserver to watch for changes in the dropdown list
- const dropdownList = document.querySelector(`#${input.id}--list`);
+ const dropdownList = comboBox.querySelector(`#${input.id}--list`);
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === "childList") {
- addBlankOption(clearInputButton, dropdownList, initialValue);
+ addBlankOption(clearInputButton, dropdownList, initialValue);
}
});
});
@@ -2111,7 +2111,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (!initialValue){
blankOption.classList.add("usa-combo-box__list-option--selected")
}
- blankOption.textContent = "---------";
+ blankOption.textContent = "⎯";
dropdownList.insertBefore(blankOption, dropdownList.firstChild);
blankOption.addEventListener("click", (e) => {
diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss
index e2377e07c..ef1a810ac 100644
--- a/src/registrar/assets/sass/_theme/_admin.scss
+++ b/src/registrar/assets/sass/_theme/_admin.scss
@@ -126,7 +126,7 @@ html[data-theme="light"] {
body.dashboard,
body.change-list,
body.change-form,
- .analytics {
+ .custom-admin-template, dt {
color: var(--body-fg);
}
.usa-table td {
@@ -155,7 +155,7 @@ html[data-theme="dark"] {
body.dashboard,
body.change-list,
body.change-form,
- .analytics {
+ .custom-admin-template, dt {
color: var(--body-fg);
}
.usa-table td {
@@ -166,7 +166,7 @@ html[data-theme="dark"] {
// Remove when dark mode successfully applies to Django delete page.
.delete-confirmation .content a:not(.button) {
color: color('primary');
- }
+ }
}
@@ -370,14 +370,60 @@ input.admin-confirm-button {
list-style-type: none;
line-height: normal;
}
- .button {
- display: inline-block;
- padding: 10px 8px;
- line-height: normal;
- }
- a.button:active, a.button:focus {
- text-decoration: none;
- }
+}
+
+// This block resolves some of the issues we're seeing on buttons due to css
+// conflicts between DJ and USWDS
+a.button,
+.usa-button--dja {
+ display: inline-block;
+ padding: 10px 15px;
+ font-size: 14px;
+ line-height: 16.1px;
+ font-kerning: auto;
+ font-family: inherit;
+ font-weight: normal;
+}
+.button svg,
+.button span,
+.usa-button--dja svg,
+.usa-button--dja span {
+ vertical-align: middle;
+}
+.usa-button--dja:not(.usa-button--unstyled, .usa-button--outline, .usa-modal__close, .usa-button--secondary) {
+ background: var(--button-bg);
+}
+.usa-button--dja span {
+ font-size: 14px;
+}
+.usa-button--dja:not(.usa-button--unstyled, .usa-button--outline, .usa-modal__close, .usa-button--secondary):hover {
+ background: var(--button-hover-bg);
+}
+a.button:active, a.button:focus {
+ text-decoration: none;
+}
+.usa-modal {
+ font-family: inherit;
+}
+input[type=submit].button--dja-toolbar {
+ border: 1px solid var(--border-color);
+ font-size: 0.8125rem;
+ padding: 4px 8px;
+ margin: 0;
+ vertical-align: middle;
+ background: var(--body-bg);
+ box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset;
+ cursor: pointer;
+ color: var(--body-fg);
+}
+input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-toolbar:hover {
+ border-color: var(--body-quiet-color);
+}
+// Targets the DJA buttom with a nested icon
+button .usa-icon,
+.button .usa-icon,
+.button--clipboard .usa-icon {
+ vertical-align: middle;
}
.module--custom {
@@ -471,13 +517,6 @@ address.dja-address-contact-list {
color: var(--link-fg);
}
-// Targets the DJA buttom with a nested icon
-button .usa-icon,
-.button .usa-icon,
-.button--clipboard .usa-icon {
- vertical-align: middle;
-}
-
.errors span.select2-selection {
border: 1px solid var(--error-fg) !important;
}
@@ -738,7 +777,7 @@ div.dja__model-description{
li {
list-style-type: disc;
- font-family: Source Sans Pro Web,Helvetica Neue,Helvetica,Roboto,Arial,sans-serif;
+ font-family: family('sans');
}
a, a:link, a:visited {
@@ -878,3 +917,16 @@ ul.add-list-reset {
padding: 0 !important;
margin: 0 !important;
}
+
+// Fix the combobox when deployed outside admin (eg user transfer)
+.submit-row .select2,
+.submit-row .select2 span {
+ margin-top: 0;
+}
+.transfer-user-selector .select2-selection__placeholder {
+ color: #3d4551!important;
+}
+
+.dl-dja dt {
+ font-size: 14px;
+}
diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss
index 9f8a0cbb6..e3ab4d538 100644
--- a/src/registrar/assets/sass/_theme/_base.scss
+++ b/src/registrar/assets/sass/_theme/_base.scss
@@ -33,16 +33,19 @@ body {
}
#wrapper.dashboard--portfolio {
- background-color: color('gray-1');
padding-top: units(4)!important;
}
+#wrapper.dashboard--grey-1 {
+ background-color: color('gray-1');
+}
-.section--outlined {
+
+.section-outlined {
background-color: color('white');
border: 1px solid color('base-lighter');
border-radius: 4px;
- padding: 0 units(2) units(3);
+ padding: 0 units(4) units(3) units(2);
margin-top: units(3);
&.margin-top-0 {
@@ -72,9 +75,13 @@ body {
}
}
-.section--outlined__header--no-portfolio {
- .section--outlined__search,
- .section--outlined__utility-button {
+.section-outlined--border-base-light {
+ border: 1px solid color('base-light');
+}
+
+.section-outlined__header--no-portfolio {
+ .section-outlined__search,
+ .section-outlined__utility-button {
margin-top: units(2);
}
@@ -82,11 +89,11 @@ body {
display: flex;
column-gap: units(3);
- .section--outlined__search,
- .section--outlined__utility-button {
+ .section-outlined__search,
+ .section-outlined__utility-button {
margin-top: 0;
}
- .section--outlined__search {
+ .section-outlined__search {
flex-grow: 4;
// Align right
max-width: 383px;
@@ -192,3 +199,7 @@ abbr[title] {
max-width: 50ch;
}
}
+
+.margin-right-neg-4px {
+ margin-right: -4px;
+}
diff --git a/src/registrar/assets/sass/_theme/_buttons.scss b/src/registrar/assets/sass/_theme/_buttons.scss
index d246366d8..12eee9926 100644
--- a/src/registrar/assets/sass/_theme/_buttons.scss
+++ b/src/registrar/assets/sass/_theme/_buttons.scss
@@ -124,10 +124,6 @@ a.withdraw:active {
background-color: color('error-darker');
}
-.usa-button--unstyled .usa-icon {
- vertical-align: bottom;
-}
-
a.usa-button--unstyled:visited {
color: color('primary');
}
@@ -162,14 +158,14 @@ a.usa-button--unstyled:visited {
}
}
-.input-with-edit-button {
+.toggleable_input {
svg.usa-icon {
width: 1.5em !important;
height: 1.5em !important;
color: #{$dhs-green};
position: absolute;
}
- &.input-with-edit-button__error {
+ &.toggleable_input__error {
svg.usa-icon {
color: #{$dhs-red};
}
@@ -205,12 +201,32 @@ a.usa-button--unstyled:visited {
}
}
+.dotgov-table a,
+.usa-link--icon,
+.usa-button--with-icon {
+ display: flex;
+ align-items: flex-start;
+ color: color('primary');
+ column-gap: units(.5);
+ align-items: center;
+}
+
+
+.dotgov-table a,
+.usa-link--icon {
+ &:visited {
+ color: color('primary');
+ }
+}
+
+a .usa-icon,
+.usa-button--with-icon .usa-icon {
+ height: 1.3em;
+ width: 1.3em;
+}
+
.usa-icon.usa-icon--big {
margin: 0;
height: 1.5em;
width: 1.5em;
}
-
-.margin-right-neg-4px {
- margin-right: -4px;
-}
\ No newline at end of file
diff --git a/src/registrar/assets/sass/_theme/_links.scss b/src/registrar/assets/sass/_theme/_links.scss
deleted file mode 100644
index fd1c3dee9..000000000
--- a/src/registrar/assets/sass/_theme/_links.scss
+++ /dev/null
@@ -1,18 +0,0 @@
-@use "uswds-core" as *;
-
-.dotgov-table a,
-.usa-link--icon {
- display: flex;
- align-items: flex-start;
- color: color('primary');
-
- &:visited {
- color: color('primary');
- }
- .usa-icon {
- // align icon with x height
- margin-top: units(0.5);
- margin-right: units(0.5);
- }
-}
-
diff --git a/src/registrar/assets/sass/_theme/_tables.scss b/src/registrar/assets/sass/_theme/_tables.scss
index e78715da8..d57b51117 100644
--- a/src/registrar/assets/sass/_theme/_tables.scss
+++ b/src/registrar/assets/sass/_theme/_tables.scss
@@ -1,5 +1,10 @@
@use "uswds-core" as *;
+td,
+th {
+ vertical-align: top;
+}
+
.dotgov-table--stacked {
td, th {
padding: units(1) units(2) units(2px) 0;
@@ -12,7 +17,7 @@
tr {
border-bottom: none;
- border-top: 2px solid color('base-light');
+ border-top: 2px solid color('base-lighter');
margin-top: units(2);
&:first-child {
@@ -39,10 +44,6 @@
.dotgov-table {
width: 100%;
- th[data-sortable]:not([aria-sort]) .usa-table__header__button {
- right: auto;
- }
-
tbody th {
word-break: break-word;
}
@@ -56,7 +57,7 @@
}
td, th {
- border-bottom: 1px solid color('base-light');
+ border-bottom: 1px solid color('base-lighter');
}
thead th {
@@ -72,11 +73,17 @@
td, th,
.usa-tabel th{
- padding: units(2) units(2) units(2) 0;
+ padding: units(2) units(4) units(2) 0;
}
thead tr:first-child th:first-child {
border-top: none;
}
}
+
+ @include at-media(tablet-lg) {
+ th[data-sortable]:not([aria-sort]) .usa-table__header__button {
+ right: auto;
+ }
+ }
}
diff --git a/src/registrar/assets/sass/_theme/styles.scss b/src/registrar/assets/sass/_theme/styles.scss
index f9df015b4..5616b7509 100644
--- a/src/registrar/assets/sass/_theme/styles.scss
+++ b/src/registrar/assets/sass/_theme/styles.scss
@@ -10,7 +10,6 @@
--- Custom Styles ---------------------------------*/
@forward "base";
@forward "typography";
-@forward "links";
@forward "lists";
@forward "accordions";
@forward "buttons";
diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py
index 73aecad7a..7965424bc 100644
--- a/src/registrar/config/settings.py
+++ b/src/registrar/config/settings.py
@@ -357,13 +357,18 @@ CSP_FORM_ACTION = allowed_sources
# and inline with a nonce, as well as allowing connections back to their domain.
# Note: If needed, we can embed chart.js instead of using the CDN
CSP_DEFAULT_SRC = ("'self'",)
-CSP_STYLE_SRC = ["'self'", "https://www.ssa.gov/accessibility/andi/andi.css"]
+CSP_STYLE_SRC = [
+ "'self'",
+ "https://www.ssa.gov/accessibility/andi/andi.css",
+ "https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css",
+]
CSP_SCRIPT_SRC_ELEM = [
"'self'",
"https://www.googletagmanager.com/",
"https://cdn.jsdelivr.net/npm/chart.js",
"https://www.ssa.gov",
"https://ajax.googleapis.com",
+ "https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js",
]
CSP_CONNECT_SRC = ["'self'", "https://www.google-analytics.com/", "https://www.ssa.gov/accessibility/andi/andi.js"]
CSP_INCLUDE_NONCE_IN = ["script-src-elem", "style-src"]
diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py
index 19fa99809..17be3c2bb 100644
--- a/src/registrar/config/urls.py
+++ b/src/registrar/config/urls.py
@@ -24,6 +24,7 @@ from registrar.views.report_views import (
from registrar.views.domain_request import Step
from registrar.views.domain_requests_json import get_domain_requests_json
+from registrar.views.transfer_user import TransferUserView
from registrar.views.utility.api_views import (
get_senior_official_from_federal_agency_json,
get_federal_and_portfolio_types_from_federal_agency_json,
@@ -137,6 +138,7 @@ urlpatterns = [
AnalyticsView.as_view(),
name="analytics",
),
+ path("admin/registrar/user//transfer/", TransferUserView.as_view(), name="transfer_user"),
path(
"admin/api/get-senior-official-from-federal-agency-json/",
get_senior_official_from_federal_agency_json,
diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py
index ea04dca80..2ac22b2e0 100644
--- a/src/registrar/context_processors.py
+++ b/src/registrar/context_processors.py
@@ -60,6 +60,17 @@ def add_has_profile_feature_flag_to_context(request):
def portfolio_permissions(request):
"""Make portfolio permissions for the request user available in global context"""
+ context = {
+ "has_base_portfolio_permission": False,
+ "has_domains_portfolio_permission": False,
+ "has_domain_requests_portfolio_permission": False,
+ "has_view_members_portfolio_permission": False,
+ "has_edit_members_portfolio_permission": False,
+ "has_view_suborganization": False,
+ "has_edit_suborganization": False,
+ "portfolio": None,
+ "has_organization_feature_flag": False,
+ }
try:
portfolio = request.session.get("portfolio")
if portfolio:
@@ -69,29 +80,15 @@ def portfolio_permissions(request):
"has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission(
portfolio
),
+ "has_view_members_portfolio_permission": request.user.has_view_members_portfolio_permission(portfolio),
+ "has_edit_members_portfolio_permission": request.user.has_edit_members_portfolio_permission(portfolio),
"has_view_suborganization": request.user.has_view_suborganization(portfolio),
"has_edit_suborganization": request.user.has_edit_suborganization(portfolio),
"portfolio": portfolio,
"has_organization_feature_flag": True,
}
- return {
- "has_base_portfolio_permission": False,
- "has_domains_portfolio_permission": False,
- "has_domain_requests_portfolio_permission": False,
- "has_view_suborganization": False,
- "has_edit_suborganization": False,
- "portfolio": None,
- "has_organization_feature_flag": False,
- }
+ return context
except AttributeError:
# Handles cases where request.user might not exist
- return {
- "has_base_portfolio_permission": False,
- "has_domains_portfolio_permission": False,
- "has_domain_requests_portfolio_permission": False,
- "has_view_suborganization": False,
- "has_edit_suborganization": False,
- "portfolio": None,
- "has_organization_feature_flag": False,
- }
+ return context
diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py
index a7a006788..84fcbe973 100644
--- a/src/registrar/forms/domain.py
+++ b/src/registrar/forms/domain.py
@@ -417,7 +417,7 @@ class SeniorOfficialContactForm(ContactForm):
# This action should be blocked by the UI, as the text fields are readonly.
# If they get past this point, we forbid it this way.
# This could be malicious, so lets reserve information for the backend only.
- raise ValueError("Senior Official cannot be modified for federal or tribal domains.")
+ raise ValueError("Senior official cannot be modified for federal or tribal domains.")
elif db_so.has_more_than_one_join("information_senior_official"):
# Handle the case where the domain information object is available and the SO Contact
# has more than one joined object.
diff --git a/src/registrar/management/commands/clean_tables.py b/src/registrar/management/commands/clean_tables.py
index 5d4439d95..66b3e772f 100644
--- a/src/registrar/management/commands/clean_tables.py
+++ b/src/registrar/management/commands/clean_tables.py
@@ -21,7 +21,7 @@ class Command(BaseCommand):
TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True,
- info_to_inspect="""
+ prompt_message="""
This script will delete all rows from the following tables:
* Contact
* Domain
diff --git a/src/registrar/management/commands/extend_expiration_dates.py b/src/registrar/management/commands/extend_expiration_dates.py
index cefc38b9e..ac083da1d 100644
--- a/src/registrar/management/commands/extend_expiration_dates.py
+++ b/src/registrar/management/commands/extend_expiration_dates.py
@@ -130,7 +130,7 @@ class Command(BaseCommand):
"""Asks if the user wants to proceed with this action"""
TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True,
- info_to_inspect=f"""
+ prompt_message=f"""
==Extension Amount==
Period: {extension_amount} year(s)
diff --git a/src/registrar/management/commands/load_organization_data.py b/src/registrar/management/commands/load_organization_data.py
index 122795400..35cc248ee 100644
--- a/src/registrar/management/commands/load_organization_data.py
+++ b/src/registrar/management/commands/load_organization_data.py
@@ -64,7 +64,7 @@ class Command(BaseCommand):
# Will sys.exit() when prompt is "n"
TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True,
- info_to_inspect=f"""
+ prompt_message=f"""
==Master data file==
domain_additional_filename: {org_args.domain_additional_filename}
@@ -84,7 +84,7 @@ class Command(BaseCommand):
# Will sys.exit() when prompt is "n"
TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True,
- info_to_inspect=f"""
+ prompt_message=f"""
==Master data file==
domain_additional_filename: {org_args.domain_additional_filename}
diff --git a/src/registrar/management/commands/load_senior_official_table.py b/src/registrar/management/commands/load_senior_official_table.py
index 43f61d57a..cdbc607bf 100644
--- a/src/registrar/management/commands/load_senior_official_table.py
+++ b/src/registrar/management/commands/load_senior_official_table.py
@@ -27,7 +27,7 @@ class Command(BaseCommand):
TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True,
- info_to_inspect=f"""
+ prompt_message=f"""
==Proposed Changes==
CSV: {federal_cio_csv_path}
diff --git a/src/registrar/management/commands/load_transition_domain.py b/src/registrar/management/commands/load_transition_domain.py
index 4132096c8..c2dd66f55 100644
--- a/src/registrar/management/commands/load_transition_domain.py
+++ b/src/registrar/management/commands/load_transition_domain.py
@@ -651,7 +651,7 @@ class Command(BaseCommand):
title = "Do you wish to load additional data for TransitionDomains?"
proceed = TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True,
- info_to_inspect=f"""
+ prompt_message=f"""
!!! ENSURE THAT ALL FILENAMES ARE CORRECT BEFORE PROCEEDING
==Master data file==
domain_additional_filename: {domain_additional_filename}
diff --git a/src/registrar/management/commands/patch_federal_agency_info.py b/src/registrar/management/commands/patch_federal_agency_info.py
index b286f1516..51a98ffaa 100644
--- a/src/registrar/management/commands/patch_federal_agency_info.py
+++ b/src/registrar/management/commands/patch_federal_agency_info.py
@@ -91,7 +91,7 @@ class Command(BaseCommand):
# Code execution will stop here if the user prompts "N"
TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True,
- info_to_inspect=f"""
+ prompt_message=f"""
==Proposed Changes==
Number of DomainInformation objects to change: {len(human_readable_domain_names)}
The following DomainInformation objects will be modified: {human_readable_domain_names}
@@ -148,7 +148,7 @@ class Command(BaseCommand):
# Code execution will stop here if the user prompts "N"
TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True,
- info_to_inspect=f"""
+ prompt_message=f"""
==File location==
current-full.csv filepath: {file_path}
diff --git a/src/registrar/management/commands/populate_first_ready.py b/src/registrar/management/commands/populate_first_ready.py
index 9636476c2..04468029a 100644
--- a/src/registrar/management/commands/populate_first_ready.py
+++ b/src/registrar/management/commands/populate_first_ready.py
@@ -31,7 +31,7 @@ class Command(BaseCommand):
# Code execution will stop here if the user prompts "N"
TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True,
- info_to_inspect=f"""
+ prompt_message=f"""
==Proposed Changes==
Number of Domain objects to change: {len(domains)}
""",
diff --git a/src/registrar/management/commands/populate_organization_type.py b/src/registrar/management/commands/populate_organization_type.py
index a7dd98b24..60d179cb8 100644
--- a/src/registrar/management/commands/populate_organization_type.py
+++ b/src/registrar/management/commands/populate_organization_type.py
@@ -54,7 +54,7 @@ class Command(BaseCommand):
# Code execution will stop here if the user prompts "N"
TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True,
- info_to_inspect=f"""
+ prompt_message=f"""
==Proposed Changes==
Number of DomainRequest objects to change: {len(domain_requests)}
@@ -72,7 +72,7 @@ class Command(BaseCommand):
# Code execution will stop here if the user prompts "N"
TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True,
- info_to_inspect=f"""
+ prompt_message=f"""
==Proposed Changes==
Number of DomainInformation objects to change: {len(domain_infos)}
diff --git a/src/registrar/management/commands/update_first_ready.py b/src/registrar/management/commands/update_first_ready.py
new file mode 100644
index 000000000..0a4ea10a7
--- /dev/null
+++ b/src/registrar/management/commands/update_first_ready.py
@@ -0,0 +1,38 @@
+import logging
+from django.core.management import BaseCommand
+from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors
+from registrar.models import Domain, TransitionDomain
+
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand, PopulateScriptTemplate):
+ help = "Loops through each domain object and populates the last_status_update and first_submitted_date"
+
+ def handle(self, **kwargs):
+ """Loops through each valid Domain object and updates it's first_ready value if it is out of sync"""
+ filter_conditions = {"state__in": [Domain.State.READY, Domain.State.ON_HOLD, Domain.State.DELETED]}
+ self.mass_update_records(Domain, filter_conditions, ["first_ready"], verbose=True)
+
+ def update_record(self, record: Domain):
+ """Defines how we update the first_ready field"""
+ # update the first_ready value based on the creation date.
+ record.first_ready = record.created_at.date()
+
+ logger.info(
+ f"{TerminalColors.OKCYAN}Updating {record} => first_ready: " f"{record.first_ready}{TerminalColors.ENDC}"
+ )
+
+ # check if a transition domain object for this domain name exists,
+ # or if so whether its first_ready value matches its created_at date
+ def custom_filter(self, records):
+ to_include_pks = []
+ for record in records:
+ if (
+ TransitionDomain.objects.filter(domain_name=record.name).exists()
+ and record.first_ready != record.created_at.date()
+ ): # noqa
+ to_include_pks.append(record.pk)
+
+ return records.filter(pk__in=to_include_pks)
diff --git a/src/registrar/management/commands/utility/terminal_helper.py b/src/registrar/management/commands/utility/terminal_helper.py
index b9e11be5d..fa7cde683 100644
--- a/src/registrar/management/commands/utility/terminal_helper.py
+++ b/src/registrar/management/commands/utility/terminal_helper.py
@@ -2,9 +2,12 @@ import logging
import sys
from abc import ABC, abstractmethod
from django.core.paginator import Paginator
+from django.db.models import Model
+from django.db.models.manager import BaseManager
from typing import List
from registrar.utility.enums import LogCode
+
logger = logging.getLogger(__name__)
@@ -76,27 +79,60 @@ class PopulateScriptTemplate(ABC):
@abstractmethod
def update_record(self, record):
- """Defines how we update each field. Must be defined before using mass_update_records."""
+ """Defines how we update each field.
+
+ raises:
+ NotImplementedError: If not defined before calling mass_update_records.
+ """
raise NotImplementedError
- def mass_update_records(self, object_class, filter_conditions, fields_to_update, debug=True):
+ def mass_update_records(self, object_class, filter_conditions, fields_to_update, debug=True, verbose=False):
"""Loops through each valid "object_class" object - specified by filter_conditions - and
updates fields defined by fields_to_update using update_record.
- You must define update_record before you can use this function.
+ Parameters:
+ object_class: The Django model class that you want to perform the bulk update on.
+ This should be the actual class, not a string of the class name.
+
+ filter_conditions: dictionary of valid Django Queryset filter conditions
+ (e.g. {'verification_type__isnull'=True}).
+
+ fields_to_update: List of strings specifying which fields to update.
+ (e.g. ["first_ready_date", "last_submitted_date"])
+
+ debug: Whether to log script run summary in debug mode.
+ Default: True.
+
+ verbose: Whether to print a detailed run summary *before* run confirmation.
+ Default: False.
+
+ Raises:
+ NotImplementedError: If you do not define update_record before using this function.
+ TypeError: If custom_filter is not Callable.
"""
records = object_class.objects.filter(**filter_conditions) if filter_conditions else object_class.objects.all()
+
+ # apply custom filter
+ records = self.custom_filter(records)
+
readable_class_name = self.get_class_name(object_class)
+ # for use in the execution prompt.
+ proposed_changes = f"""==Proposed Changes==
+ Number of {readable_class_name} objects to change: {len(records)}
+ These fields will be updated on each record: {fields_to_update}
+ """
+
+ if verbose:
+ proposed_changes = f"""{proposed_changes}
+ These records will be updated: {list(records.all())}
+ """
+
# Code execution will stop here if the user prompts "N"
TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True,
- info_to_inspect=f"""
- ==Proposed Changes==
- Number of {readable_class_name} objects to change: {len(records)}
- These fields will be updated on each record: {fields_to_update}
- """,
+ prompt_message=proposed_changes,
prompt_title=self.prompt_title,
)
logger.info("Updating...")
@@ -141,10 +177,17 @@ class PopulateScriptTemplate(ABC):
return f"{TerminalColors.FAIL}" f"Failed to update {record}" f"{TerminalColors.ENDC}"
def should_skip_record(self, record) -> bool: # noqa
- """Defines the condition in which we should skip updating a record. Override as needed."""
+ """Defines the condition in which we should skip updating a record. Override as needed.
+ The difference between this and custom_filter is that records matching these conditions
+ *will* be included in the run but will be skipped (and logged as such)."""
# By default - don't skip
return False
+ def custom_filter(self, records: BaseManager[Model]) -> BaseManager[Model]:
+ """Override to define filters that can't be represented by django queryset field lookups.
+ Applied to individual records *after* filter_conditions. True means"""
+ return records
+
class TerminalHelper:
@staticmethod
@@ -220,6 +263,9 @@ class TerminalHelper:
an answer is required of the user).
The "answer" return value is True for "yes" or False for "no".
+
+ Raises:
+ ValueError: When "default" is not "yes", "no", or None.
"""
valid = {"yes": True, "y": True, "ye": True, "no": False, "n": False}
if default is None:
@@ -244,6 +290,7 @@ class TerminalHelper:
@staticmethod
def query_yes_no_exit(question: str, default="yes"):
"""Ask a yes/no question via raw_input() and return their answer.
+ Allows for answer "e" to exit.
"question" is a string that is presented to the user.
"default" is the presumed answer if the user just hits .
@@ -251,6 +298,9 @@ class TerminalHelper:
an answer is required of the user).
The "answer" return value is True for "yes" or False for "no".
+
+ Raises:
+ ValueError: When "default" is not "yes", "no", or None.
"""
valid = {
"yes": True,
@@ -317,9 +367,8 @@ class TerminalHelper:
case _:
logger.info(print_statement)
- # TODO - "info_to_inspect" should be refactored to "prompt_message"
@staticmethod
- def prompt_for_execution(system_exit_on_terminate: bool, info_to_inspect: str, prompt_title: str) -> bool:
+ def prompt_for_execution(system_exit_on_terminate: bool, prompt_message: str, prompt_title: str) -> bool:
"""Create to reduce code complexity.
Prompts the user to inspect the given string
and asks if they wish to proceed.
@@ -340,7 +389,7 @@ class TerminalHelper:
=====================================================
*** IMPORTANT: VERIFY THE FOLLOWING LOOKS CORRECT ***
- {info_to_inspect}
+ {prompt_message}
{TerminalColors.FAIL}
Proceed? (Y = proceed, N = {action_description_for_selecting_no})
{TerminalColors.ENDC}"""
diff --git a/src/registrar/migrations/0123_alter_portfolioinvitation_portfolio_additional_permissions_and_more.py b/src/registrar/migrations/0123_alter_portfolioinvitation_portfolio_additional_permissions_and_more.py
new file mode 100644
index 000000000..c14a70ab0
--- /dev/null
+++ b/src/registrar/migrations/0123_alter_portfolioinvitation_portfolio_additional_permissions_and_more.py
@@ -0,0 +1,66 @@
+# Generated by Django 4.2.10 on 2024-09-04 21:29
+
+import django.contrib.postgres.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("registrar", "0122_create_groups_v16"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="portfolioinvitation",
+ name="portfolio_additional_permissions",
+ field=django.contrib.postgres.fields.ArrayField(
+ base_field=models.CharField(
+ choices=[
+ ("view_all_domains", "View all domains and domain reports"),
+ ("view_managed_domains", "View managed domains"),
+ ("view_members", "View members"),
+ ("edit_members", "Create and edit members"),
+ ("view_all_requests", "View all requests"),
+ ("view_created_requests", "View created requests"),
+ ("edit_requests", "Create and edit requests"),
+ ("view_portfolio", "View organization"),
+ ("edit_portfolio", "Edit organization"),
+ ("view_suborganization", "View suborganization"),
+ ("edit_suborganization", "Edit suborganization"),
+ ],
+ max_length=50,
+ ),
+ blank=True,
+ help_text="Select one or more additional permissions.",
+ null=True,
+ size=None,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userportfoliopermission",
+ name="additional_permissions",
+ field=django.contrib.postgres.fields.ArrayField(
+ base_field=models.CharField(
+ choices=[
+ ("view_all_domains", "View all domains and domain reports"),
+ ("view_managed_domains", "View managed domains"),
+ ("view_members", "View members"),
+ ("edit_members", "Create and edit members"),
+ ("view_all_requests", "View all requests"),
+ ("view_created_requests", "View created requests"),
+ ("edit_requests", "Create and edit requests"),
+ ("view_portfolio", "View organization"),
+ ("edit_portfolio", "Edit organization"),
+ ("view_suborganization", "View suborganization"),
+ ("edit_suborganization", "Edit suborganization"),
+ ],
+ max_length=50,
+ ),
+ blank=True,
+ help_text="Select one or more additional permissions.",
+ null=True,
+ size=None,
+ ),
+ ),
+ ]
diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py
index bf1c3e566..0c2487df3 100644
--- a/src/registrar/models/user_portfolio_permission.py
+++ b/src/registrar/models/user_portfolio_permission.py
@@ -16,8 +16,8 @@ class UserPortfolioPermission(TimeStampedModel):
PORTFOLIO_ROLE_PERMISSIONS = {
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
- UserPortfolioPermissionChoices.VIEW_MEMBER,
- UserPortfolioPermissionChoices.EDIT_MEMBER,
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ UserPortfolioPermissionChoices.EDIT_MEMBERS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
@@ -28,7 +28,7 @@ class UserPortfolioPermission(TimeStampedModel):
],
UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
- UserPortfolioPermissionChoices.VIEW_MEMBER,
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
# Domain: field specific permissions
diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py
index 86aaa5e16..7afd32603 100644
--- a/src/registrar/models/utility/portfolio_helper.py
+++ b/src/registrar/models/utility/portfolio_helper.py
@@ -17,8 +17,8 @@ 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_MEMBER = "view_member", "View members"
- EDIT_MEMBER = "edit_member", "Create and edit members"
+ VIEW_MEMBERS = "view_members", "View members"
+ EDIT_MEMBERS = "edit_members", "Create and edit members"
VIEW_ALL_REQUESTS = "view_all_requests", "View all requests"
VIEW_CREATED_REQUESTS = "view_created_requests", "View created requests"
diff --git a/src/registrar/templates/admin/analytics.html b/src/registrar/templates/admin/analytics.html
index 13db3b60a..7c1a09c78 100644
--- a/src/registrar/templates/admin/analytics.html
+++ b/src/registrar/templates/admin/analytics.html
@@ -5,7 +5,7 @@
{% block content %}
-
diff --git a/src/registrar/templates/django/admin/portfolio_change_form.html b/src/registrar/templates/django/admin/portfolio_change_form.html
index 6c7aca0ea..8dae8a080 100644
--- a/src/registrar/templates/django/admin/portfolio_change_form.html
+++ b/src/registrar/templates/django/admin/portfolio_change_form.html
@@ -17,8 +17,7 @@
This is a placeholder for now.
Disclaimer:
- When extending the fieldset view - *make a new one* that extends from detail_table_fieldset.
- For instance, "portfolio_fieldset.html".
+ When extending the fieldset view consider whether you need to make a new one that extends from detail_table_fieldset.
detail_table_fieldset is used on multiple admin pages, so a change there can have unintended consequences.
{% endcomment %}
{% include "django/admin/includes/detail_table_fieldset.html" with original_object=original %}
diff --git a/src/registrar/templates/django/admin/user_change_form.html b/src/registrar/templates/django/admin/user_change_form.html
index b545bed23..736f12ba4 100644
--- a/src/registrar/templates/django/admin/user_change_form.html
+++ b/src/registrar/templates/django/admin/user_change_form.html
@@ -1,6 +1,21 @@
{% extends 'django/admin/email_clipboard_change_form.html' %}
{% load i18n static %}
+
+{% block field_sets %}
+
+
+ {% for fieldset in adminform %}
+ {% include "django/admin/includes/domain_fieldset.html" with state_help_message=state_help_message %}
+ {% endfor %}
+{% endblock %}
+
{% block after_related_objects %}
{% if portfolios %}
diff --git a/src/registrar/templates/django/forms/widgets/combobox.html b/src/registrar/templates/django/forms/widgets/combobox.html
index 107c2e14e..7ff31945b 100644
--- a/src/registrar/templates/django/forms/widgets/combobox.html
+++ b/src/registrar/templates/django/forms/widgets/combobox.html
@@ -7,7 +7,9 @@ for now we just carry the attribute to both the parent element and the select.
{% include "django/forms/widgets/select.html" %}
diff --git a/src/registrar/templates/domain_dsdata.html b/src/registrar/templates/domain_dsdata.html
index b62ad7ec5..1dd1e1abe 100644
--- a/src/registrar/templates/domain_dsdata.html
+++ b/src/registrar/templates/domain_dsdata.html
@@ -63,10 +63,10 @@
-
+ Delete
+ Delete
@@ -74,10 +74,10 @@
{% endfor %}
-
+ Add new record
+ Add new record
-
+ Delete
+ Delete
{% endfor %}
-
+ Add another name server
+ Add another name server
{% comment %} Work around USWDS' button margins to add some spacing between the submit and the 'add more'
diff --git a/src/registrar/templates/domain_org_name_address.html b/src/registrar/templates/domain_org_name_address.html
index 1e6176aa0..a7eb02b59 100644
--- a/src/registrar/templates/domain_org_name_address.html
+++ b/src/registrar/templates/domain_org_name_address.html
@@ -42,7 +42,7 @@
{% input_with_errors form.state_territory %}
- {% with add_class="usa-input--small" %}
+ {% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
{% input_with_errors form.zipcode %}
{% endwith %}
diff --git a/src/registrar/templates/domain_request_org_contact.html b/src/registrar/templates/domain_request_org_contact.html
index f145ee3bf..d4f3c2071 100644
--- a/src/registrar/templates/domain_request_org_contact.html
+++ b/src/registrar/templates/domain_request_org_contact.html
@@ -33,7 +33,7 @@
{% input_with_errors forms.0.state_territory %}
- {% with add_class="usa-input--small" %}
+ {% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
{% input_with_errors forms.0.zipcode %}
{% endwith %}
diff --git a/src/registrar/templates/domain_suborganization.html b/src/registrar/templates/domain_suborganization.html
index ad96f1d65..823629213 100644
--- a/src/registrar/templates/domain_suborganization.html
+++ b/src/registrar/templates/domain_suborganization.html
@@ -1,7 +1,7 @@
{% extends "domain_base.html" %}
{% load static field_helpers%}
-{% block title %}Suborganization{% endblock %}
+{% block title %}Suborganization{% if suborganization_name %} | suborganization_name{% endif %} | {% endblock %}
{% block domain_content %}
{# this is right after the messages block in the parent template #}
diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html
index 8e4f04fcd..412f4ee73 100644
--- a/src/registrar/templates/domain_users.html
+++ b/src/registrar/templates/domain_users.html
@@ -21,7 +21,7 @@
{% if domain.permissions %}
-
+
Domain managers
Domain managers
@@ -112,7 +112,7 @@
{% if domain.invitations.exists %}
-
+
Invitations
Domain invitations
diff --git a/src/registrar/templates/includes/domain_requests_table.html b/src/registrar/templates/includes/domain_requests_table.html
index bd909350c..f73f8079f 100644
--- a/src/registrar/templates/includes/domain_requests_table.html
+++ b/src/registrar/templates/includes/domain_requests_table.html
@@ -3,7 +3,7 @@
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get_domain_requests_json' as url %}
{{url}}
-
+
{% if not has_domain_requests_portfolio_permission %}
diff --git a/src/registrar/templates/includes/domains_table.html b/src/registrar/templates/includes/domains_table.html
index 48de2d98c..62e9295dd 100644
--- a/src/registrar/templates/includes/domains_table.html
+++ b/src/registrar/templates/includes/domains_table.html
@@ -5,8 +5,8 @@
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get_domains_json' as url %}
{{url}}
-
-
+
+
{% if not portfolio %}
Domains
@@ -14,7 +14,7 @@
{% endif %}
-
+
{% if user_domain_count and user_domain_count > 0 %}
-