resolved merge

This commit is contained in:
CocoByte 2024-09-20 13:01:30 -06:00
commit 59c3a1e535
No known key found for this signature in database
GPG key ID: BBFAA2526384C97F
46 changed files with 1759 additions and 1134 deletions

View file

@ -9,10 +9,10 @@ We use [django-waffle](https://waffle.readthedocs.io/en/stable/) for our feature
3. Click `Add waffle flag`.
4. Add the model as you would normally. Refer to waffle's documentation [regarding attributes](https://waffle.readthedocs.io/en/stable/types/flag.html#flag-attributes) for more information on them.
### Enabling the profile_feature flag
### Enabling a feature flag
1. On the app, navigate to `\admin`.
2. Under models, click `Waffle flags`.
3. Click the `profile_feature` record. This should exist by default, if not - create one with that name.
3. Click the featue flag record. This should exist by default, if not - create one with that name.
4. (Important) Set the field `Everyone` to `Unknown`. This field overrides all other settings when set to anything else.
5. Configure the settings as you see fit.

View file

@ -126,7 +126,15 @@ class AvailableAPITest(MockEppLib):
def setUp(self):
super().setUp()
self.user = get_user_model().objects.create(username="username")
username = "test_user"
first_name = "First"
last_name = "Last"
email = "info@example.com"
title = "title"
phone = "8080102431"
self.user = get_user_model().objects.create(
username=username, title=title, first_name=first_name, last_name=last_name, email=email, phone=phone
)
def test_available_get(self):
self.client.force_login(self.user)

View file

@ -1977,11 +1977,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
so we should display that information using this function.
"""
if hasattr(obj, "creator"):
recipient = obj.creator
else:
recipient = None
# Displays a warning in admin when an email cannot be sent
if recipient and recipient.email:
@ -2186,7 +2182,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
extra_context["filtered_audit_log_entries"] = filtered_audit_log_entries
emails = self.get_all_action_needed_reason_emails(obj)
extra_context["action_needed_reason_emails"] = json.dumps(emails)
extra_context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
# Denote if an action needed email was sent or not
email_sent = request.session.get("action_needed_email_sent", False)

View file

@ -33,6 +33,14 @@ const showElement = (element) => {
element.classList.remove('display-none');
};
/**
* Helper function to get the CSRF token from the cookie
*
*/
function getCsrfToken() {
return document.querySelector('input[name="csrfmiddlewaretoken"]').value;
}
/**
* Helper function that scrolls to an element
* @param {string} attributeName - The string "class" or "id"
@ -994,40 +1002,79 @@ function unloadModals() {
window.modal.off();
}
class LoadTableBase {
constructor(tableSelector, tableWrapperSelector, searchFieldId, searchSubmitId, resetSearchBtn, resetFiltersBtn, noDataDisplay, noSearchresultsDisplay) {
this.tableWrapper = document.querySelector(tableWrapperSelector);
this.tableHeaders = document.querySelectorAll(`${tableSelector} th[data-sortable]`);
this.currentSortBy = 'id';
this.currentOrder = 'asc';
this.currentStatus = [];
this.currentSearchTerm = '';
this.scrollToTable = false;
this.searchInput = document.querySelector(searchFieldId);
this.searchSubmit = document.querySelector(searchSubmitId);
this.tableAnnouncementRegion = document.querySelector(`${tableWrapperSelector} .usa-table__announcement-region`);
this.resetSearchButton = document.querySelector(resetSearchBtn);
this.resetFiltersButton = document.querySelector(resetFiltersBtn);
// NOTE: these 3 can't be used if filters are active on a page with more than 1 table
this.statusCheckboxes = document.querySelectorAll('input[name="filter-status"]');
this.statusIndicator = document.querySelector('.filter-indicator');
this.statusToggle = document.querySelector('.usa-button--filter');
this.noTableWrapper = document.querySelector(noDataDisplay);
this.noSearchResultsWrapper = document.querySelector(noSearchresultsDisplay);
this.portfolioElement = document.getElementById('portfolio-js-value');
this.portfolioValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-portfolio') : null;
this.initializeTableHeaders();
this.initializeSearchHandler();
this.initializeStatusToggleHandler();
this.initializeFilterCheckboxes();
this.initializeResetSearchButton();
this.initializeResetFiltersButton();
this.initializeAccordionAccessibilityListeners();
}
/**
* Generalized function to update pagination for a list.
* @param {string} itemName - The name displayed in the counter
* @param {string} paginationSelector - CSS selector for the pagination container.
* @param {string} counterSelector - CSS selector for the pagination counter.
* @param {string} linkAnchor - CSS selector for the header element to anchor the links to.
* @param {Function} loadPageFunction - Function to call when a page link is clicked.
* @param {string} tableSelector - CSS selector for the header element to anchor the links to.
* @param {number} currentPage - The current page number (starting with 1).
* @param {number} numPages - The total number of pages.
* @param {boolean} hasPrevious - Whether there is a page before the current page.
* @param {boolean} hasNext - Whether there is a page after the current page.
* @param {number} totalItems - The total number of items.
* @param {string} searchTerm - The search term
* @param {number} total - The total number of items.
*/
function updatePagination(itemName, paginationSelector, counterSelector, linkAnchor, loadPageFunction, currentPage, numPages, hasPrevious, hasNext, totalItems, searchTerm) {
const paginationContainer = document.querySelector(paginationSelector);
const paginationCounter = document.querySelector(counterSelector);
updatePagination(
itemName,
paginationSelector,
counterSelector,
parentTableSelector,
currentPage,
numPages,
hasPrevious,
hasNext,
totalItems,
) {
const paginationButtons = document.querySelector(`${paginationSelector} .usa-pagination__list`);
paginationCounter.innerHTML = '';
const counterSelectorEl = document.querySelector(counterSelector);
const paginationSelectorEl = document.querySelector(paginationSelector);
counterSelectorEl.innerHTML = '';
paginationButtons.innerHTML = '';
// Buttons should only be displayed if there are more than one pages of results
paginationButtons.classList.toggle('display-none', numPages <= 1);
// Counter should only be displayed if there is more than 1 item
paginationContainer.classList.toggle('display-none', totalItems < 1);
paginationSelectorEl.classList.toggle('display-none', totalItems < 1);
paginationCounter.innerHTML = `${totalItems} ${itemName}${totalItems > 1 ? 's' : ''}${searchTerm ? ' for ' + '"' + searchTerm + '"' : ''}`;
counterSelectorEl.innerHTML = `${totalItems} ${itemName}${totalItems > 1 ? 's' : ''}${this.currentSearchTerm ? ' for ' + '"' + this.currentSearchTerm + '"' : ''}`;
if (hasPrevious) {
const prevPageItem = document.createElement('li');
prevPageItem.className = 'usa-pagination__item usa-pagination__arrow';
prevPageItem.innerHTML = `
<a href="${linkAnchor}" class="usa-pagination__link usa-pagination__previous-page" aria-label="Previous page">
<a href="${parentTableSelector}" class="usa-pagination__link usa-pagination__previous-page" aria-label="Previous page">
<svg class="usa-icon" aria-hidden="true" role="img">
<use xlink:href="/public/img/sprite.svg#navigate_before"></use>
</svg>
@ -1036,32 +1083,14 @@ function updatePagination(itemName, paginationSelector, counterSelector, linkAnc
`;
prevPageItem.querySelector('a').addEventListener('click', (event) => {
event.preventDefault();
loadPageFunction(currentPage - 1);
this.loadTable(currentPage - 1);
});
paginationButtons.appendChild(prevPageItem);
}
// Helper function to create a page item
function createPageItem(page) {
const pageItem = document.createElement('li');
pageItem.className = 'usa-pagination__item usa-pagination__page-no';
pageItem.innerHTML = `
<a href="${linkAnchor}" class="usa-pagination__button" aria-label="Page ${page}">${page}</a>
`;
if (page === currentPage) {
pageItem.querySelector('a').classList.add('usa-current');
pageItem.querySelector('a').setAttribute('aria-current', 'page');
}
pageItem.querySelector('a').addEventListener('click', (event) => {
event.preventDefault();
loadPageFunction(page);
});
return pageItem;
}
// Add first page and ellipsis if necessary
if (currentPage > 2) {
paginationButtons.appendChild(createPageItem(1));
paginationButtons.appendChild(this.createPageItem(1, parentTableSelector, currentPage));
if (currentPage > 3) {
const ellipsis = document.createElement('li');
ellipsis.className = 'usa-pagination__item usa-pagination__overflow';
@ -1073,7 +1102,7 @@ function updatePagination(itemName, paginationSelector, counterSelector, linkAnc
// Add pages around the current page
for (let i = Math.max(1, currentPage - 1); i <= Math.min(numPages, currentPage + 1); i++) {
paginationButtons.appendChild(createPageItem(i));
paginationButtons.appendChild(this.createPageItem(i, parentTableSelector, currentPage));
}
// Add last page and ellipsis if necessary
@ -1085,14 +1114,14 @@ function updatePagination(itemName, paginationSelector, counterSelector, linkAnc
ellipsis.innerHTML = '<span>…</span>';
paginationButtons.appendChild(ellipsis);
}
paginationButtons.appendChild(createPageItem(numPages));
paginationButtons.appendChild(this.createPageItem(numPages, parentTableSelector, currentPage));
}
if (hasNext) {
const nextPageItem = document.createElement('li');
nextPageItem.className = 'usa-pagination__item usa-pagination__arrow';
nextPageItem.innerHTML = `
<a href="${linkAnchor}" class="usa-pagination__link usa-pagination__next-page" aria-label="Next page">
<a href="${parentTableSelector}" class="usa-pagination__link usa-pagination__next-page" aria-label="Next page">
<span class="usa-pagination__link-text">Next</span>
<svg class="usa-icon" aria-hidden="true" role="img">
<use xlink:href="/public/img/sprite.svg#navigate_next"></use>
@ -1101,7 +1130,7 @@ function updatePagination(itemName, paginationSelector, counterSelector, linkAnc
`;
nextPageItem.querySelector('a').addEventListener('click', (event) => {
event.preventDefault();
loadPageFunction(currentPage + 1);
this.loadTable(currentPage + 1);
});
paginationButtons.appendChild(nextPageItem);
}
@ -1111,7 +1140,7 @@ function updatePagination(itemName, paginationSelector, counterSelector, linkAnc
* A helper that toggles content/ no content/ no search results
*
*/
const updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper) => {
updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper) => {
const { unfiltered_total, total } = data;
if (unfiltered_total) {
if (total) {
@ -1130,11 +1159,29 @@ const updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper)
}
};
// Helper function to create a page item
createPageItem(page, parentTableSelector, currentPage) {
const pageItem = document.createElement('li');
pageItem.className = 'usa-pagination__item usa-pagination__page-no';
pageItem.innerHTML = `
<a href="${parentTableSelector}" class="usa-pagination__button" aria-label="Page ${page}">${page}</a>
`;
if (page === currentPage) {
pageItem.querySelector('a').classList.add('usa-current');
pageItem.querySelector('a').setAttribute('aria-current', 'page');
}
pageItem.querySelector('a').addEventListener('click', (event) => {
event.preventDefault();
this.loadTable(page);
});
return pageItem;
}
/**
* A helper that resets sortable table headers
*
*/
const unsetHeader = (header) => {
unsetHeader = (header) => {
header.removeAttribute('aria-sort');
let headerName = header.innerText;
const headerLabel = `${headerName}, sortable column, currently unsorted"`;
@ -1143,34 +1190,181 @@ const unsetHeader = (header) => {
header.querySelector('.usa-table__header__button').setAttribute("title", headerButtonLabel);
};
/**
* An IIFE that listens for DOM Content to be loaded, then executes. This function
* initializes the domains list and associated functionality on the home page of the app.
*
*/
document.addEventListener('DOMContentLoaded', function() {
const domainsWrapper = document.querySelector('.domains__table-wrapper');
// Abstract method (to be implemented in the child class)
loadTable(page, sortBy, order) {
throw new Error('loadData() must be implemented in a subclass');
}
if (domainsWrapper) {
let currentSortBy = 'id';
let currentOrder = 'asc';
const noDomainsWrapper = document.querySelector('.domains__no-data');
const noSearchResultsWrapper = document.querySelector('.domains__no-search-results');
let scrollToTable = false;
let currentStatus = [];
let currentSearchTerm = '';
const domainsSearchInput = document.getElementById('domains__search-field');
const domainsSearchSubmit = document.getElementById('domains__search-field-submit');
const tableHeaders = document.querySelectorAll('.domains__table th[data-sortable]');
const tableAnnouncementRegion = document.querySelector('.domains__table-wrapper .usa-table__announcement-region');
const resetSearchButton = document.querySelector('.domains__reset-search');
const resetFiltersButton = document.querySelector('.domains__reset-filters');
const statusCheckboxes = document.querySelectorAll('input[name="filter-status"]');
const statusIndicator = document.querySelector('.domain__filter-indicator');
const statusToggle = document.querySelector('.usa-button--filter');
const portfolioElement = document.getElementById('portfolio-js-value');
const portfolioValue = portfolioElement ? portfolioElement.getAttribute('data-portfolio') : null;
// Add event listeners to table headers for sorting
initializeTableHeaders() {
this.tableHeaders.forEach(header => {
header.addEventListener('click', () => {
const sortBy = header.getAttribute('data-sortable');
let order = 'asc';
// sort order will be ascending, unless the currently sorted column is ascending, and the user
// is selecting the same column to sort in descending order
if (sortBy === this.currentSortBy) {
order = this.currentOrder === 'asc' ? 'desc' : 'asc';
}
// load the results with the updated sort
this.loadTable(1, sortBy, order);
});
});
}
initializeSearchHandler() {
this.searchSubmit.addEventListener('click', (e) => {
e.preventDefault();
this.currentSearchTerm = this.searchInput.value;
// If the search is blank, we match the resetSearch functionality
if (this.currentSearchTerm) {
showElement(this.resetSearchButton);
} else {
hideElement(this.resetSearchButton);
}
this.loadTable(1, 'id', 'asc');
this.resetHeaders();
});
}
initializeStatusToggleHandler() {
if (this.statusToggle) {
this.statusToggle.addEventListener('click', () => {
toggleCaret(this.statusToggle);
});
}
}
// Add event listeners to status filter checkboxes
initializeFilterCheckboxes() {
this.statusCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', () => {
const checkboxValue = checkbox.value;
// Update currentStatus array based on checkbox state
if (checkbox.checked) {
this.currentStatus.push(checkboxValue);
} else {
const index = this.currentStatus.indexOf(checkboxValue);
if (index > -1) {
this.currentStatus.splice(index, 1);
}
}
// Manage visibility of reset filters button
if (this.currentStatus.length == 0) {
hideElement(this.resetFiltersButton);
} else {
showElement(this.resetFiltersButton);
}
// Disable the auto scroll
this.scrollToTable = false;
// Call loadTable with updated status
this.loadTable(1, 'id', 'asc');
this.resetHeaders();
this.updateStatusIndicator();
});
});
}
// Reset UI and accessibility
resetHeaders() {
this.tableHeaders.forEach(header => {
// Unset sort UI in headers
this.unsetHeader(header);
});
// Reset the announcement region
this.tableAnnouncementRegion.innerHTML = '';
}
resetSearch() {
this.searchInput.value = '';
this.currentSearchTerm = '';
hideElement(this.resetSearchButton);
this.loadTable(1, 'id', 'asc');
this.resetHeaders();
}
initializeResetSearchButton() {
if (this.resetSearchButton) {
this.resetSearchButton.addEventListener('click', () => {
this.resetSearch();
});
}
}
resetFilters() {
this.currentStatus = [];
this.statusCheckboxes.forEach(checkbox => {
checkbox.checked = false;
});
hideElement(this.resetFiltersButton);
// Disable the auto scroll
this.scrollToTable = false;
this.loadTable(1, 'id', 'asc');
this.resetHeaders();
this.updateStatusIndicator();
// No need to toggle close the filters. The focus shift will trigger that for us.
}
initializeResetFiltersButton() {
if (this.resetFiltersButton) {
this.resetFiltersButton.addEventListener('click', () => {
this.resetFilters();
});
}
}
updateStatusIndicator() {
this.statusIndicator.innerHTML = '';
// Even if the element is empty, it'll mess up the flex layout unless we set display none
hideElement(this.statusIndicator);
if (this.currentStatus.length)
this.statusIndicator.innerHTML = '(' + this.currentStatus.length + ')';
showElement(this.statusIndicator);
}
closeFilters() {
if (this.statusToggle.getAttribute("aria-expanded") === "true") {
this.statusToggle.click();
}
}
initializeAccordionAccessibilityListeners() {
// Instead of managing the toggle/close on the filter buttons in all edge cases (user clicks on search, user clicks on ANOTHER filter,
// user clicks on main nav...) we add a listener and close the filters whenever the focus shifts out of the dropdown menu/filter button.
// NOTE: We may need to evolve this as we add more filters.
document.addEventListener('focusin', (event) => {
const accordion = document.querySelector('.usa-accordion--select');
const accordionThatIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
if (accordionThatIsOpen && !accordion.contains(event.target)) {
this.closeFilters();
}
});
// Close when user clicks outside
// NOTE: We may need to evolve this as we add more filters.
document.addEventListener('click', (event) => {
const accordion = document.querySelector('.usa-accordion--select');
const accordionThatIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
if (accordionThatIsOpen && !accordion.contains(event.target)) {
this.closeFilters();
}
});
}
}
class DomainsTable extends LoadTableBase {
constructor() {
super('.domains__table', '.domains__table-wrapper', '#domains__search-field', '#domains__search-field-submit', '.domains__reset-search', '.domains__reset-filters', '.domains__no-data', '.domains__no-search-results');
}
/**
* Loads rows in the domains list, as well as updates pagination around the domains list
* based on the supplied attributes.
@ -1178,10 +1372,12 @@ document.addEventListener('DOMContentLoaded', function() {
* @param {*} sortBy - the sort column option
* @param {*} order - the sort order {asc, desc}
* @param {*} scroll - control for the scrollToElement functionality
* @param {*} status - control for the status filter
* @param {*} searchTerm - the search term
* @param {*} portfolio - the portfolio id
*/
function loadDomains(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, status = currentStatus, searchTerm = currentSearchTerm, portfolio = portfolioValue) {
loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) {
// fetch json of page of domais, given params
let baseUrl = document.getElementById("get_domains_json_url");
if (!baseUrl) {
@ -1194,10 +1390,19 @@ document.addEventListener('DOMContentLoaded', function() {
}
// fetch json of page of domains, given params
let url = `${baseUrlValue}?page=${page}&sort_by=${sortBy}&order=${order}&status=${status}&search_term=${searchTerm}`
let searchParams = new URLSearchParams(
{
"page": page,
"sort_by": sortBy,
"order": order,
"status": status,
"search_term": searchTerm
}
);
if (portfolio)
url += `&portfolio=${portfolio}`
searchParams.append("portfolio", portfolio)
let url = `${baseUrlValue}?${searchParams.toString()}`
fetch(url)
.then(response => response.json())
.then(data => {
@ -1207,7 +1412,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
// handle the display of proper messaging in the event that no domains exist in the list or search returns no results
updateDisplay(data, domainsWrapper, noDomainsWrapper, noSearchResultsWrapper, currentSearchTerm);
this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm);
// identify the DOM element where the domain list will be inserted into the DOM
const domainList = document.querySelector('.domains__table tbody');
@ -1225,7 +1430,7 @@ document.addEventListener('DOMContentLoaded', function() {
let markupForSuborganizationRow = '';
if (portfolioValue) {
if (this.portfolioValue) {
markupForSuborganizationRow = `
<td>
<span class="text-wrap" aria-label="${domain.suborganization ? suborganization : 'No suborganization'}">${suborganization}</span>
@ -1271,271 +1476,46 @@ document.addEventListener('DOMContentLoaded', function() {
// Do not scroll on first page load
if (scroll)
ScrollToElement('class', 'domains');
scrollToTable = true;
this.scrollToTable = true;
// update pagination
updatePagination(
this.updatePagination(
'domain',
'#domains-pagination',
'#domains-pagination .usa-pagination__counter',
'#domains',
loadDomains,
data.page,
data.num_pages,
data.has_previous,
data.has_next,
data.total,
currentSearchTerm
);
currentSortBy = sortBy;
currentOrder = order;
currentSearchTerm = searchTerm;
this.currentSortBy = sortBy;
this.currentOrder = order;
this.currentSearchTerm = searchTerm;
})
.catch(error => console.error('Error fetching domains:', error));
}
// Add event listeners to table headers for sorting
tableHeaders.forEach(header => {
header.addEventListener('click', function() {
const sortBy = this.getAttribute('data-sortable');
let order = 'asc';
// sort order will be ascending, unless the currently sorted column is ascending, and the user
// is selecting the same column to sort in descending order
if (sortBy === currentSortBy) {
order = currentOrder === 'asc' ? 'desc' : 'asc';
}
// load the results with the updated sort
loadDomains(1, sortBy, order);
});
});
domainsSearchSubmit.addEventListener('click', function(e) {
e.preventDefault();
currentSearchTerm = domainsSearchInput.value;
// If the search is blank, we match the resetSearch functionality
if (currentSearchTerm) {
showElement(resetSearchButton);
} else {
hideElement(resetSearchButton);
}
loadDomains(1, 'id', 'asc');
resetHeaders();
});
if (statusToggle) {
statusToggle.addEventListener('click', function() {
toggleCaret(statusToggle);
});
}
// Add event listeners to status filter checkboxes
statusCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
const checkboxValue = this.value;
// Update currentStatus array based on checkbox state
if (this.checked) {
currentStatus.push(checkboxValue);
} else {
const index = currentStatus.indexOf(checkboxValue);
if (index > -1) {
currentStatus.splice(index, 1);
class DomainRequestsTable extends LoadTableBase {
constructor() {
super('.domain-requests__table', '.domain-requests__table-wrapper', '#domain-requests__search-field', '#domain-requests__search-field-submit', '.domain-requests__reset-search', '.domain-requests__reset-filters', '.domain-requests__no-data', '.domain-requests__no-search-results');
}
}
// Manage visibility of reset filters button
if (currentStatus.length == 0) {
hideElement(resetFiltersButton);
} else {
showElement(resetFiltersButton);
}
// Disable the auto scroll
scrollToTable = false;
// Call loadDomains with updated status
loadDomains(1, 'id', 'asc');
resetHeaders();
updateStatusIndicator();
});
});
// Reset UI and accessibility
function resetHeaders() {
tableHeaders.forEach(header => {
// Unset sort UI in headers
unsetHeader(header);
});
// Reset the announcement region
tableAnnouncementRegion.innerHTML = '';
}
function resetSearch() {
domainsSearchInput.value = '';
currentSearchTerm = '';
hideElement(resetSearchButton);
loadDomains(1, 'id', 'asc');
resetHeaders();
}
if (resetSearchButton) {
resetSearchButton.addEventListener('click', function() {
resetSearch();
});
}
function resetFilters() {
currentStatus = [];
statusCheckboxes.forEach(checkbox => {
checkbox.checked = false;
});
hideElement(resetFiltersButton);
// Disable the auto scroll
scrollToTable = false;
loadDomains(1, 'id', 'asc');
resetHeaders();
updateStatusIndicator();
// No need to toggle close the filters. The focus shift will trigger that for us.
}
if (resetFiltersButton) {
resetFiltersButton.addEventListener('click', function() {
resetFilters();
});
}
function updateStatusIndicator() {
statusIndicator.innerHTML = '';
// Even if the element is empty, it'll mess up the flex layout unless we set display none
statusIndicator.hideElement();
if (currentStatus.length)
statusIndicator.innerHTML = '(' + currentStatus.length + ')';
statusIndicator.showElement();
}
function closeFilters() {
if (statusToggle.getAttribute("aria-expanded") === "true") {
statusToggle.click();
}
}
// Instead of managing the toggle/close on the filter buttons in all edge cases (user clicks on search, user clicks on ANOTHER filter,
// user clicks on main nav...) we add a listener and close the filters whenever the focus shifts out of the dropdown menu/filter button.
// NOTE: We may need to evolve this as we add more filters.
document.addEventListener('focusin', function(event) {
const accordion = document.querySelector('.usa-accordion--select');
const accordionThatIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
if (accordionThatIsOpen && !accordion.contains(event.target)) {
closeFilters();
}
});
// Close when user clicks outside
// NOTE: We may need to evolve this as we add more filters.
document.addEventListener('click', function(event) {
const accordion = document.querySelector('.usa-accordion--select');
const accordionThatIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
if (accordionThatIsOpen && !accordion.contains(event.target)) {
closeFilters();
}
});
// Initial load
loadDomains(1);
}
});
const utcDateString = (dateString) => {
const date = new Date(dateString);
const utcYear = date.getUTCFullYear();
const utcMonth = date.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
const utcDay = date.getUTCDate().toString().padStart(2, '0');
let utcHours = date.getUTCHours();
const utcMinutes = date.getUTCMinutes().toString().padStart(2, '0');
const ampm = utcHours >= 12 ? 'PM' : 'AM';
utcHours = utcHours % 12 || 12; // Convert to 12-hour format, '0' hours should be '12'
return `${utcMonth} ${utcDay}, ${utcYear}, ${utcHours}:${utcMinutes} ${ampm} UTC`;
};
/**
* An IIFE that listens for DOM Content to be loaded, then executes. This function
* initializes the domain requests list and associated functionality on the home page of the app.
*
*/
document.addEventListener('DOMContentLoaded', function() {
const domainRequestsSectionWrapper = document.querySelector('.domain-requests');
const domainRequestsWrapper = document.querySelector('.domain-requests__table-wrapper');
if (domainRequestsWrapper) {
let currentSortBy = 'id';
let currentOrder = 'asc';
const noDomainRequestsWrapper = document.querySelector('.domain-requests__no-data');
const noSearchResultsWrapper = document.querySelector('.domain-requests__no-search-results');
let scrollToTable = false;
let currentSearchTerm = '';
const domainRequestsSearchInput = document.getElementById('domain-requests__search-field');
const domainRequestsSearchSubmit = document.getElementById('domain-requests__search-field-submit');
const tableHeaders = document.querySelectorAll('.domain-requests__table th[data-sortable]');
const tableAnnouncementRegion = document.querySelector('.domain-requests__table-wrapper .usa-table__announcement-region');
const resetSearchButton = document.querySelector('.domain-requests__reset-search');
const portfolioElement = document.getElementById('portfolio-js-value');
const portfolioValue = portfolioElement ? portfolioElement.getAttribute('data-portfolio') : null;
/**
* Delete is actually a POST API that requires a csrf token. The token will be waiting for us in the template as a hidden input.
* @param {*} domainRequestPk - the identifier for the request that we're deleting
* @param {*} pageToDisplay - If we're deleting the last item on a page that is not page 1, we'll need to display the previous page
*/
function deleteDomainRequest(domainRequestPk,pageToDisplay) {
// Use to debug uswds modal issues
//console.log('deleteDomainRequest')
// Get csrf token
const csrfToken = getCsrfToken();
// Create FormData object and append the CSRF token
const formData = `csrfmiddlewaretoken=${encodeURIComponent(csrfToken)}&delete-domain-request=`;
fetch(`/domain-request/${domainRequestPk}/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrfToken,
},
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Update data and UI
loadDomainRequests(pageToDisplay, currentSortBy, currentOrder, scrollToTable, currentSearchTerm);
})
.catch(error => console.error('Error fetching domain requests:', error));
}
// Helper function to get the CSRF token from the cookie
function getCsrfToken() {
return document.querySelector('input[name="csrfmiddlewaretoken"]').value;
}
/**
* Loads rows in the domain requests list, as well as updates pagination around the domain requests list
* Loads rows in the domains list, as well as updates pagination around the domains list
* based on the supplied attributes.
* @param {*} page - the page number of the results (starts with 1)
* @param {*} sortBy - the sort column option
* @param {*} order - the sort order {asc, desc}
* @param {*} scroll - control for the scrollToElement functionality
* @param {*} status - control for the status filter
* @param {*} searchTerm - the search term
* @param {*} portfolio - the portfolio id
*/
function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, searchTerm = currentSearchTerm, portfolio = portfolioValue) {
// fetch json of page of domain requests, given params
loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm = this.currentSearchTerm, portfolio = this.portfolioValue) {
let baseUrl = document.getElementById("get_domain_requests_json_url");
if (!baseUrl) {
return;
@ -1546,11 +1526,20 @@ document.addEventListener('DOMContentLoaded', function() {
return;
}
// fetch json of page of requests, given params
let url = `${baseUrlValue}?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`
// add searchParams
let searchParams = new URLSearchParams(
{
"page": page,
"sort_by": sortBy,
"order": order,
"status": status,
"search_term": searchTerm
}
);
if (portfolio)
url += `&portfolio=${portfolio}`
searchParams.append("portfolio", portfolio)
let url = `${baseUrlValue}?${searchParams.toString()}`
fetch(url)
.then(response => response.json())
.then(data => {
@ -1560,7 +1549,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
// handle the display of proper messaging in the event that no requests exist in the list or search returns no results
updateDisplay(data, domainRequestsWrapper, noDomainRequestsWrapper, noSearchResultsWrapper, currentSearchTerm);
this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm);
// identify the DOM element where the domain request list will be inserted into the DOM
const tbody = document.querySelector('.domain-requests__table tbody');
@ -1613,7 +1602,7 @@ document.addEventListener('DOMContentLoaded', function() {
let markupCreatorRow = '';
if (portfolioValue) {
if (this.portfolioValue) {
markupCreatorRow = `
<td>
<span class="text-wrap break-word">${request.creator ? request.creator : ''}</span>
@ -1708,10 +1697,10 @@ document.addEventListener('DOMContentLoaded', function() {
</div>
`
domainRequestsSectionWrapper.appendChild(modal);
this.tableWrapper.appendChild(modal);
// Request is deletable, modal and modalTrigger are built. Now check if we are on the portfolio requests page (by seeing if there is a portfolio value) and enhance the modalTrigger accordingly
if (portfolioValue) {
if (this.portfolioValue) {
modalTrigger = `
<a
role="button"
@ -1794,8 +1783,8 @@ document.addEventListener('DOMContentLoaded', function() {
modals.forEach(modal => {
const submitButton = modal.querySelector('.usa-modal__submit');
const closeButton = modal.querySelector('.usa-modal__close');
submitButton.addEventListener('click', function() {
pk = submitButton.getAttribute('data-pk');
submitButton.addEventListener('click', () => {
let pk = submitButton.getAttribute('data-pk');
// Close the modal to remove the USWDS UI local classes
closeButton.click();
// If we're deleting the last item on a page that is not page 1, we'll need to refresh the display to the previous page
@ -1803,90 +1792,95 @@ document.addEventListener('DOMContentLoaded', function() {
if (data.total == 1 && data.unfiltered_total > 1) {
pageToDisplay--;
}
deleteDomainRequest(pk, pageToDisplay);
this.deleteDomainRequest(pk, pageToDisplay);
});
});
// Do not scroll on first page load
if (scroll)
ScrollToElement('class', 'domain-requests');
scrollToTable = true;
this.scrollToTable = true;
// update the pagination after the domain requests list is updated
updatePagination(
this.updatePagination(
'domain request',
'#domain-requests-pagination',
'#domain-requests-pagination .usa-pagination__counter',
'#domain-requests',
loadDomainRequests,
data.page,
data.num_pages,
data.has_previous,
data.has_next,
data.total,
currentSearchTerm
);
currentSortBy = sortBy;
currentOrder = order;
currentSearchTerm = searchTerm;
this.currentSortBy = sortBy;
this.currentOrder = order;
this.currentSearchTerm = searchTerm;
})
.catch(error => console.error('Error fetching domain requests:', error));
}
// Add event listeners to table headers for sorting
tableHeaders.forEach(header => {
header.addEventListener('click', function() {
const sortBy = this.getAttribute('data-sortable');
let order = 'asc';
// sort order will be ascending, unless the currently sorted column is ascending, and the user
// is selecting the same column to sort in descending order
if (sortBy === currentSortBy) {
order = currentOrder === 'asc' ? 'desc' : 'asc';
}
loadDomainRequests(1, sortBy, order);
});
});
/**
* Delete is actually a POST API that requires a csrf token. The token will be waiting for us in the template as a hidden input.
* @param {*} domainRequestPk - the identifier for the request that we're deleting
* @param {*} pageToDisplay - If we're deleting the last item on a page that is not page 1, we'll need to display the previous page
*/
deleteDomainRequest(domainRequestPk, pageToDisplay) {
// Use to debug uswds modal issues
//console.log('deleteDomainRequest')
domainRequestsSearchSubmit.addEventListener('click', function(e) {
e.preventDefault();
currentSearchTerm = domainRequestsSearchInput.value;
// If the search is blank, we match the resetSearch functionality
if (currentSearchTerm) {
showElement(resetSearchButton);
} else {
hideElement(resetSearchButton);
}
loadDomainRequests(1, 'id', 'asc');
resetHeaders();
});
// Get csrf token
const csrfToken = getCsrfToken();
// Create FormData object and append the CSRF token
const formData = `csrfmiddlewaretoken=${encodeURIComponent(csrfToken)}&delete-domain-request=`;
// Reset UI and accessibility
function resetHeaders() {
tableHeaders.forEach(header => {
// unset sort UI in headers
unsetHeader(header);
});
// Reset the announcement region
tableAnnouncementRegion.innerHTML = '';
fetch(`/domain-request/${domainRequestPk}/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrfToken,
},
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Update data and UI
this.loadTable(pageToDisplay, this.currentSortBy, this.currentOrder, this.scrollToTable, this.currentSearchTerm);
})
.catch(error => console.error('Error fetching domain requests:', error));
}
}
function resetSearch() {
domainRequestsSearchInput.value = '';
currentSearchTerm = '';
hideElement(resetSearchButton);
loadDomainRequests(1, 'id', 'asc');
resetHeaders();
}
if (resetSearchButton) {
resetSearchButton.addEventListener('click', function() {
resetSearch();
/**
* An IIFE that listens for DOM Content to be loaded, then executes. This function
* initializes the domains list and associated functionality on the home page of the app.
*
*/
document.addEventListener('DOMContentLoaded', function() {
const isDomainsPage = document.querySelector("#domains")
if (isDomainsPage){
const domainsTable = new DomainsTable();
if (domainsTable.tableWrapper) {
// Initial load
domainsTable.loadTable(1);
}
}
});
}
function closeMoreActionMenu(accordionThatIsOpen) {
if (accordionThatIsOpen.getAttribute("aria-expanded") === "true") {
accordionThatIsOpen.click();
/**
* An IIFE that listens for DOM Content to be loaded, then executes. This function
* initializes the domain requests list and associated functionality on the home page of the app.
*
*/
document.addEventListener('DOMContentLoaded', function() {
const domainRequestsSectionWrapper = document.querySelector('.domain-requests');
if (domainRequestsSectionWrapper) {
const domainRequestsTable = new DomainRequestsTable();
if (domainRequestsTable.tableWrapper) {
domainRequestsTable.loadTable(1);
}
}
@ -1898,6 +1892,12 @@ document.addEventListener('DOMContentLoaded', function() {
closeOpenAccordions(event);
});
function closeMoreActionMenu(accordionThatIsOpen) {
if (accordionThatIsOpen.getAttribute("aria-expanded") === "true") {
accordionThatIsOpen.click();
}
}
function closeOpenAccordions(event) {
const openAccordions = document.querySelectorAll('.usa-button--more-actions[aria-expanded="true"]');
openAccordions.forEach((openAccordionButton) => {
@ -1909,12 +1909,22 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
}
// Initial load
loadDomainRequests(1);
}
});
const utcDateString = (dateString) => {
const date = new Date(dateString);
const utcYear = date.getUTCFullYear();
const utcMonth = date.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
const utcDay = date.getUTCDate().toString().padStart(2, '0');
let utcHours = date.getUTCHours();
const utcMinutes = date.getUTCMinutes().toString().padStart(2, '0');
const ampm = utcHours >= 12 ? 'PM' : 'AM';
utcHours = utcHours % 12 || 12; // Convert to 12-hour format, '0' hours should be '12'
return `${utcMonth} ${utcDay}, ${utcYear}, ${utcHours}:${utcMinutes} ${ampm} UTC`;
};
/**
* An IIFE that displays confirmation modal on the user profile page

View file

@ -1,7 +1,8 @@
@use "uswds-core" as *;
@use "cisa_colors" as *;
/* Make "placeholder" links visually obvious */
// Used on: TODO links
// Used on: NONE
a[href$="todo"]::after {
background-color: yellow;
color: color(blue-80v);
@ -9,10 +10,14 @@ a[href$="todo"]::after {
font-style: italic;
}
// Used on: profile
// Note: Is this needed?
a.usa-link.usa-link--always-blue {
color: #{$dhs-blue};
}
// Used on: breadcrumbs
// Note: This could potentially be simplified and use usa-button--with-icon
a.breadcrumb__back {
display:flex;
align-items: center;
@ -28,10 +33,18 @@ a.breadcrumb__back {
}
}
// Remove anchor buttons' underline
a.usa-button {
text-decoration: none;
}
// Unstyled anchor buttons
a.usa-button--unstyled:visited {
color: color('primary');
}
// Disabled anchor buttons
// NOTE: Not used
a.usa-button.disabled-link {
background-color: #ccc !important;
color: #454545 !important
@ -58,6 +71,8 @@ a.usa-button--unstyled.disabled-link:focus {
text-decoration: none !important;
}
// Disabled buttons
// Used on: Domain managers, disabled logo on profile
.usa-button--unstyled.disabled-button,
.usa-button--unstyled.disabled-button:hover,
.usa-button--unstyled.disabled-button:focus {
@ -66,6 +81,16 @@ a.usa-button--unstyled.disabled-link:focus {
text-decoration: none !important;
}
// Unstyled variant for reverse out?
// Used on: NONE
.usa-button--unstyled--white,
.usa-button--unstyled--white:hover,
.usa-button--unstyled--white:focus,
.usa-button--unstyled--white:active {
color: color('white');
}
// Solid anchor buttons
a.usa-button:not(.usa-button--unstyled, .usa-button--outline) {
color: color('white');
}
@ -77,6 +102,7 @@ a.usa-button:not(.usa-button--unstyled, .usa-button--outline):active {
color: color('white');
}
// Outline anchor buttons
a.usa-button--outline,
a.usa-button--outline:visited {
box-shadow: inset 0 0 0 2px color('primary');
@ -94,10 +120,22 @@ a.usa-button--outline:active {
color: color('primary-darker');
}
// Used on: Domain request withdraw confirmation
a.withdraw {
background-color: color('error');
}
a.withdraw:hover,
a.withdraw:focus {
background-color: color('error-dark');
}
a.withdraw:active {
background-color: color('error-darker');
}
// Used on: Domain request status
//NOTE: Revise to BEM convention usa-button--outline-secondary
a.withdraw_outline,
a.withdraw_outline:visited {
box-shadow: inset 0 0 0 2px color('error');
@ -115,19 +153,8 @@ a.withdraw_outline:active {
color: color('error-darker');
}
a.withdraw:hover,
a.withdraw:focus {
background-color: color('error-dark');
}
a.withdraw:active {
background-color: color('error-darker');
}
a.usa-button--unstyled:visited {
color: color('primary');
}
// Used on: Domain request submit
.dotgov-button--green {
background-color: color('success-dark');
@ -140,15 +167,8 @@ a.usa-button--unstyled:visited {
}
}
.usa-button--unstyled--white,
.usa-button--unstyled--white:hover,
.usa-button--unstyled--white:focus,
.usa-button--unstyled--white:active {
color: color('white');
}
// Cancel button used on the
// DNSSEC main page
// Cancel button
// Used on: DNSSEC main page
// We want to center this button on mobile
// and add some extra left margin on tablet+
.usa-button--cancel {
@ -175,6 +195,8 @@ a.usa-button--unstyled:visited {
}
}
// Used on: Profile page, toggleable fields
// Note: Could potentially be cleaned up by using usa-button--with-icon
// We need to deviate from some default USWDS styles here
// in this particular case, so we have to override this.
.usa-form .usa-button.readonly-edit-button {
@ -186,6 +208,7 @@ a.usa-button--unstyled:visited {
}
}
//Used on: Domains and Requests tables
.usa-button--filter {
width: auto;
// For mobile stacking
@ -201,6 +224,8 @@ a.usa-button--unstyled:visited {
}
}
// Buttons with nested icons
// Note: Can be simplified by adding usa-link--icon to anchors in tables
.dotgov-table a,
.usa-link--icon,
.usa-button--with-icon {
@ -232,6 +257,9 @@ a .usa-icon,
width: 1.5em;
}
// Red, for delete buttons
// Used on: All delete buttons
// Note: Can be simplified by adding text-secondary to delete anchors in tables
button.text-secondary,
button.text-secondary:hover,
.dotgov-table a.text-secondary {

View file

@ -85,7 +85,7 @@ legend.float-left-tablet + button.float-right-tablet {
.read-only-label {
font-size: size('body', 'sm');
color: color('primary');
color: color('primary-dark');
margin-bottom: units(0.5);
}

View file

@ -245,7 +245,6 @@ TEMPLATES = [
"registrar.context_processors.is_production",
"registrar.context_processors.org_user_status",
"registrar.context_processors.add_path_to_context",
"registrar.context_processors.add_has_profile_feature_flag_to_context",
"registrar.context_processors.portfolio_permissions",
"registrar.context_processors.is_widescreen_mode",
],

View file

@ -1,5 +1,4 @@
from django.conf import settings
from waffle.decorators import flag_is_active
def language_code(request):
@ -54,10 +53,6 @@ def add_path_to_context(request):
return {"path": getattr(request, "path", None)}
def add_has_profile_feature_flag_to_context(request):
return {"has_profile_feature_flag": flag_is_active(request, "profile_feature")}
def portfolio_permissions(request):
"""Make portfolio permissions for the request user available in global context"""
portfolio_context = {

View file

@ -0,0 +1,229 @@
# Generated by Django 4.2.10 on 2024-09-11 21:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0127_remove_domaininformation_submitter_and_more"),
]
operations = [
migrations.AlterField(
model_name="domaininformation",
name="state_territory",
field=models.CharField(
blank=True,
choices=[
("AL", "Alabama (AL)"),
("AK", "Alaska (AK)"),
("AS", "American Samoa (AS)"),
("AZ", "Arizona (AZ)"),
("AR", "Arkansas (AR)"),
("CA", "California (CA)"),
("CO", "Colorado (CO)"),
("CT", "Connecticut (CT)"),
("DE", "Delaware (DE)"),
("DC", "District of Columbia (DC)"),
("FL", "Florida (FL)"),
("GA", "Georgia (GA)"),
("GU", "Guam (GU)"),
("HI", "Hawaii (HI)"),
("ID", "Idaho (ID)"),
("IL", "Illinois (IL)"),
("IN", "Indiana (IN)"),
("IA", "Iowa (IA)"),
("KS", "Kansas (KS)"),
("KY", "Kentucky (KY)"),
("LA", "Louisiana (LA)"),
("ME", "Maine (ME)"),
("MD", "Maryland (MD)"),
("MA", "Massachusetts (MA)"),
("MI", "Michigan (MI)"),
("MN", "Minnesota (MN)"),
("MS", "Mississippi (MS)"),
("MO", "Missouri (MO)"),
("MT", "Montana (MT)"),
("NE", "Nebraska (NE)"),
("NV", "Nevada (NV)"),
("NH", "New Hampshire (NH)"),
("NJ", "New Jersey (NJ)"),
("NM", "New Mexico (NM)"),
("NY", "New York (NY)"),
("NC", "North Carolina (NC)"),
("ND", "North Dakota (ND)"),
("MP", "Northern Mariana Islands (MP)"),
("OH", "Ohio (OH)"),
("OK", "Oklahoma (OK)"),
("OR", "Oregon (OR)"),
("PA", "Pennsylvania (PA)"),
("PR", "Puerto Rico (PR)"),
("RI", "Rhode Island (RI)"),
("SC", "South Carolina (SC)"),
("SD", "South Dakota (SD)"),
("TN", "Tennessee (TN)"),
("TX", "Texas (TX)"),
("UM", "United States Minor Outlying Islands (UM)"),
("UT", "Utah (UT)"),
("VT", "Vermont (VT)"),
("VI", "Virgin Islands (VI)"),
("VA", "Virginia (VA)"),
("WA", "Washington (WA)"),
("WV", "West Virginia (WV)"),
("WI", "Wisconsin (WI)"),
("WY", "Wyoming (WY)"),
("AA", "Armed Forces Americas (AA)"),
("AE", "Armed Forces Africa, Canada, Europe, Middle East (AE)"),
("AP", "Armed Forces Pacific (AP)"),
],
max_length=2,
null=True,
verbose_name="state, territory, or military post",
),
),
migrations.AlterField(
model_name="domainrequest",
name="state_territory",
field=models.CharField(
blank=True,
choices=[
("AL", "Alabama (AL)"),
("AK", "Alaska (AK)"),
("AS", "American Samoa (AS)"),
("AZ", "Arizona (AZ)"),
("AR", "Arkansas (AR)"),
("CA", "California (CA)"),
("CO", "Colorado (CO)"),
("CT", "Connecticut (CT)"),
("DE", "Delaware (DE)"),
("DC", "District of Columbia (DC)"),
("FL", "Florida (FL)"),
("GA", "Georgia (GA)"),
("GU", "Guam (GU)"),
("HI", "Hawaii (HI)"),
("ID", "Idaho (ID)"),
("IL", "Illinois (IL)"),
("IN", "Indiana (IN)"),
("IA", "Iowa (IA)"),
("KS", "Kansas (KS)"),
("KY", "Kentucky (KY)"),
("LA", "Louisiana (LA)"),
("ME", "Maine (ME)"),
("MD", "Maryland (MD)"),
("MA", "Massachusetts (MA)"),
("MI", "Michigan (MI)"),
("MN", "Minnesota (MN)"),
("MS", "Mississippi (MS)"),
("MO", "Missouri (MO)"),
("MT", "Montana (MT)"),
("NE", "Nebraska (NE)"),
("NV", "Nevada (NV)"),
("NH", "New Hampshire (NH)"),
("NJ", "New Jersey (NJ)"),
("NM", "New Mexico (NM)"),
("NY", "New York (NY)"),
("NC", "North Carolina (NC)"),
("ND", "North Dakota (ND)"),
("MP", "Northern Mariana Islands (MP)"),
("OH", "Ohio (OH)"),
("OK", "Oklahoma (OK)"),
("OR", "Oregon (OR)"),
("PA", "Pennsylvania (PA)"),
("PR", "Puerto Rico (PR)"),
("RI", "Rhode Island (RI)"),
("SC", "South Carolina (SC)"),
("SD", "South Dakota (SD)"),
("TN", "Tennessee (TN)"),
("TX", "Texas (TX)"),
("UM", "United States Minor Outlying Islands (UM)"),
("UT", "Utah (UT)"),
("VT", "Vermont (VT)"),
("VI", "Virgin Islands (VI)"),
("VA", "Virginia (VA)"),
("WA", "Washington (WA)"),
("WV", "West Virginia (WV)"),
("WI", "Wisconsin (WI)"),
("WY", "Wyoming (WY)"),
("AA", "Armed Forces Americas (AA)"),
("AE", "Armed Forces Africa, Canada, Europe, Middle East (AE)"),
("AP", "Armed Forces Pacific (AP)"),
],
max_length=2,
null=True,
verbose_name="state, territory, or military post",
),
),
migrations.AlterField(
model_name="portfolio",
name="state_territory",
field=models.CharField(
blank=True,
choices=[
("AL", "Alabama (AL)"),
("AK", "Alaska (AK)"),
("AS", "American Samoa (AS)"),
("AZ", "Arizona (AZ)"),
("AR", "Arkansas (AR)"),
("CA", "California (CA)"),
("CO", "Colorado (CO)"),
("CT", "Connecticut (CT)"),
("DE", "Delaware (DE)"),
("DC", "District of Columbia (DC)"),
("FL", "Florida (FL)"),
("GA", "Georgia (GA)"),
("GU", "Guam (GU)"),
("HI", "Hawaii (HI)"),
("ID", "Idaho (ID)"),
("IL", "Illinois (IL)"),
("IN", "Indiana (IN)"),
("IA", "Iowa (IA)"),
("KS", "Kansas (KS)"),
("KY", "Kentucky (KY)"),
("LA", "Louisiana (LA)"),
("ME", "Maine (ME)"),
("MD", "Maryland (MD)"),
("MA", "Massachusetts (MA)"),
("MI", "Michigan (MI)"),
("MN", "Minnesota (MN)"),
("MS", "Mississippi (MS)"),
("MO", "Missouri (MO)"),
("MT", "Montana (MT)"),
("NE", "Nebraska (NE)"),
("NV", "Nevada (NV)"),
("NH", "New Hampshire (NH)"),
("NJ", "New Jersey (NJ)"),
("NM", "New Mexico (NM)"),
("NY", "New York (NY)"),
("NC", "North Carolina (NC)"),
("ND", "North Dakota (ND)"),
("MP", "Northern Mariana Islands (MP)"),
("OH", "Ohio (OH)"),
("OK", "Oklahoma (OK)"),
("OR", "Oregon (OR)"),
("PA", "Pennsylvania (PA)"),
("PR", "Puerto Rico (PR)"),
("RI", "Rhode Island (RI)"),
("SC", "South Carolina (SC)"),
("SD", "South Dakota (SD)"),
("TN", "Tennessee (TN)"),
("TX", "Texas (TX)"),
("UM", "United States Minor Outlying Islands (UM)"),
("UT", "Utah (UT)"),
("VT", "Vermont (VT)"),
("VI", "Virgin Islands (VI)"),
("VA", "Virginia (VA)"),
("WA", "Washington (WA)"),
("WV", "West Virginia (WV)"),
("WI", "Wisconsin (WI)"),
("WY", "Wyoming (WY)"),
("AA", "Armed Forces Americas (AA)"),
("AE", "Armed Forces Africa, Canada, Europe, Middle East (AE)"),
("AP", "Armed Forces Pacific (AP)"),
],
max_length=2,
null=True,
verbose_name="state, territory, or military post",
),
),
]

View file

@ -159,7 +159,7 @@ class DomainInformation(TimeStampedModel):
choices=StateTerritoryChoices.choices,
null=True,
blank=True,
verbose_name="state / territory",
verbose_name="state, territory, or military post",
)
zipcode = models.CharField(
max_length=10,

View file

@ -11,6 +11,7 @@ from registrar.models.federal_agency import FederalAgency
from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
from registrar.utility.constants import BranchChoices
from auditlog.models import LogEntry
from .utility.time_stamped_model import TimeStampedModel
from ..utility.email import send_templated_email, EmailSendingError
@ -422,7 +423,7 @@ class DomainRequest(TimeStampedModel):
choices=StateTerritoryChoices.choices,
null=True,
blank=True,
verbose_name="state / territory",
verbose_name="state, territory, or military post",
)
zipcode = models.CharField(
max_length=10,
@ -576,11 +577,25 @@ class DomainRequest(TimeStampedModel):
verbose_name="last updated on",
help_text="Date of the last status update",
)
notes = models.TextField(
null=True,
blank=True,
)
def get_first_status_set_date(self, status):
"""Returns the date when the domain request was first set to the given status."""
log_entry = (
LogEntry.objects.filter(content_type__model="domainrequest", object_pk=self.pk, changes__status__1=status)
.order_by("-timestamp")
.first()
)
return log_entry.timestamp.date() if log_entry else None
def get_first_status_started_date(self):
"""Returns the date when the domain request was put into the status "started" for the first time"""
return self.get_first_status_set_date(DomainRequest.DomainRequestStatus.STARTED)
@classmethod
def get_statuses_that_send_emails(cls):
"""Returns a list of statuses that send an email to the user"""
@ -1138,6 +1153,11 @@ class DomainRequest(TimeStampedModel):
data[field.name] = field.value_from_object(self)
return data
def get_formatted_cisa_rep_name(self):
"""Returns the cisa representatives name in Western order."""
names = [n for n in [self.cisa_representative_first_name, self.cisa_representative_last_name] if n]
return " ".join(names) if names else "Unknown"
def _is_federal_complete(self):
# Federal -> "Federal government branch" page can't be empty + Federal Agency selection can't be None
return not (self.federal_type is None or self.federal_agency is None)

View file

@ -89,7 +89,7 @@ class Portfolio(TimeStampedModel):
choices=StateTerritoryChoices.choices,
null=True,
blank=True,
verbose_name="state / territory",
verbose_name="state, territory, or military post",
)
zipcode = models.CharField(

View file

@ -3,6 +3,7 @@
import time
import logging
from urllib.parse import urlparse, urlunparse, urlencode
from django.urls import resolve, Resolver404
logger = logging.getLogger(__name__)
@ -315,3 +316,21 @@ def convert_queryset_to_dict(queryset, is_model=True, key="id"):
request_dict = {value[key]: value for value in queryset}
return request_dict
def get_url_name(path):
"""
Given a URL path, returns the corresponding URL name defined in urls.py.
Args:
path (str): The URL path to resolve.
Returns:
str or None: The URL name if it exists, otherwise None.
"""
try:
match = resolve(path)
return match.url_name
except Resolver404:
logger.error(f"No matching URL name found for path: {path}")
return None

View file

@ -9,7 +9,7 @@ class WaffleFlag(AbstractUserFlag):
Custom implementation of django-waffles 'Flag' object.
Read more here: https://waffle.readthedocs.io/en/stable/types/flag.html
Use this class when dealing with feature flags, such as profile_feature.
Use this class when dealing with feature flags.
"""
class Meta:

View file

@ -72,12 +72,6 @@ class CheckUserProfileMiddleware:
"""Runs pre-processing logic for each view. Checks for the
finished_setup flag on the current user. If they haven't done so,
then we redirect them to the finish setup page."""
# Check that the user is "opted-in" to the profile feature flag
has_profile_feature_flag = flag_is_active(request, "profile_feature")
# If they aren't, skip this check entirely
if not has_profile_feature_flag:
return None
if request.user.is_authenticated:
profile_page = self.profile_page

View file

@ -77,19 +77,12 @@
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_edit_suborganization_portfolio_permission %}
{% else %}
{% url 'domain-org-name-address' pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Organization name and mailing address' value=domain.domain_info address='true' edit_link=url editable=is_editable %}
{% include "includes/summary_item.html" with title='Organization' value=domain.domain_info address='true' edit_link=url editable=is_editable %}
{% url 'domain-senior-official' pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Senior official' value=domain.domain_info.senior_official contact='true' edit_link=url editable=is_editable %}
{% endif %}
{# Conditionally display profile #}
{% if not has_profile_feature_flag %}
{% url 'domain-your-contact-information' pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Your contact information' value=request.user contact='true' edit_link=url editable=is_editable %}
{% endif %}
{% url 'domain-security-email' pk=domain.id as url %}
{% if security_email is not None and security_email not in hidden_security_emails%}
{% include "includes/summary_item.html" with title='Security email' value=security_email edit_link=url editable=is_editable %}

View file

@ -7,7 +7,7 @@
{# this is right after the messages block in the parent template #}
{% include "includes/form_errors.html" with form=form %}
<h1>Organization name and mailing address </h1>
<h1>Organization</h1>
<p>The name of your organization will be publicly listed as the domain registrant.</p>

View file

@ -16,11 +16,9 @@
<h2>Time to complete the form</h2>
<p>If you have <a href="{% public_site_url 'domains/before/#information-you%E2%80%99ll-need-to-complete-the-domain-request-form' %}" target="_blank" class="usa-link">all the information you need</a>,
completing your domain request might take around 15 minutes.</p>
{% if has_profile_feature_flag %}
<h2>How well reach you</h2>
<p>While reviewing your domain request, we may need to reach out with questions. Well also email you when we complete our review. If the contact information below is not correct, visit <a href="{% url 'user-profile' %}?redirect=domain-request:" class="usa-link">your profile</a> to make updates.</p>
{% include "includes/profile_information.html" with user=user%}
{% endif %}
{% block form_buttons %}

View file

@ -8,33 +8,30 @@
{% block content %}
<main id="main-content" class="grid-container">
<div class="grid-col desktop:grid-offset-2 desktop:grid-col-8">
{% comment %}
TODO: Uncomment in #2596
{% if portfolio %}
{% url 'domain-requests' as url %}
{% else %}
{% url 'home' as url %}
{% endif %}
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain request breadcrumb">
<ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
{% if portfolio %}
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Domain requests</span></a>
{% else %}
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Manage your domains</span></a>
{% endif %}
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>{{ DomainRequest.requested_domain.name }}</span
>
{% if not DomainRequest.requested_domain and DomainRequest.status == DomainRequest.DomainRequestStatus.STARTED %}
<span>New domain request</span>
{% else %}
<span>{{ DomainRequest.requested_domain.name }}</span>
{% endif %}
</li>
</ol>
</nav>
{% else %}{% endcomment %}
{% url 'home' as url %}
<a href="{{ url }}" class="breadcrumb__back">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
</svg>
<p class="margin-left-05 margin-top-0 margin-bottom-0 line-height-sans-1">
Back to manage your domains
</p>
</a>
{% comment %} {% endif %}{% endcomment %}
<h1>Domain request for {{ DomainRequest.requested_domain.name }}</h1>
<div
class="usa-summary-box dotgov-status-box margin-top-3 padding-left-2"
@ -48,18 +45,63 @@
<span class="text-bold text-primary-darker">
Status:
</span>
{% if DomainRequest.status == 'approved' %} Approved
{% elif DomainRequest.status == 'in review' %} In review
{% elif DomainRequest.status == 'rejected' %} Rejected
{% elif DomainRequest.status == 'submitted' %} Submitted
{% elif DomainRequest.status == 'ineligible' %} Ineligible
{% else %}ERROR Please contact technical support/dev
{% endif %}
{{ DomainRequest.get_status_display|default:"ERROR Please contact technical support/dev" }}
</p>
</div>
</div>
<br>
<p><b class="review__step__name">Last updated:</b> {{DomainRequest.updated_at|date:"F j, Y"}}</p>
{% with statuses=DomainRequest.DomainRequestStatus last_submitted=DomainRequest.last_submitted_date|date:"F j, Y" first_submitted=DomainRequest.first_submitted_date|date:"F j, Y" last_status_update=DomainRequest.last_status_update|date:"F j, Y" %}
{% comment %}
These are intentionally seperated this way.
There is some code repetition, but it gives us more flexibility rather than a dense reduction.
Leave it this way until we've solidified our requirements.
{% endcomment %}
{% if DomainRequest.status == statuses.STARTED %}
{% with first_started_date=DomainRequest.get_first_status_started_date|date:"F j, Y" %}
<p class="margin-top-1">
{% comment %}
A newly created domain request will not have a value for last_status update.
This is because the status never really updated.
However, if this somehow goes back to started we can default to displaying that new date.
{% endcomment %}
<b class="review__step__name">Started on:</b> {{last_status_update|default:first_started_date}}
</p>
{% endwith %}
{% elif DomainRequest.status == statuses.SUBMITTED %}
<p class="margin-top-1 margin-bottom-1">
<b class="review__step__name">Submitted on:</b> {{last_submitted|default:first_submitted }}
</p>
<p class="margin-top-1">
<b class="review__step__name">Last updated on:</b> {{DomainRequest.updated_at|date:"F j, Y"}}
</p>
{% elif DomainRequest.status == statuses.ACTION_NEEDED %}
<p class="margin-top-1 margin-bottom-1">
<b class="review__step__name">Submitted on:</b> {{last_submitted|default:first_submitted }}
</p>
<p class="margin-top-1">
<b class="review__step__name">Last updated on:</b> {{DomainRequest.updated_at|date:"F j, Y"}}
</p>
{% elif DomainRequest.status == statuses.REJECTED %}
<p class="margin-top-1 margin-bottom-1">
<b class="review__step__name">Submitted on:</b> {{last_submitted|default:first_submitted }}
</p>
<p class="margin-top-1">
<b class="review__step__name">Rejected on:</b> {{last_status_update}}
</p>
{% elif DomainRequest.status == statuses.WITHDRAWN %}
<p class="margin-top-1 margin-bottom-1">
<b class="review__step__name">Submitted on:</b> {{last_submitted|default:first_submitted }}
</p>
<p class="margin-top-1">
<b class="review__step__name">Withdrawn on:</b> {{last_status_update}}
</p>
{% else %}
{% comment %} Shown for in_review, approved, ineligible {% endcomment %}
<p class="margin-top-1">
<b class="review__step__name">Last updated on:</b> {{DomainRequest.updated_at|date:"F j, Y"}}
</p>
{% endif %}
{% if DomainRequest.status != 'rejected' %}
<p>{% include "includes/domain_request.html" %}</p>
@ -67,6 +109,7 @@
Withdraw request</a>
</p>
{% endif %}
{% endwith %}
</div>
<div class="grid-col desktop:grid-offset-2 maxw-tablet">
@ -100,7 +143,7 @@
{% endif %}
{% if DomainRequest.organization_name %}
{% include "includes/summary_item.html" with title='Organization name and mailing address' value=DomainRequest address='true' heading_level=heading_level %}
{% include "includes/summary_item.html" with title='Organization' value=DomainRequest address='true' heading_level=heading_level %}
{% endif %}
{% if DomainRequest.about_your_organization %}
@ -130,7 +173,6 @@
{% if DomainRequest.creator %}
{% include "includes/summary_item.html" with title='Your contact information' value=DomainRequest.creator contact='true' heading_level=heading_level %}
{% endif %}
{% if DomainRequest.other_contacts.all %}
{% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.other_contacts.all contact='true' list='true' heading_level=heading_level %}
{% else %}
@ -141,8 +183,8 @@
{% if DomainRequest %}
<h3 class="register-form-review-header">CISA Regional Representative</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% if domain_request.cisa_representative_first_name %}
{{domain_request.cisa_representative_first_name}} {{domain_request.cisa_representative_last_name}}
{% if DomainRequest.cisa_representative_first_name %}
{{ DomainRequest.get_formatted_cisa_rep_name }}
{% else %}
No
{% endif %}

View file

@ -12,7 +12,7 @@
{% if not portfolio %}
{% with url_name="domain-org-name-address" %}
{% include "includes/domain_sidenav_item.html" with item_text="Organization name and mailing address" %}
{% include "includes/domain_sidenav_item.html" with item_text="Organization" %}
{% endwith %}
{% endif %}

View file

@ -15,7 +15,7 @@ State-recognized tribe
Election office:
{{ domain_request.is_election_board|yesno:"Yes,No,Incomplete" }}
{% endif %}
Organization name and mailing address:
Organization:
{% spaceless %}{{ domain_request.federal_agency }}
{{ domain_request.organization_name }}
{{ domain_request.address_line1 }}{% if domain_request.address_line2 %}

View file

@ -23,13 +23,21 @@
</svg>
Reset
</button>
{% if portfolio %}
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name or creator</label>
{% else %}
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name</label>
{% endif %}
<input
class="usa-input"
id="domain-requests__search-field"
type="search"
name="search"
{% if portfolio %}
placeholder="Search by domain name or creator"
{% else %}
placeholder="Search by domain name"
{% endif %}
/>
<button class="usa-button" type="submit" id="domain-requests__search-field-submit">
<img
@ -42,6 +50,125 @@
</section>
</div>
</div>
{% if portfolio %}
<div class="display-flex flex-align-center">
<span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span>
<div class="usa-accordion usa-accordion--select margin-right-2">
<div class="usa-accordion__heading">
<button
type="button"
class="usa-button usa-button--small padding--8-8-9 usa-button--outline usa-button--filter usa-accordion__button"
aria-expanded="false"
aria-controls="filter-status"
>
<span class="filter-indicator text-bold display-none"></span> Status
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
</svg>
</button>
</div>
<div id="filter-status" class="usa-accordion__content usa-prose shadow-1">
<h2>Status</h2>
<fieldset class="usa-fieldset margin-top-0">
<legend class="usa-legend">Select to apply <span class="sr-only">status</span> filter</legend>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-started"
type="checkbox"
name="filter-status"
value="started"
/>
<label class="usa-checkbox__label" for="filter-status-started"
>Started</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-submitted"
type="checkbox"
name="filter-status"
value="submitted"
/>
<label class="usa-checkbox__label" for="filter-status-submitted"
>Submitted</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-in-review"
type="checkbox"
name="filter-status"
value="in review"
/>
<label class="usa-checkbox__label" for="filter-status-in-review"
>In review</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-action-needed"
type="checkbox"
name="filter-status"
value="action needed"
/>
<label class="usa-checkbox__label" for="filter-status-action-needed"
>Action needed</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-rejected"
type="checkbox"
name="filter-status"
value="rejected"
/>
<label class="usa-checkbox__label" for="filter-status-rejected"
>Rejected</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-withdrawn"
type="checkbox"
name="filter-status"
value="withdrawn"
/>
<label class="usa-checkbox__label" for="filter-status-withdrawn"
>Withdrawn</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-ineligible"
type="checkbox"
name="filter-status"
value="ineligible"
/>
<label class="usa-checkbox__label" for="filter-status-ineligible"
>Ineligible</label
>
</div>
</fieldset>
</div>
</div>
<button
type="button"
class="usa-button usa-button--small padding--8-12-9-12 usa-button--outline usa-button--filter domain-requests__reset-filters display-none"
>
Clear filters
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#close"></use>
</svg>
</button>
</div>
{% endif %}
<div class="domain-requests__table-wrapper display-none usa-table-container--scrollable margin-top-0" tabindex="0">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domain-requests__table">
<caption class="sr-only">Your domain requests</caption>

View file

@ -64,7 +64,7 @@
aria-expanded="false"
aria-controls="filter-status"
>
<span class="domain__filter-indicator text-bold display-none"></span> Status
<span class="filter-indicator text-bold display-none"></span> Status
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
</svg>

View file

@ -16,7 +16,6 @@
{% if user.is_authenticated %}
<span class="usa-nav__username ellipsis">{{ user.email }}</span>
</li>
{% if has_profile_feature_flag %}
<li class="usa-nav__primary-item">
{% url 'user-profile' as user_profile_url %}
{% url 'finish-user-profile-setup' as finish_setup_url %}
@ -24,7 +23,6 @@
<span class="text-primary">Your profile</span>
</a>
</li>
{% endif %}
<li class="usa-nav__primary-item">
<a href="{% url 'logout' %}"><span class="text-primary">Sign out</span></a>
{% else %}

View file

@ -18,7 +18,6 @@
{% if user.is_authenticated %}
<span class="ellipsis usa-nav__username">{{ user.email }}</span>
</li>
{% if has_profile_feature_flag %}
<li class="usa-nav__secondary-item">
{% url 'user-profile' as user_profile_url %}
{% url 'finish-user-profile-setup' as finish_setup_url %}
@ -26,7 +25,6 @@
Your profile
</a>
</li>
{% endif %}
<li class="usa-nav__secondary-item">
<a class="usa-nav-link" href="{% url 'logout' %}">Sign out</a>
{% else %}
@ -42,7 +40,7 @@
{% else %}
{% url 'no-portfolio-domains' as url %}
{% endif %}
<a href="{{ url }}" class="usa-nav-link{% if 'domain'|in_path:request.path %} usa-current{% endif %}">
<a href="{{ url }}" class="usa-nav-link{% if path|is_domain_subpage %} usa-current{% endif %}">
Domains
</a>
</li>
@ -59,7 +57,7 @@
{% url 'domain-requests' as url %}
<button
type="button"
class="usa-accordion__button usa-nav__link{% if 'request'|in_path:request.path %} usa-current{% endif %}"
class="usa-accordion__button usa-nav__link{% if path|is_domain_request_subpage %} usa-current{% endif %}"
aria-expanded="false"
aria-controls="basic-nav-section-two"
>
@ -80,13 +78,13 @@
<!-- user has view but no edit permissions -->
{% elif has_any_requests_portfolio_permission %}
{% url 'domain-requests' as url %}
<a href="{{ url }}" class="usa-nav-link{% if 'request'|in_path:request.path %} usa-current{% endif %}">
<a href="{{ url }}" class="usa-nav-link{% if path|is_domain_request_subpage %} usa-current{% endif %}">
Domain requests
</a>
<!-- user does not have permissions -->
{% else %}
{% url 'no-portfolio-requests' as url %}
<a href="{{ url }}" class="usa-nav-link{% if 'request'|in_path:request.path %} usa-current{% endif %}">
<a href="{{ url }}" class="usa-nav-link{% if path|is_domain_request_subpage %} usa-current{% endif %}">
Domain requests
</a>
{% endif %}
@ -104,7 +102,7 @@
<li class="usa-nav__primary-item">
{% url 'organization' as url %}
<!-- Move the padding from the a to the span so that the descenders do not get cut off -->
<a href="{{ url }}" class="usa-nav-link padding-y-0 {% if request.path == '/organization/' %} usa-current{% endif %}">
<a href="{{ url }}" class="usa-nav-link padding-y-0 {% if path|is_portfolio_subpage %} usa-current{% endif %}">
<span class="ellipsis ellipsis--23 ellipsis--desktop-50 padding-y-1 desktop:padding-y-2">
{{ portfolio.organization_name }}
</span>

View file

@ -2,9 +2,6 @@
<div class="usa-alert">
<div class="usa-alert__body {% if add_body_class %}{{ add_body_class }}{% endif %} {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
<b>Attention:</b> You are on a test site.
{% if has_profile_feature_flag %}
The profile_feature flag is active.
{% endif %}
</div>
</div>
</div>

View file

@ -16,15 +16,7 @@
{% if can_edit %}
{% include "includes/required_fields.html" %}
{% else %}
<p>
The senior official for your organization cant be updated here.
To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p>
{% endif %}
{% if can_edit %}
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
<form class="usa-form usa-form--large desktop:margin-top-4" method="post" novalidate id="form-container">
{% csrf_token %}
{% input_with_errors form.first_name %}
{% input_with_errors form.last_name %}
@ -33,8 +25,16 @@
<button type="submit" class="usa-button">Save</button>
</form>
{% elif not form.full_name.value and not form.title.value and not form.email.value %}
<h4>No senior official was found.</h4>
<p>
Your senior official is a person within your organization who can authorize domain requests.
We don't have information about your organization's senior official. To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p>
{% else %}
<p>
The senior official for your organization cant be updated here.
To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p>
<div class="desktop:margin-top-4">
{% if form.full_name.value is not None %}
{% include "includes/input_read_only.html" with field=form.full_name %}
{% endif %}
@ -46,4 +46,5 @@
{% if form.email.value is not None %}
{% include "includes/input_read_only.html" with field=form.email %}
{% endif %}
</div>
{% endif %}

View file

@ -9,6 +9,10 @@
{% endblock %}
{% block portfolio_content %}
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
<div id="main-content">
<h1 id="domains-header">Domains</h1>
{% include "includes/domains_table.html" with portfolio=portfolio user_domain_count=user_domain_count %}

View file

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

View file

@ -1,7 +1,7 @@
{% extends 'portfolio_base.html' %}
{% load static field_helpers%}
{% block title %}Organization mailing address | {{ portfolio.name }}{% endblock %}
{% block title %}Organization name and mailing address | {{ portfolio.name }}{% endblock %}
{% load static %}
@ -19,21 +19,25 @@
<div class="tablet:grid-col-9" id="main-content">
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
<h1>Organization</h1>
<p>The name of your federal agency will be publicly listed as the domain registrant.</p>
<p>The name of your organization will be publicly listed as the domain registrant.</p>
{% if has_edit_org_portfolio_permission %}
<p>
The federal agency for your organization cant be updated here.
Your organization name cant be updated here.
To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p>
{% include "includes/form_errors.html" with form=form %}
{% include "includes/required_fields.html" %}
<form class="usa-form usa-form--large" method="post" novalidate>
<form class="usa-form usa-form--large desktop:margin-top-4" method="post" novalidate>
{% csrf_token %}
<h4 class="read-only-label">Federal agency</h4>
<h4 class="read-only-label">Organization name</h4>
<p class="read-only-value">
{{ portfolio.federal_agency }}
</p>
@ -49,7 +53,7 @@
</button>
</form>
{% else %}
<h4 class="read-only-label">Federal agency</h4>
<h4 class="read-only-label">Organization name</h4>
<p class="read-only-value">
{{ portfolio.federal_agency }}
</p>

View file

@ -9,6 +9,10 @@
{% endblock %}
{% block portfolio_content %}
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
<div id="main-content">
<h1 id="domain-requests-header">Domain requests</h1>
<div class="grid-row grid-gap">

View file

@ -6,6 +6,10 @@
{% load static %}
{% block portfolio_content %}
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
<div class="grid-row grid-gap">
<div class="tablet:grid-col-3">
<p class="font-body-md margin-top-0 margin-bottom-2

View file

@ -3,6 +3,9 @@ from django import template
import re
from registrar.models.domain_request import DomainRequest
from phonenumber_field.phonenumber import PhoneNumber
from registrar.views.domain_request import DomainRequestWizard
from registrar.models.utility.generic_helper import get_url_name
register = template.Library()
logger = logging.getLogger(__name__)
@ -174,3 +177,65 @@ def has_contact_info(user):
@register.filter
def model_name_lowercase(instance):
return instance.__class__.__name__.lower()
@register.filter(name="is_domain_subpage")
def is_domain_subpage(path):
"""Checks if the given page is a subpage of domains.
Takes a path name, like '/domains/'."""
# Since our pages aren't unified under a common path, we need this approach for now.
url_names = [
"domains",
"no-portfolio-domains",
"domain",
"domain-users",
"domain-dns",
"domain-dns-nameservers",
"domain-dns-dnssec",
"domain-dns-dnssec-dsdata",
"domain-your-contact-information",
"domain-org-name-address",
"domain-senior-official",
"domain-security-email",
"domain-users-add",
"domain-request-delete",
"domain-user-delete",
"invitation-delete",
]
return get_url_name(path) in url_names
@register.filter(name="is_domain_request_subpage")
def is_domain_request_subpage(path):
"""Checks if the given page is a subpage of domain requests.
Takes a path name, like '/requests/'."""
# Since our pages aren't unified under a common path, we need this approach for now.
url_names = [
"domain-requests",
"no-portfolio-requests",
"domain-request-status",
"domain-request-withdraw-confirmation",
"domain-request-withdrawn",
"domain-request-delete",
]
# The domain request wizard pages don't have a defined path,
# so we need to check directly on it.
wizard_paths = [
DomainRequestWizard.EDIT_URL_NAME,
DomainRequestWizard.URL_NAMESPACE,
DomainRequestWizard.NEW_URL_NAME,
]
return get_url_name(path) in url_names or any(wizard in path for wizard in wizard_paths)
@register.filter(name="is_portfolio_subpage")
def is_portfolio_subpage(path):
"""Checks if the given page is a subpage of portfolio.
Takes a path name, like '/organization/'."""
# Since our pages aren't unified under a common path, we need this approach for now.
url_names = [
"organization",
"senior-official",
]
return get_url_name(path) in url_names

View file

@ -535,8 +535,10 @@ class MockDb(TestCase):
first_name = "First"
last_name = "Last"
email = "info@example.com"
title = "title"
phone = "8080102431"
cls.user = get_user_model().objects.create(
username=username, first_name=first_name, last_name=last_name, email=email
username=username, first_name=first_name, last_name=last_name, email=email, title=title, phone=phone
)
current_date = get_time_aware_date(datetime(2024, 4, 2))
@ -845,6 +847,7 @@ def create_superuser():
last_name="last",
is_staff=True,
password=p,
phone="8003111234",
)
# Retrieve the group or create it if it doesn't exist
group, _ = UserGroup.objects.get_or_create(name="full_access_group")
@ -862,7 +865,9 @@ def create_user():
first_name="first",
last_name="last",
is_staff=True,
title="title",
password=p,
phone="8003111234",
)
# Retrieve the group or create it if it doesn't exist
group, _ = UserGroup.objects.get_or_create(name="cisa_analysts_group")
@ -879,7 +884,12 @@ def create_test_user():
phone = "8003111234"
title = "test title"
user = get_user_model().objects.create(
username=username, first_name=first_name, last_name=last_name, email=email, phone=phone, title=title
username=username,
first_name=first_name,
last_name=last_name,
email=email,
phone=phone,
title=title,
)
return user

View file

@ -37,7 +37,6 @@ from .common import (
GenericTestHelper,
)
from unittest.mock import patch
from waffle.testutils import override_flag
from django.conf import settings
import boto3_mocking # type: ignore
@ -957,7 +956,6 @@ class TestDomainRequestAdmin(MockEppLib):
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
@override_flag("profile_feature", True)
@less_console_noise_decorator
def test_save_model_sends_approved_email(self):
"""When transitioning to approved on a domain request,

View file

@ -256,19 +256,9 @@ class TestDomainRequest(TestCase):
email_allowed.delete()
@override_flag("profile_feature", active=False)
@less_console_noise_decorator
def test_submit_from_started_sends_email(self):
msg = "Create a domain request and submit it and see if email was sent."
domain_request = completed_domain_request(user=self.dummy_user_2)
self.check_email_sent(
domain_request, msg, "submit", 1, expected_content="Lava", expected_email=self.dummy_user_2.email
)
@override_flag("profile_feature", active=True)
@less_console_noise_decorator
def test_submit_from_started_sends_email_to_creator(self):
"""Tests if, when the profile feature flag is on, we send an email to the creator"""
"""tests that we send an email to the creator"""
msg = "Create a domain request and submit it and see if email was sent when the feature flag is on."
domain_request = completed_domain_request(user=self.dummy_user_2)
self.check_email_sent(

View file

@ -9,6 +9,9 @@ from registrar.templatetags.custom_filters import (
find_index,
slice_after,
contains_checkbox,
is_domain_request_subpage,
is_domain_subpage,
is_portfolio_subpage,
)
@ -90,3 +93,18 @@ class CustomFiltersTestCase(TestCase):
]
result = contains_checkbox(html_list)
self.assertFalse(result) # Expecting False
def test_is_domain_subpage(self):
"""Tests if the path is recognized as a domain subpage."""
self.assertTrue(is_domain_subpage("/domains/"))
self.assertFalse(is_domain_subpage("/"))
def test_is_domain_request_subpage(self):
"""Tests if the path is recognized as a domain request subpage."""
self.assertTrue(is_domain_request_subpage("/requests/"))
self.assertFalse(is_domain_request_subpage("/"))
def test_is_portfolio_subpage(self):
"""Tests if the path is recognized as a portfolio subpage."""
self.assertTrue(is_portfolio_subpage("/organization/"))
self.assertFalse(is_portfolio_subpage("/"))

View file

@ -494,7 +494,13 @@ class HomeTests(TestWithUser):
phone = "8003111234"
status = User.RESTRICTED
restricted_user = get_user_model().objects.create(
username=username, first_name=first_name, last_name=last_name, email=email, phone=phone, status=status
username=username,
first_name=first_name,
last_name=last_name,
email=email,
phone=phone,
status=status,
title="title",
)
self.client.force_login(restricted_user)
response = self.client.get("/request/", follow=True)
@ -546,7 +552,6 @@ class FinishUserProfileTests(TestWithUser, WebTest):
return page.follow() if follow else page
@less_console_noise_decorator
@override_flag("profile_feature", active=True)
def test_full_name_initial_value(self):
"""Test that full_name initial value is empty when first_name or last_name is empty.
This will later be displayed as "unknown" using javascript."""
@ -600,8 +605,8 @@ class FinishUserProfileTests(TestWithUser, WebTest):
incomplete_regular_user.delete()
@less_console_noise_decorator
def test_new_user_with_profile_feature_on(self):
"""Tests that a new user is redirected to the profile setup page when profile_feature is on"""
def test_new_user(self):
"""Tests that a new user is redirected to the profile setup page"""
username_regular_incomplete = "test_regular_user_incomplete"
first_name_2 = "Incomplete"
email_2 = "unicorn@igorville.com"
@ -614,12 +619,10 @@ class FinishUserProfileTests(TestWithUser, WebTest):
)
self.app.set_user(incomplete_regular_user.username)
with override_flag("profile_feature", active=True):
# This will redirect the user to the setup page.
# Follow implicity checks if our redirect is working.
finish_setup_page = self.app.get(reverse("home")).follow()
self._set_session_cookie()
# Assert that we're on the right page
self.assertContains(finish_setup_page, "Finish setting up your profile")
@ -663,7 +666,6 @@ class FinishUserProfileTests(TestWithUser, WebTest):
verification_type=User.VerificationTypeChoices.REGULAR,
)
self.app.set_user(incomplete_regular_user.username)
with override_flag("profile_feature", active=True):
# This will redirect the user to the setup page.
# Follow implicity checks if our redirect is working.
finish_setup_page = self.app.get(reverse("home")).follow()
@ -701,8 +703,8 @@ class FinishUserProfileTests(TestWithUser, WebTest):
incomplete_regular_user.delete()
@less_console_noise_decorator
def test_new_user_goes_to_domain_request_with_profile_feature_on(self):
"""Tests that a new user is redirected to the domain request page when profile_feature is on"""
def test_new_user_goes_to_domain_request(self):
"""Tests that a new user is redirected to the domain request page"""
username_regular_incomplete = "test_regular_user_incomplete"
first_name_2 = "Incomplete"
email_2 = "unicorn@igorville.com"
@ -714,7 +716,7 @@ class FinishUserProfileTests(TestWithUser, WebTest):
verification_type=User.VerificationTypeChoices.REGULAR,
)
self.app.set_user(incomplete_regular_user.username)
with override_flag("profile_feature", active=True):
with override_flag("", active=True):
# This will redirect the user to the setup page
finish_setup_page = self.app.get(reverse("domain-request:")).follow()
self._set_session_cookie()
@ -758,25 +760,6 @@ class FinishUserProfileTests(TestWithUser, WebTest):
self.assertContains(completed_setup_page, "Youre about to start your .gov domain request")
incomplete_regular_user.delete()
@less_console_noise_decorator
def test_new_user_with_profile_feature_off(self):
"""Tests that a new user is not redirected to the profile setup page when profile_feature is off"""
with override_flag("profile_feature", active=False):
response = self.client.get("/")
self.assertNotContains(response, "Finish setting up your profile")
@less_console_noise_decorator
def test_new_user_goes_to_domain_request_with_profile_feature_off(self):
"""Tests that a new user is redirected to the domain request page
when profile_feature is off but not the setup page"""
with override_flag("profile_feature", active=False):
response = self.client.get("/request/")
self.assertNotContains(response, "Finish setting up your profile")
self.assertNotContains(response, "What contact information should we use to reach you?")
self.assertContains(response, "Youre about to start your .gov domain request")
class FinishUserProfileForOtherUsersTests(TestWithUser, WebTest):
"""A series of tests that target the user profile page intercept for incomplete IAL1 user profiles."""
@ -816,8 +799,8 @@ class FinishUserProfileForOtherUsersTests(TestWithUser, WebTest):
return page.follow() if follow else page
@less_console_noise_decorator
def test_new_user_with_profile_feature_on(self):
"""Tests that a new user is redirected to the profile setup page when profile_feature is on,
def test_new_user(self):
"""Tests that a new user is redirected to the profile setup page,
and testing that the confirmation modal is present"""
username_other_incomplete = "test_other_user_incomplete"
first_name_2 = "Incomplete"
@ -831,7 +814,6 @@ class FinishUserProfileForOtherUsersTests(TestWithUser, WebTest):
verification_type=User.VerificationTypeChoices.VERIFIED_BY_STAFF,
)
self.app.set_user(incomplete_other_user.username)
with override_flag("profile_feature", active=True):
# This will redirect the user to the user profile page.
# Follow implicity checks if our redirect is working.
user_profile_page = self.app.get(reverse("home")).follow()
@ -881,9 +863,7 @@ class FinishUserProfileForOtherUsersTests(TestWithUser, WebTest):
# NOTE: "anage" is not a typo. It is to accomodate the fact that the "m" is uppercase in one
# instance and lowercase in the other.
self.assertContains(save_page, "anage your domains", count=2)
self.assertNotContains(
save_page, "Before you can manage your domains, we need you to add contact information"
)
self.assertNotContains(save_page, "Before you can manage your domains, we need you to add contact information")
# Assert that modal does not appear on subsequent submits
self.assertNotContains(save_page, "domain registrants must maintain accurate contact information")
@ -915,113 +895,59 @@ class UserProfileTests(TestWithUser, WebTest):
DomainInformation.objects.all().delete()
@less_console_noise_decorator
def error_500_main_nav_with_profile_feature_turned_on(self):
"""test that Your profile is in main nav of 500 error page when profile_feature is on.
def error_500_main_nav(self):
"""test that Your profile is in main nav of 500 error page.
Our treatment of 401 and 403 error page handling with that waffle feature is similar, so we
assume that the same test results hold true for 401 and 403."""
with override_flag("profile_feature", active=True):
with self.assertRaises(Exception):
response = self.client.get(reverse("home"), follow=True)
self.assertEqual(response.status_code, 500)
self.assertContains(response, "Your profile")
@less_console_noise_decorator
def error_500_main_nav_with_profile_feature_turned_off(self):
"""test that Your profile is not in main nav of 500 error page when profile_feature is off.
Our treatment of 401 and 403 error page handling with that waffle feature is similar, so we
assume that the same test results hold true for 401 and 403."""
with override_flag("profile_feature", active=False):
with self.assertRaises(Exception):
response = self.client.get(reverse("home"), follow=True)
self.assertEqual(response.status_code, 500)
self.assertNotContains(response, "Your profile")
@less_console_noise_decorator
def test_home_page_main_nav_with_profile_feature_on(self):
"""test that Your profile is in main nav of home page when profile_feature is on"""
with override_flag("profile_feature", active=True):
def test_home_page_main_nav(self):
"""test that Your profile is in main nav of home page"""
response = self.client.get("/", follow=True)
self.assertContains(response, "Your profile")
@less_console_noise_decorator
def test_home_page_main_nav_with_profile_feature_off(self):
"""test that Your profile is not in main nav of home page when profile_feature is off"""
with override_flag("profile_feature", active=False):
response = self.client.get("/", follow=True)
self.assertNotContains(response, "Your profile")
@less_console_noise_decorator
def test_new_request_main_nav_with_profile_feature_on(self):
"""test that Your profile is in main nav of new request when profile_feature is on"""
with override_flag("profile_feature", active=True):
def test_new_request_main_nav(self):
"""test that Your profile is in main nav of new request"""
response = self.client.get("/request/", follow=True)
self.assertContains(response, "Your profile")
@less_console_noise_decorator
def test_new_request_main_nav_with_profile_feature_off(self):
"""test that Your profile is not in main nav of new request when profile_feature is off"""
with override_flag("profile_feature", active=False):
response = self.client.get("/request/", follow=True)
self.assertNotContains(response, "Your profile")
@less_console_noise_decorator
def test_user_profile_main_nav_with_profile_feature_on(self):
"""test that Your profile is in main nav of user profile when profile_feature is on"""
with override_flag("profile_feature", active=True):
def test_user_profile_main_nav(self):
"""test that Your profile is in main nav of user profile"""
response = self.client.get("/user-profile", follow=True)
self.assertContains(response, "Your profile")
@less_console_noise_decorator
def test_user_profile_returns_404_when_feature_off(self):
"""test that Your profile returns 404 when profile_feature is off"""
with override_flag("profile_feature", active=False):
response = self.client.get("/user-profile", follow=True)
self.assertEqual(response.status_code, 404)
@less_console_noise_decorator
def test_user_profile_back_button_when_coming_from_domain_request(self):
"""tests user profile when profile_feature is on,
"""tests user profile,
and when they are redirected from the domain request page"""
with override_flag("profile_feature", active=True):
response = self.client.get("/user-profile?redirect=domain-request:")
self.assertContains(response, "Your profile")
self.assertContains(response, "Go back to your domain request")
self.assertNotContains(response, "Back to manage your domains")
@less_console_noise_decorator
def test_domain_detail_profile_feature_on(self):
"""test that domain detail view when profile_feature is on"""
with override_flag("profile_feature", active=True):
def test_domain_detail_contains_your_profile(self):
"""Tests that the domain detail view contains 'your profile' rather than 'your contact information'"""
response = self.client.get(reverse("domain", args=[self.domain.pk]))
self.assertContains(response, "Your profile")
self.assertNotContains(response, "Your contact information")
@less_console_noise_decorator
def test_request_when_profile_feature_on(self):
"""test that Your profile is in request page when profile feature is on"""
contact_user, _ = Contact.objects.get_or_create(
first_name="Hank",
last_name="McFakerson",
)
site = DraftDomain.objects.create(name="igorville.gov")
domain_request = DomainRequest.objects.create(
creator=self.user,
requested_domain=site,
status=DomainRequest.DomainRequestStatus.SUBMITTED,
senior_official=contact_user,
)
with override_flag("profile_feature", active=True):
response = self.client.get(f"/domain-request/{domain_request.id}", follow=True)
self.assertContains(response, "Your profile")
response = self.client.get(f"/domain-request/{domain_request.id}/withdraw", follow=True)
self.assertContains(response, "Your profile")
def test_domain_your_contact_information(self):
"""test that your contact information is not accessible"""
response = self.client.get(f"/domain/{self.domain.id}/your-contact-information", follow=True)
self.assertEqual(response.status_code, 404)
@less_console_noise_decorator
def test_request_when_profile_feature_off(self):
"""test that Your profile is not in request page when profile feature is off"""
def test_profile_request_page(self):
"""test that your profile is in request"""
contact_user, _ = Contact.objects.get_or_create(
first_name="Hank",
@ -1034,20 +960,16 @@ class UserProfileTests(TestWithUser, WebTest):
status=DomainRequest.DomainRequestStatus.SUBMITTED,
senior_official=contact_user,
)
with override_flag("profile_feature", active=False):
response = self.client.get(f"/domain-request/{domain_request.id}", follow=True)
self.assertNotContains(response, "Your profile")
self.assertContains(response, "Your profile")
response = self.client.get(f"/domain-request/{domain_request.id}/withdraw", follow=True)
self.assertNotContains(response, "Your profile")
# cleanup
domain_request.delete()
site.delete()
self.assertContains(response, "Your profile")
@less_console_noise_decorator
def test_user_profile_form_submission(self):
"""test user profile form submission"""
self.app.set_user(self.user.username)
with override_flag("profile_feature", active=True):
profile_page = self.app.get(reverse("user-profile"))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)

View file

@ -723,7 +723,7 @@ class TestDomainManagers(TestDomainOverview):
email_address = "mayor@igorville.gov"
invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address)
other_user = User()
other_user = create_user()
other_user.save()
self.client.force_login(other_user)
mock_client = MagicMock()
@ -737,6 +737,12 @@ class TestDomainManagers(TestDomainOverview):
def test_domain_invitation_flow(self):
"""Send an invitation to a new user, log in and load the dashboard."""
email_address = "mayor@igorville.gov"
username = "mayor"
first_name = "First"
last_name = "Last"
title = "title"
phone = "8080102431"
title = "title"
User.objects.filter(email=email_address).delete()
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
@ -752,7 +758,9 @@ class TestDomainManagers(TestDomainOverview):
add_page.form.submit()
# user was invited, create them
new_user = User.objects.create(username=email_address, email=email_address)
new_user = User.objects.create(
username=username, email=email_address, first_name=first_name, last_name=last_name, title=title, phone=phone
)
# log them in to `self.app`
self.app.set_user(new_user.username)
# and manually call the on each login callback
@ -1298,7 +1306,9 @@ class TestDomainOrganization(TestDomainOverview):
"""Can load domain's org name and mailing address page."""
page = self.client.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id}))
# once on the sidebar, once in the page title, once as H1
self.assertContains(page, "Organization name and mailing address", count=4)
self.assertContains(page, "/org-name-address")
self.assertContains(page, "Organization name and mailing address")
self.assertContains(page, "Organization</h1>")
@less_console_noise_decorator
def test_domain_org_name_address_content(self):
@ -1607,7 +1617,7 @@ class TestDomainSuborganization(TestDomainOverview):
# Test for the title change
self.assertContains(page, "Suborganization")
self.assertNotContains(page, "Organization name")
self.assertNotContains(page, "Organization")
# Test for the good value
self.assertContains(page, "Ice Cream")

View file

@ -200,7 +200,7 @@ class TestPortfolio(WebTest):
# Assert the response is a 200
self.assertEqual(response.status_code, 200)
# The label for Federal agency will always be a h4
self.assertContains(response, '<h4 class="read-only-label">Federal agency</h4>')
self.assertContains(response, '<h4 class="read-only-label">Organization name</h4>')
# The read only label for city will be a h4
self.assertContains(response, '<h4 class="read-only-label">City</h4>')
self.assertNotContains(response, 'for="id_city"')
@ -225,10 +225,10 @@ class TestPortfolio(WebTest):
# Assert the response is a 200
self.assertEqual(response.status_code, 200)
# The label for Federal agency will always be a h4
self.assertContains(response, '<h4 class="read-only-label">Federal agency</h4>')
self.assertContains(response, '<h4 class="read-only-label">Organization name</h4>')
# The read only label for city will be a h4
self.assertNotContains(response, '<h4 class="read-only-label">City</h4>')
self.assertNotContains(response, '<p class="read-only-value">Los Angeles</p>>')
self.assertNotContains(response, '<p class="read-only-value">Los Angeles</p>')
self.assertContains(response, 'for="id_city"')
@less_console_noise_decorator
@ -342,9 +342,7 @@ class TestPortfolio(WebTest):
user=self.user, portfolio=self.portfolio, additional_permissions=portfolio_additional_permissions
)
page = self.app.get(reverse("organization"))
self.assertContains(
page, "The name of your federal agency will be publicly listed as the domain registrant."
)
self.assertContains(page, "The name of your organization will be publicly listed as the domain registrant.")
@less_console_noise_decorator
def test_domain_org_name_address_content(self):

View file

@ -1,6 +1,7 @@
from unittest import skip
from unittest.mock import Mock
from unittest.mock import Mock, patch
from datetime import datetime
from django.utils import timezone
from django.conf import settings
from django.urls import reverse
from api.tests.common import less_console_noise_decorator
@ -56,6 +57,46 @@ class DomainRequestTests(TestWithUser, WebTest):
intro_page = self.app.get(reverse("domain-request:"))
self.assertContains(intro_page, "Youre about to start your .gov domain request")
@less_console_noise_decorator
def test_template_status_display(self):
"""Tests the display of status-related information in the template."""
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.SUBMITTED, user=self.user)
domain_request.last_submitted_date = datetime.now()
domain_request.save()
response = self.app.get(f"/domain-request/{domain_request.id}")
self.assertContains(response, "Submitted on:")
self.assertContains(response, domain_request.last_submitted_date.strftime("%B %-d, %Y"))
@patch.object(DomainRequest, "get_first_status_set_date")
def test_get_first_status_started_date(self, mock_get_first_status_set_date):
"""Tests retrieval of the first date the status was set to 'started'."""
# Set the mock to return a fixed date
fixed_date = timezone.datetime(2023, 1, 1).date()
mock_get_first_status_set_date.return_value = fixed_date
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.STARTED, user=self.user)
domain_request.last_status_update = None
domain_request.save()
response = self.app.get(f"/domain-request/{domain_request.id}")
# Ensure that the date is still set to None
self.assertIsNone(domain_request.last_status_update)
print(response)
# We should still grab a date for this field in this event - but it should come from the audit log instead
self.assertContains(response, "Started on:")
self.assertContains(response, fixed_date.strftime("%B %-d, %Y"))
# If a status date is set, we display that instead
domain_request.last_status_update = datetime.now()
domain_request.save()
response = self.app.get(f"/domain-request/{domain_request.id}")
# We should still grab a date for this field in this event - but it should come from the audit log instead
self.assertContains(response, "Started on:")
self.assertContains(response, domain_request.last_status_update.strftime("%B %-d, %Y"))
@less_console_noise_decorator
def test_domain_request_form_intro_is_skipped_when_edit_access(self):
"""Tests that user is NOT presented with intro acknowledgement page when accessed through 'edit'"""
@ -2206,7 +2247,6 @@ class DomainRequestTests(TestWithUser, WebTest):
senior_official = domain_request.senior_official
self.assertEquals("Testy2", senior_official.first_name)
@override_flag("profile_feature", active=True)
@less_console_noise_decorator
def test_edit_creator_in_place(self):
"""When you:

View file

@ -458,3 +458,81 @@ class GetRequestsJsonTest(TestWithUser, WebTest):
# Ensure no approved requests are included
for domain_request in data["domain_requests"]:
self.assertNotEqual(domain_request["status"], DomainRequest.DomainRequestStatus.APPROVED)
def test_search(self):
"""Tests our search functionality. We expect that search filters on creator only when we are in a portfolio"""
# Test search for domain name
response = self.app.get(reverse("get_domain_requests_json"), {"search_term": "lamb"})
self.assertEqual(response.status_code, 200)
data = response.json
self.assertEqual(len(data["domain_requests"]), 1)
requested_domain = data["domain_requests"][0]["requested_domain"]
self.assertEqual(requested_domain, "lamb-chops.gov")
# Test search for 'New domain request'
response = self.app.get(reverse("get_domain_requests_json"), {"search_term": "new domain"})
self.assertEqual(response.status_code, 200)
data = response.json
self.assertTrue(any(req["requested_domain"] is None for req in data["domain_requests"]))
# Test search with portfolio (including creator search)
self.client.force_login(self.user)
with override_flag("organization_feature", active=True), override_flag("organization_requests", active=True):
user_perm, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
)
response = self.app.get(
reverse("get_domain_requests_json"), {"search_term": "info", "portfolio": self.portfolio.id}
)
self.assertEqual(response.status_code, 200)
data = response.json
self.assertTrue(any(req["creator"].startswith("info") for req in data["domain_requests"]))
# Test search without portfolio (should not search on creator)
with override_flag("organization_feature", active=False), override_flag("organization_requests", active=False):
user_perm.delete()
response = self.app.get(reverse("get_domain_requests_json"), {"search_term": "info"})
self.assertEqual(response.status_code, 200)
data = response.json
self.assertEqual(len(data["domain_requests"]), 0)
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
def test_status_filter(self):
"""Test that status filtering works properly"""
# Test a single status
response = self.app.get(reverse("get_domain_requests_json"), {"status": "started"})
self.assertEqual(response.status_code, 200)
data = response.json
self.assertTrue(all(req["status"] == "Started" for req in data["domain_requests"]))
# Test an invalid status
response = self.app.get(reverse("get_domain_requests_json"), {"status": "approved"})
self.assertEqual(response.status_code, 200)
data = response.json
self.assertEqual(len(data["domain_requests"]), 0)
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
def test_combined_filtering_and_sorting(self):
"""Test that combining filters and sorting works properly"""
user_perm, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
)
self.client.force_login(self.user)
response = self.app.get(
reverse("get_domain_requests_json"),
{"search_term": "beef", "status": "started", "portfolio": self.portfolio.id},
)
self.assertEqual(response.status_code, 200)
data = response.json
self.assertTrue(all("beef" in req["requested_domain"] for req in data["domain_requests"]))
self.assertTrue(all(req["status"] == "Started" for req in data["domain_requests"]))
created_at_dates = [req["created_at"] for req in data["domain_requests"]]
self.assertEqual(created_at_dates, sorted(created_at_dates, reverse=True))
user_perm.delete()

View file

@ -204,7 +204,7 @@ class DomainView(DomainBaseView):
class DomainOrgNameAddressView(DomainFormBaseView):
"""Organization name and mailing address view"""
"""Organization view"""
model = Domain
template_name = "domain_org_name_address.html"

View file

@ -82,7 +82,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
Step.TRIBAL_GOVERNMENT: _("Tribal government"),
Step.ORGANIZATION_FEDERAL: _("Federal government branch"),
Step.ORGANIZATION_ELECTION: _("Election office"),
Step.ORGANIZATION_CONTACT: _("Organization name and mailing address"),
Step.ORGANIZATION_CONTACT: _("Organization"),
Step.ABOUT_YOUR_ORGANIZATION: _("About your organization"),
Step.SENIOR_OFFICIAL: _("Senior official"),
Step.CURRENT_SITES: _("Current websites"),

View file

@ -20,6 +20,7 @@ def get_domain_requests_json(request):
unfiltered_total = objects.count()
objects = apply_search(objects, request)
objects = apply_status_filter(objects, request)
objects = apply_sorting(objects, request)
paginator = Paginator(objects, 10)
@ -63,6 +64,7 @@ def get_domain_request_ids_from_request(request):
def apply_search(queryset, request):
search_term = request.GET.get("search_term")
is_portfolio = request.GET.get("portfolio")
if search_term:
search_term_lower = search_term.lower()
@ -75,11 +77,34 @@ def apply_search(queryset, request):
queryset = queryset.filter(
Q(requested_domain__name__icontains=search_term) | Q(requested_domain__isnull=True)
)
elif is_portfolio:
queryset = queryset.filter(
Q(requested_domain__name__icontains=search_term)
| Q(creator__first_name__icontains=search_term)
| Q(creator__last_name__icontains=search_term)
| Q(creator__email__icontains=search_term)
)
# For non org users
else:
queryset = queryset.filter(Q(requested_domain__name__icontains=search_term))
return queryset
def apply_status_filter(queryset, request):
status_param = request.GET.get("status")
if status_param:
status_list = status_param.split(",")
statuses = [status for status in status_list if status in DomainRequest.DomainRequestStatus.values]
# Construct Q objects for statuses that can be queried through ORM
status_query = Q()
if statuses:
status_query |= Q(status__in=statuses)
# Apply the combined query
queryset = queryset.filter(status_query)
return queryset
def apply_sorting(queryset, request):
sort_by = request.GET.get("sort_by", "id") # Default to 'id'
order = request.GET.get("order", "asc") # Default to 'asc'

View file

@ -11,7 +11,6 @@ from django.urls import NoReverseMatch, reverse
from registrar.models.user import User
from registrar.models.utility.generic_helper import replace_url_queryparams
from registrar.views.utility.permission_views import UserProfilePermissionView
from waffle.decorators import waffle_flag
logger = logging.getLogger(__name__)
@ -46,7 +45,6 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
return self.render_to_response(context)
@waffle_flag("profile_feature") # type: ignore
def dispatch(self, request, *args, **kwargs): # type: ignore
return super().dispatch(request, *args, **kwargs)