diff --git a/docs/developer/README.md b/docs/developer/README.md index 860140a96..72f6b9f20 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -320,16 +320,6 @@ it may help to resync your laptop with time.nist.gov: sudo sntp -sS time.nist.gov ``` -### Settings -The config for the connection pool exists inside the `settings.py` file. -| Name | Purpose | -| ------------------------ | ------------------------------------------------------------------------------------------------- | -| EPP_CONNECTION_POOL_SIZE | Determines the number of concurrent sockets that should exist in the pool. | -| POOL_KEEP_ALIVE | Determines the interval in which we ping open connections in seconds. Calculated as POOL_KEEP_ALIVE / EPP_CONNECTION_POOL_SIZE | -| POOL_TIMEOUT | Determines how long we try to keep a pool alive for, before restarting it. | - -Consider updating the `POOL_TIMEOUT` or `POOL_KEEP_ALIVE` periods if the pool often restarts. If the pool only restarts after a period of inactivity, update `POOL_KEEP_ALIVE`. If it restarts during the EPP call itself, then `POOL_TIMEOUT` needs to be updated. - ## Adding a S3 instance to your sandbox This can either be done through the CLI, or through the cloud.gov dashboard. Generally, it is better to do it through the dashboard as it handles app binding for you. @@ -405,3 +395,9 @@ This function is triggered by the post_save event on the User model, designed to 1. For New Users: Upon the creation of a new user, it checks for an existing `Contact` by email. If no matching contact is found, it creates a new Contact using the user's details from Login.gov. If a matching contact is found, it associates this contact with the user. In cases where multiple contacts with the same email exist, it logs a warning and associates the first contact found. 2. For Existing Users: For users logging in subsequent times, the function ensures that any updates from Login.gov are applied to the associated User record. However, it does not alter any existing Contact records. + +## Disable email sending (toggling the disable_email_sending flag) +1. On the app, navigate to `\admin`. +2. Under models, click `Waffle flags`. +3. Click the `disable_email_sending` record. This should exist by default, if not - create one with that name. +4. (Important) Set the field `everyone` to `Yes`. This field overrides all other settings \ No newline at end of file diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md index e4543a28c..472362a79 100644 --- a/docs/operations/data_migration.md +++ b/docs/operations/data_migration.md @@ -668,3 +668,32 @@ Example: `cf ssh getgov-za` #### Step 1: Running the script ```docker-compose exec app ./manage.py populate_verification_type``` + + +## Copy names from contacts to users + +### Running on sandboxes + +#### Step 1: Login to CloudFoundry +```cf login -a api.fr.cloud.gov --sso``` + +#### Step 2: SSH into your environment +```cf ssh getgov-{space}``` + +Example: `cf ssh getgov-za` + +#### Step 3: Create a shell instance +```/tmp/lifecycle/shell``` + +#### Step 4: Running the script +```./manage.py copy_names_from_contacts_to_users --debug``` + +### Running locally + +#### Step 1: Running the script +```docker-compose exec app ./manage.py copy_names_from_contacts_to_users --debug``` + +##### Optional parameters +| | Parameter | Description | +|:-:|:-------------------------- |:----------------------------------------------------------------------------| +| 1 | **debug** | Increases logging detail. Defaults to False. | diff --git a/docs/operations/import_export.md b/docs/operations/import_export.md index 7c3ee1159..7ddfd5d3b 100644 --- a/docs/operations/import_export.md +++ b/docs/operations/import_export.md @@ -1,18 +1,29 @@ # Export / Import Tables -A means is provided to export and import individual tables from +A means is provided to export and import tables from one environment to another. This allows for replication of production data in a development environment. Import and export -are provided through the django admin interface, through a modified -library, django-import-export. Each supported model has an Import -and an Export button on the list view. +are provided through a modified library, django-import-export. +Simple scripts are provided as detailed below. ### Export -When exporting models from the source environment, make sure that -no filters are selected. This will ensure that all rows of the model -are exported. Due to database dependencies, the following models -need to be exported: +To export from the source environment, run the following command from src directory: +manage.py export_tables + +Connect to the source sandbox and run the command: +cf ssh {source-app} +/tmp/lifecycle/shell +./manage.py export_tables + +example exporting from getgov-stable: +cf ssh getgov-stable +/tmp/lifecycle/shell +./manage.py export_tables + +This exports a file, exported_tables.zip, to the tmp directory + +For reference, the zip file will contain the following tables in csv form: * User * Contact @@ -25,6 +36,20 @@ need to be exported: * Host * HostIP +After exporting the file from the target environment, scp the exported_tables.zip +file from the target environment to local. Run the below commands from local. + +Get passcode by running: +cf ssh-code + +scp file from source app to local file: +scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app {source-app} --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 ssh.fr.cloud.gov:app/tmp/exported_tables.zip {local_file_path} +when prompted, supply the passcode retrieved in the 'cf ssh-code' command + +example copying from stable to local cwd: +scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app getgov-stable --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 ssh.fr.cloud.gov:app/tmp/exported_tables.zip . + + ### Import When importing into the target environment, if the target environment @@ -34,7 +59,18 @@ that there are no database conflicts on import. #### Preparing Target Environment -Delete all rows from tables in the following order through django admin: +In order to delete all rows from the appropriate tables, run the following +command: +cf ssh {target-app} +/tmp/lifecycle/shell +./manage.py clean_tables + +example cleaning getgov-backup: +cf ssh getgov-backup +/tmp/lifecycle/backup +./manage.py clean_tables + +For reference, this deletes all rows from the following tables: * DomainInformation * DomainRequest @@ -48,10 +84,34 @@ Delete all rows from tables in the following order through django admin: #### Importing into Target Environment -Once target environment is prepared, files can be imported in the following -order: +Once target environment is prepared, files can be imported. -* User (After importing User table, you need to delete all rows from Contact table before importing Contacts) +To scp the exported_tables.zip file from local to the sandbox, run the following: + +Get passcode by running: +cf ssh-code + +scp file from local to target app: +scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app {target-app} --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 {local_file_path} ssh.fr.cloud.gov:app/tmp/exported_tables.zip +when prompted, supply the passcode retrieved in the 'cf ssh-code' command + +example copy of local file in tmp to getgov-backup: +scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app getgov-backup --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 tmp/exported_tables.zip ssh.fr.cloud.gov:app/tmp/exported_tables.zip + + +Then connect to a shell in the target environment, and run the following import command: +cf ssh {target-app} +/tmp/lifecycle/shell +./manage.py import_tables + +example cleaning getgov-backup: +cf ssh getgov-backup +/tmp/lifecycle/backup +./manage.py import_tables + +For reference, this imports tables in the following order: + +* User * Contact * Domain * Host diff --git a/src/registrar/admin.py b/src/registrar/admin.py index e693c41cc..a2f1a1dfc 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -596,7 +596,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): None, {"fields": ("username", "password", "status", "verification_type")}, ), - ("Personal info", {"fields": ("first_name", "middle_name", "last_name", "email", "title")}), + ("Personal info", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}), ( "Permissions", { @@ -627,7 +627,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): ) }, ), - ("Personal Info", {"fields": ("first_name", "middle_name", "last_name", "email", "title")}), + ("Personal Info", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}), ( "Permissions", { @@ -2262,9 +2262,46 @@ class DraftDomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): return response -class PublicContactAdmin(ListHeaderAdmin): +class PublicContactResource(resources.ModelResource): + """defines how each field in the referenced model should be mapped to the corresponding fields in the + import/export file""" + + class Meta: + model = models.PublicContact + + def import_row(self, row, instance_loader, using_transactions=True, dry_run=False, raise_errors=None, **kwargs): + """Override kwargs skip_epp_save and set to True""" + kwargs["skip_epp_save"] = True + return super().import_row( + row, + instance_loader, + using_transactions=using_transactions, + dry_run=dry_run, + raise_errors=raise_errors, + **kwargs, + ) + + def save_instance(self, instance, is_create, using_transactions=True, dry_run=False): + """Override save_instance setting skip_epp_save to True""" + self.before_save_instance(instance, using_transactions, dry_run) + if self._meta.use_bulk: + if is_create: + self.create_instances.append(instance) + else: + self.update_instances.append(instance) + elif not using_transactions and dry_run: + # we don't have transactions and we want to do a dry_run + pass + else: + instance.save(skip_epp_save=True) + self.after_save_instance(instance, using_transactions, dry_run) + + +class PublicContactAdmin(ListHeaderAdmin, ImportExportModelAdmin): """Custom PublicContact admin class.""" + resource_classes = [PublicContactResource] + change_form_template = "django/admin/email_clipboard_change_form.html" autocomplete_fields = ["domain"] @@ -2322,6 +2359,8 @@ class UserGroupAdmin(AuditedAdmin): class WaffleFlagAdmin(FlagAdmin): + """Custom admin implementation of django-waffle's Flag class""" + class Meta: """Contains meta information about this class""" @@ -2355,6 +2394,6 @@ admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin) # Register our custom waffle implementations admin.site.register(models.WaffleFlag, WaffleFlagAdmin) -# Unregister Sample and Switch from the waffle library -admin.site.unregister(Sample) +# Unregister Switch and Sample from the waffle library admin.site.unregister(Switch) +admin.site.unregister(Sample) diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index c33a31aa2..0d594b315 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -879,6 +879,146 @@ function unloadModals() { window.modal.off(); } +/** + * Helper function that scrolls to an element + * @param {string} attributeName - The string "class" or "id" + * @param {string} attributeValue - The class or id name + */ +function ScrollToElement(attributeName, attributeValue) { + let targetEl = null; + + if (attributeName === 'class') { + targetEl = document.getElementsByClassName(attributeValue)[0]; + } else if (attributeName === 'id') { + targetEl = document.getElementById(attributeValue); + } else { + console.log('Error: unknown attribute name provided.'); + return; // Exit the function if an invalid attributeName is provided + } + + if (targetEl) { + const rect = targetEl.getBoundingClientRect(); + const scrollTop = window.scrollY || document.documentElement.scrollTop; + window.scrollTo({ + top: rect.top + scrollTop, + behavior: 'smooth' // Optional: for smooth scrolling + }); + } +} + +/** + * 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} headerAnchor - CSS selector for the header element to anchor the links to. + * @param {Function} loadPageFunction - Function to call when a page link is clicked. + * @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. + */ +function updatePagination(itemName, paginationSelector, counterSelector, headerAnchor, loadPageFunction, currentPage, numPages, hasPrevious, hasNext, totalItems) { + const paginationContainer = document.querySelector(paginationSelector); + const paginationCounter = document.querySelector(counterSelector); + const paginationButtons = document.querySelector(`${paginationSelector} .usa-pagination__list`); + paginationCounter.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); + + paginationCounter.innerHTML = `${totalItems} ${itemName}${totalItems > 1 ? 's' : ''}`; + + if (hasPrevious) { + const prevPageItem = document.createElement('li'); + prevPageItem.className = 'usa-pagination__item usa-pagination__arrow'; + prevPageItem.innerHTML = ` + + + Previous + + `; + prevPageItem.querySelector('a').addEventListener('click', (event) => { + event.preventDefault(); + loadPageFunction(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 = ` + ${page} + `; + 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)); + if (currentPage > 3) { + const ellipsis = document.createElement('li'); + ellipsis.className = 'usa-pagination__item usa-pagination__overflow'; + ellipsis.setAttribute('aria-label', 'ellipsis indicating non-visible pages'); + ellipsis.innerHTML = ''; + paginationButtons.appendChild(ellipsis); + } + } + + // 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)); + } + + // Add last page and ellipsis if necessary + if (currentPage < numPages - 1) { + if (currentPage < numPages - 2) { + const ellipsis = document.createElement('li'); + ellipsis.className = 'usa-pagination__item usa-pagination__overflow'; + ellipsis.setAttribute('aria-label', 'ellipsis indicating non-visible pages'); + ellipsis.innerHTML = ''; + paginationButtons.appendChild(ellipsis); + } + paginationButtons.appendChild(createPageItem(numPages)); + } + + if (hasNext) { + const nextPageItem = document.createElement('li'); + nextPageItem.className = 'usa-pagination__item usa-pagination__arrow'; + nextPageItem.innerHTML = ` + + Next + + + `; + nextPageItem.querySelector('a').addEventListener('click', (event) => { + event.preventDefault(); + loadPageFunction(currentPage + 1); + }); + paginationButtons.appendChild(nextPageItem); + } +} + + /** * 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. @@ -891,6 +1031,7 @@ document.addEventListener('DOMContentLoaded', function() { let currentSortBy = 'id'; let currentOrder = 'asc'; let noDomainsWrapper = document.querySelector('.no-domains-wrapper'); + let hasLoaded = false; /** * Loads rows in the domains list, as well as updates pagination around the domains list @@ -898,8 +1039,9 @@ document.addEventListener('DOMContentLoaded', function() { * @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 {*} loaded - control for the scrollToElement functionality */ - function loadDomains(page, sortBy = currentSortBy, order = currentOrder) { + function loadDomains(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded) { //fetch json of page of domains, given page # and sort fetch(`/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}`) .then(response => response.json()) @@ -923,9 +1065,12 @@ document.addEventListener('DOMContentLoaded', function() { domainList.innerHTML = ''; data.domains.forEach(domain => { + const options = { year: 'numeric', month: 'short', day: 'numeric' }; const expirationDate = domain.expiration_date ? new Date(domain.expiration_date) : null; + const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : null; const expirationDateSortValue = expirationDate ? expirationDate.getTime() : ''; const actionUrl = domain.action_url; + const row = document.createElement('tr'); row.innerHTML = ` @@ -933,7 +1078,7 @@ document.addEventListener('DOMContentLoaded', function() { ${domain.name} - ${expirationDate ? expirationDate.toLocaleDateString() : ''} + ${expirationDateFormatted} ${domain.state_display} @@ -961,84 +1106,31 @@ document.addEventListener('DOMContentLoaded', function() { }); // initialize tool tips immediately after the associated DOM elements are added initializeTooltips(); + if (loaded) + ScrollToElement('id', 'domains-header'); hasLoaded = true; // update pagination - updateDomainsPagination(data.page, data.num_pages, data.has_previous, data.has_next, data.total); + updatePagination( + 'domain', + '#domains-pagination', + '#domains-pagination .usa-pagination__counter', + '#domains-header', + loadDomains, + data.page, + data.num_pages, + data.has_previous, + data.has_next, + data.total + ); currentSortBy = sortBy; currentOrder = order; }) .catch(error => console.error('Error fetching domains:', error)); } - /** - * Update the pagination below the domains list. - * @param {*} currentPage - the current page number (starting with 1) - * @param {*} numPages - the number of pages indicated by the domains list response - * @param {*} hasPrevious - if there is a page of results prior to the current page - * @param {*} hasNext - if there is a page of results after the current page - */ - function updateDomainsPagination(currentPage, numPages, hasPrevious, hasNext, totalItems) { - // identify the DOM element where the pagination will be inserted - const paginationContainer = document.querySelector('#domains-pagination'); - const paginationCounter = document.querySelector('#domains-pagination .usa-pagination__counter'); - const paginationButtons = document.querySelector('#domains-pagination .usa-pagination__list'); - paginationCounter.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); - - paginationCounter.innerHTML = `${totalItems} domain${totalItems > 1 ? 's' : ''}`; - if (hasPrevious) { - const prevPageItem = document.createElement('li'); - prevPageItem.className = 'usa-pagination__item usa-pagination__arrow'; - prevPageItem.innerHTML = ` - - - Previous - - `; - prevPageItem.querySelector('a').addEventListener('click', () => loadDomains(currentPage - 1)); - paginationButtons.appendChild(prevPageItem); - } - - for (let i = 1; i <= numPages; i++) { - const pageItem = document.createElement('li'); - pageItem.className = 'usa-pagination__item usa-pagination__page-no'; - pageItem.innerHTML = ` - ${i} - `; - if (i === currentPage) { - pageItem.querySelector('a').classList.add('usa-current'); - pageItem.querySelector('a').setAttribute('aria-current', 'page'); - } - pageItem.querySelector('a').addEventListener('click', () => loadDomains(i)); - paginationButtons.appendChild(pageItem); - } - - if (hasNext) { - const nextPageItem = document.createElement('li'); - nextPageItem.className = 'usa-pagination__item usa-pagination__arrow'; - nextPageItem.innerHTML = ` - - Next - - - `; - nextPageItem.querySelector('a').addEventListener('click', () => loadDomains(currentPage + 1)); - paginationButtons.appendChild(nextPageItem); - } - } // Add event listeners to table headers for sorting document.querySelectorAll('.dotgov-table__registered-domains th[data-sortable]').forEach(header => { @@ -1060,6 +1152,17 @@ document.addEventListener('DOMContentLoaded', function() { } }); +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'); + const utcHours = date.getUTCHours().toString().padStart(2, '0'); + const utcMinutes = date.getUTCMinutes().toString().padStart(2, '0'); + + return `${utcMonth} ${utcDay}, ${utcYear}, ${utcHours}:${utcMinutes} 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. @@ -1072,6 +1175,7 @@ document.addEventListener('DOMContentLoaded', function() { let currentSortBy = 'id'; let currentOrder = 'asc'; let noDomainRequestsWrapper = document.querySelector('.no-domain-requests-wrapper'); + let hasLoaded = false; /** * Loads rows in the domain requests list, as well as updates pagination around the domain requests list @@ -1079,8 +1183,9 @@ document.addEventListener('DOMContentLoaded', function() { * @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 {*} loaded - control for the scrollToElement functionality */ - function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder) { + function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded) { //fetch json of page of domain requests, given page # and sort fetch(`/get-domain-requests-json/?page=${page}&sort_by=${sortBy}&order=${order}`) .then(response => response.json()) @@ -1107,10 +1212,10 @@ document.addEventListener('DOMContentLoaded', function() { // after the DOM content changes and there are new delete modal buttons added unloadModals(); data.domain_requests.forEach(request => { - const domainName = request.requested_domain ? request.requested_domain : `New domain request (${new Date(request.created_at).toLocaleString()} UTC)`; + const options = { year: 'numeric', month: 'short', day: 'numeric' }; + const domainName = request.requested_domain ? request.requested_domain : `New domain request
(${utcDateString(request.created_at)})`; const actionUrl = request.action_url; const actionLabel = request.action_label; - const options = { year: 'numeric', month: 'short', day: 'numeric' }; const submissionDate = request.submission_date ? new Date(request.submission_date).toLocaleDateString('en-US', options) : `Not submitted`; const deleteButton = request.is_deletable ? ` console.error('Error fetching domain requests:', error)); } - /** - * Update the pagination below the domain requests list. - * @param {*} currentPage - the current page number (starting with 1) - * @param {*} numPages - the number of pages indicated by the domain request list response - * @param {*} hasPrevious - if there is a page of results prior to the current page - * @param {*} hasNext - if there is a page of results after the current page - */ - function updateDomainRequestsPagination(currentPage, numPages, hasPrevious, hasNext, totalItems) { - // identify the DOM element where pagination is contained - const paginationContainer = document.querySelector('#domain-requests-pagination'); - const paginationCounter = document.querySelector('#domain-requests-pagination .usa-pagination__counter'); - const paginationButtons = document.querySelector('#domain-requests-pagination .usa-pagination__list'); - paginationCounter.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); - - paginationCounter.innerHTML = `${totalItems} domain request${totalItems > 1 ? 's' : ''}`; - - if (hasPrevious) { - const prevPageItem = document.createElement('li'); - prevPageItem.className = 'usa-pagination__item usa-pagination__arrow'; - prevPageItem.innerHTML = ` - - - Previous - - `; - prevPageItem.querySelector('a').addEventListener('click', () => loadDomainRequests(currentPage - 1)); - paginationButtons.appendChild(prevPageItem); - } - - for (let i = 1; i <= numPages; i++) { - const pageItem = document.createElement('li'); - pageItem.className = 'usa-pagination__item usa-pagination__page-no'; - pageItem.innerHTML = ` - ${i} - `; - if (i === currentPage) { - pageItem.querySelector('a').classList.add('usa-current'); - pageItem.querySelector('a').setAttribute('aria-current', 'page'); - } - pageItem.querySelector('a').addEventListener('click', () => loadDomainRequests(i)); - paginationButtons.appendChild(pageItem); - } - - if (hasNext) { - const nextPageItem = document.createElement('li'); - nextPageItem.className = 'usa-pagination__item usa-pagination__arrow'; - nextPageItem.innerHTML = ` - - Next - - - `; - nextPageItem.querySelector('a').addEventListener('click', () => loadDomainRequests(currentPage + 1)); - paginationButtons.appendChild(nextPageItem); - } - } - // Add event listeners to table headers for sorting document.querySelectorAll('.dotgov-table__domain-requests th[data-sortable]').forEach(header => { header.addEventListener('click', function() { @@ -1248,3 +1298,125 @@ document.addEventListener('DOMContentLoaded', function() { loadDomainRequests(1); } }); + + + +/** + * An IIFE that hooks up the edit buttons on the finish-user-setup page + */ +(function finishUserSetupListener() { + + function getInputField(fieldName){ + return document.querySelector(`#id_${fieldName}`) + } + + // Shows the hidden input field and hides the readonly one + function showInputFieldHideReadonlyField(fieldName, button) { + let inputField = getInputField(fieldName) + let readonlyField = document.querySelector(`#${fieldName}__edit-button-readonly`) + + readonlyField.classList.toggle('display-none'); + inputField.classList.toggle('display-none'); + + // Toggle the bold style on the grid row + let gridRow = button.closest(".grid-col-2").closest(".grid-row") + if (gridRow){ + gridRow.classList.toggle("bold-usa-label") + } + } + + function handleFullNameField(fieldName = "full_name") { + // Remove the display-none class from the nearest parent div + let nameFieldset = document.querySelector("#profile-name-group"); + if (nameFieldset){ + nameFieldset.classList.remove("display-none"); + } + + // Hide the "full_name" field + let inputField = getInputField(fieldName); + if (inputField) { + inputFieldParentDiv = inputField.closest("div"); + if (inputFieldParentDiv) { + inputFieldParentDiv.classList.add("display-none"); + } + } + } + + function handleEditButtonClick(fieldName, button){ + button.addEventListener('click', function() { + // Lock the edit button while this operation occurs + button.disabled = true + + if (fieldName == "full_name"){ + handleFullNameField(); + }else { + showInputFieldHideReadonlyField(fieldName, button); + } + + // Hide the button itself + button.classList.add("display-none"); + + // Unlock after it completes + button.disabled = false + }); + } + + function setupListener(){ + document.querySelectorAll('[id$="__edit-button"]').forEach(function(button) { + // Get the "{field_name}" and "edit-button" + let fieldIdParts = button.id.split("__") + if (fieldIdParts && fieldIdParts.length > 0){ + let fieldName = fieldIdParts[0] + + // When the edit button is clicked, show the input field under it + handleEditButtonClick(fieldName, button); + } + }); + } + + function showInputOnErrorFields(){ + document.addEventListener('DOMContentLoaded', function() { + // Get all input elements within the form + let form = document.querySelector("#finish-profile-setup-form"); + let inputs = form ? form.querySelectorAll("input") : null; + if (!inputs) { + return null; + } + + let fullNameButtonClicked = false + inputs.forEach(function(input) { + let fieldName = input.name; + let errorMessage = document.querySelector(`#id_${fieldName}__error-message`); + + // If no error message is found, do nothing + if (!fieldName || !errorMessage) { + return null; + } + + let editButton = document.querySelector(`#${fieldName}__edit-button`); + if (editButton){ + // Show the input field of the field that errored out + editButton.click(); + } + + // If either the full_name field errors out, + // or if any of its associated fields do - show all name related fields. + let nameFields = ["first_name", "middle_name", "last_name"]; + if (nameFields.includes(fieldName) && !fullNameButtonClicked){ + // Click the full name button if any of its related fields error out + fullNameButton = document.querySelector("#full_name__edit-button"); + if (fullNameButton) { + fullNameButton.click(); + fullNameButtonClicked = true; + } + } + }); + }); + }; + + // Hookup all edit buttons to the `handleEditButtonClick` function + setupListener(); + + // Show the input fields if an error exists + showInputOnErrorFields(); +})(); diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index ff85db402..8ed702665 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -163,6 +163,7 @@ html[data-theme="dark"] { } } + #branding h1 a:link, #branding h1 a:visited { color: var(--primary-fg); } @@ -202,6 +203,18 @@ div#content > h2 { } } +.change-form { + .usa-table--striped tbody tr:nth-child(odd) td, + .usa-table--striped tbody tr:nth-child(odd) th, + .usa-table td, + .usa-table th { + background-color: transparent; + } + .usa-table td { + border-bottom: 1px solid var(--hairline-color); + } +} + #nav-sidebar { padding-top: 20px; } diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index dc115d69e..e88d75f4e 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -1,4 +1,5 @@ @use "uswds-core" as *; +@use "cisa_colors" as *; /* Styles for making visible to screen reader / AT users only. */ .sr-only { @@ -169,3 +170,44 @@ abbr[title] { .cursor-pointer { cursor: pointer; } + +.input-with-edit-button { + svg.usa-icon { + width: 1.5em !important; + height: 1.5em !important; + color: #{$dhs-green}; + position: absolute; + } + &.input-with-edit-button__error { + svg.usa-icon { + color: #{$dhs-red}; + } + div.readonly-field { + color: #{$dhs-red}; + } + } +} + +// 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 { + margin-top: 0px !important; + padding-top: 0px !important; + svg { + width: 1.25em !important; + height: 1.25em !important; + } +} + +// Define some styles for the .gov header/logo +.usa-logo button { + color: #{$dhs-dark-gray-85}; + font-weight: 700; + font-family: family('sans'); + font-size: 1.6rem; + line-height: 1.1; +} + +.usa-logo button.usa-button--unstyled.disabled-button:hover{ + color: #{$dhs-dark-gray-85}; +} diff --git a/src/registrar/assets/sass/_theme/_buttons.scss b/src/registrar/assets/sass/_theme/_buttons.scss index 1f5047503..4024a6f53 100644 --- a/src/registrar/assets/sass/_theme/_buttons.scss +++ b/src/registrar/assets/sass/_theme/_buttons.scss @@ -1,4 +1,5 @@ @use "uswds-core" as *; +@use "cisa_colors" as *; /* Make "placeholder" links visually obvious */ a[href$="todo"]::after { @@ -7,11 +8,16 @@ a[href$="todo"]::after { content: " [link TBD]"; font-style: italic; } - + +a.usa-link.usa-link--always-blue { + color: #{$dhs-blue}; +} + a.breadcrumb__back { display:flex; align-items: center; margin-bottom: units(2.5); + color: #{$dhs-blue}; &:visited { color: color('primary'); } diff --git a/src/registrar/assets/sass/_theme/_cisa_colors.scss b/src/registrar/assets/sass/_theme/_cisa_colors.scss index 7466a3490..23ecf7989 100644 --- a/src/registrar/assets/sass/_theme/_cisa_colors.scss +++ b/src/registrar/assets/sass/_theme/_cisa_colors.scss @@ -46,6 +46,7 @@ $dhs-gray-10: #fcfdfd; /*--- Dark Gray ---*/ $dhs-dark-gray-90: #040404; +$dhs-dark-gray-85: #1b1b1b; $dhs-dark-gray-80: #19191a; $dhs-dark-gray-70: #2f2f30; $dhs-dark-gray-60: #444547; diff --git a/src/registrar/assets/sass/_theme/_forms.scss b/src/registrar/assets/sass/_theme/_forms.scss index 058a9f6c8..c025bdb29 100644 --- a/src/registrar/assets/sass/_theme/_forms.scss +++ b/src/registrar/assets/sass/_theme/_forms.scss @@ -1,4 +1,5 @@ @use "uswds-core" as *; +@use "cisa_colors" as *; .usa-form .usa-button { margin-top: units(3); @@ -26,6 +27,34 @@ } } +.usa-form-editable { + border-top: 2px #{$dhs-dark-gray-15} solid; + + .bold-usa-label label.usa-label{ + font-weight: bold; + } + + &.bold-usa-label label.usa-label{ + font-weight: bold; + } + + &.usa-form-editable--no-border { + border-top: None; + margin-top: 0px !important; + } + +} + +.usa-form-editable > .usa-form-group:first-of-type { + margin-top: unset; +} + +@media (min-width: 35em) { + .usa-form--largest { + max-width: 35rem; + } +} + .usa-form-group--unstyled-error { margin-left: 0; padding-left: 0; @@ -52,4 +81,4 @@ legend.float-left-tablet + button.float-right-tablet { background-color: var(--body-fg); color: var(--close-button-hover-bg); } -} \ No newline at end of file +} diff --git a/src/registrar/assets/sass/_theme/_pagination.scss b/src/registrar/assets/sass/_theme/_pagination.scss new file mode 100644 index 000000000..53fa3fff9 --- /dev/null +++ b/src/registrar/assets/sass/_theme/_pagination.scss @@ -0,0 +1,15 @@ +@use "uswds-core" as *; + +.usa-pagination { + flex-wrap: wrap; + background-color: transparent; + .usa-current { + background-color: color('base-dark'); + } +} + +@include at-media(desktop) { + .usa-pagination { + flex-wrap: nowrap; + } +} diff --git a/src/registrar/assets/sass/_theme/_tooltips.scss b/src/registrar/assets/sass/_theme/_tooltips.scss index 04c6f3cda..3ab630dc0 100644 --- a/src/registrar/assets/sass/_theme/_tooltips.scss +++ b/src/registrar/assets/sass/_theme/_tooltips.scss @@ -24,3 +24,7 @@ text-align: center !important; } } + +#extended-logo .usa-tooltip__body { + font-weight: 400 !important; +} diff --git a/src/registrar/assets/sass/_theme/styles.scss b/src/registrar/assets/sass/_theme/styles.scss index 64b113a29..921976b44 100644 --- a/src/registrar/assets/sass/_theme/styles.scss +++ b/src/registrar/assets/sass/_theme/styles.scss @@ -13,6 +13,7 @@ @forward "links"; @forward "lists"; @forward "buttons"; +@forward "pagination"; @forward "forms"; @forward "tooltips"; @forward "fieldsets"; diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 9f31ffc2c..851f3550c 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -162,7 +162,7 @@ MIDDLEWARE = [ # django-cors-headers: listen to cors responses "corsheaders.middleware.CorsMiddleware", # custom middleware to stop caching from CloudFront - "registrar.no_cache_middleware.NoCacheMiddleware", + "registrar.registrar_middleware.NoCacheMiddleware", # serve static assets in production "whitenoise.middleware.WhiteNoiseMiddleware", # provide security enhancements to the request/response cycle @@ -188,6 +188,7 @@ MIDDLEWARE = [ "auditlog.middleware.AuditlogMiddleware", # Used for waffle feature flags "waffle.middleware.WaffleMiddleware", + "registrar.registrar_middleware.CheckUserProfileMiddleware", ] # application object used by Django’s built-in servers (e.g. `runserver`) @@ -326,7 +327,7 @@ SERVER_EMAIL = "root@get.gov" # endregion # region: Waffle feature flags-----------------------------------------------------------### -# If Waffle encounters a reference to a flag that is not in the database, should Waffle create the flag? +# If Waffle encounters a reference to a flag that is not in the database, create the flag automagically. WAFFLE_CREATE_MISSING_FLAGS = True # The model that will be used to keep track of flags. Extends AbstractUserFlag. diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 1279e0fd8..bf13b950e 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -180,6 +180,11 @@ urlpatterns = [ views.DomainAddUserView.as_view(), name="domain-users-add", ), + path( + "finish-profile-setup", + views.FinishProfileSetupView.as_view(), + name="finish-user-profile-setup", + ), path( "user-profile", views.UserProfileView.as_view(), diff --git a/src/registrar/forms/user_profile.py b/src/registrar/forms/user_profile.py index 11b5cc069..557e34e0d 100644 --- a/src/registrar/forms/user_profile.py +++ b/src/registrar/forms/user_profile.py @@ -60,4 +60,35 @@ class UserProfileForm(forms.ModelForm): } self.fields["phone"].error_messages["required"] = "Enter your phone number." + if self.instance and self.instance.phone: + self.fields["phone"].initial = self.instance.phone.as_national + DomainHelper.disable_field(self.fields["email"], disable_required=True) + + +class FinishSetupProfileForm(UserProfileForm): + """Form for updating user profile.""" + + full_name = forms.CharField(required=True, label="Full name") + + def clean(self): + cleaned_data = super().clean() + # Remove the full name property + if "full_name" in cleaned_data: + # Delete the full name element as its purely decorative. + # We include it as a normal Charfield for all the advantages + # and utility that it brings, but we're playing pretend. + del cleaned_data["full_name"] + return cleaned_data + + def __init__(self, *args, **kwargs): + """Override the inerited __init__ method to update the fields.""" + + super().__init__(*args, **kwargs) + + # Set custom form label for email + self.fields["email"].label = "Organization email" + self.fields["title"].label = "Title or role in your organization" + + # Define the "full_name" value + self.fields["full_name"].initial = self.instance.get_formatted_name() diff --git a/src/registrar/management/commands/clean_tables.py b/src/registrar/management/commands/clean_tables.py new file mode 100644 index 000000000..fa37c214d --- /dev/null +++ b/src/registrar/management/commands/clean_tables.py @@ -0,0 +1,68 @@ +import logging +from django.conf import settings +from django.core.management import BaseCommand +from django.apps import apps +from django.db import transaction + +from registrar.management.commands.utility.terminal_helper import TerminalHelper + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Clean tables in database to prepare for import." + + def handle(self, **options): + """Delete all rows from a list of tables""" + + if settings.IS_PRODUCTION: + logger.error("clean_tables cannot be run in production") + return + + TerminalHelper.prompt_for_execution( + system_exit_on_terminate=True, + info_to_inspect=""" + This script will delete all rows from the following tables: + * Contact + * Domain + * DomainInformation + * DomainRequest + * DraftDomain + * Host + * HostIp + * PublicContact + * User + * Website + """, + prompt_title="Do you wish to proceed with these changes?", + ) + + table_names = [ + "DomainInformation", + "DomainRequest", + "PublicContact", + "Domain", + "User", + "Contact", + "Website", + "DraftDomain", + "HostIp", + "Host", + ] + + for table_name in table_names: + self.clean_table(table_name) + + def clean_table(self, table_name): + """Delete all rows in the given table""" + try: + # Get the model class dynamically + model = apps.get_model("registrar", table_name) + # Use a transaction to ensure database integrity + with transaction.atomic(): + model.objects.all().delete() + logger.info(f"Successfully cleaned table {table_name}") + except LookupError: + logger.error(f"Model for table {table_name} not found.") + except Exception as e: + logger.error(f"Error cleaning table {table_name}: {e}") diff --git a/src/registrar/management/commands/copy_names_from_contacts_to_users.py b/src/registrar/management/commands/copy_names_from_contacts_to_users.py index 50e1bea3d..384029400 100644 --- a/src/registrar/management/commands/copy_names_from_contacts_to_users.py +++ b/src/registrar/management/commands/copy_names_from_contacts_to_users.py @@ -10,6 +10,7 @@ from registrar.management.commands.utility.terminal_helper import ( ) from registrar.models.contact import Contact from registrar.models.user import User +from registrar.models.utility.domain_helper import DomainHelper logger = logging.getLogger(__name__) @@ -110,15 +111,21 @@ class Command(BaseCommand): {TerminalColors.ENDC}""", # noqa ) - # ---- UPDATE THE USER IF IT DOES NOT HAVE A FIRST AND LAST NAMES - # ---- LET'S KEEP A LIGHT TOUCH - if not eligible_user.first_name and not eligible_user.last_name: - # (expression has type "str | None", variable has type "str | int | Combinable") - # so we'll ignore type - eligible_user.first_name = contact.first_name # type: ignore - eligible_user.last_name = contact.last_name # type: ignore - eligible_user.save() - processed_user = eligible_user + # Get the fields that exist on both User and Contact. Excludes id. + common_fields = DomainHelper.get_common_fields(User, Contact) + if "email" in common_fields: + # Don't change the email field. + common_fields.remove("email") + + for field in common_fields: + # Grab the value that contact has stored for this field + new_value = getattr(contact, field) + + # Set it on the user field + setattr(eligible_user, field, new_value) + + eligible_user.save() + processed_user = eligible_user return ( eligible_user, diff --git a/src/registrar/management/commands/export_tables.py b/src/registrar/management/commands/export_tables.py new file mode 100644 index 000000000..f927129fe --- /dev/null +++ b/src/registrar/management/commands/export_tables.py @@ -0,0 +1,64 @@ +import logging +import os +import pyzipper +from django.core.management import BaseCommand +import registrar.admin + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Exports tables in csv format to zip file in tmp directory." + + def handle(self, **options): + """Generates CSV files for specified tables and creates a zip archive""" + table_names = [ + "User", + "Contact", + "Domain", + "DomainRequest", + "DomainInformation", + "UserDomainRole", + "DraftDomain", + "Website", + "HostIp", + "Host", + "PublicContact", + ] + + # Ensure the tmp directory exists + os.makedirs("tmp", exist_ok=True) + + for table_name in table_names: + self.export_table(table_name) + + # Create a zip file containing all the CSV files + zip_filename = "tmp/exported_tables.zip" + with pyzipper.AESZipFile(zip_filename, "w", compression=pyzipper.ZIP_DEFLATED) as zipf: + for table_name in table_names: + csv_filename = f"tmp/{table_name}.csv" + if os.path.exists(csv_filename): + zipf.write(csv_filename, os.path.basename(csv_filename)) + logger.info(f"Added {csv_filename} to zip archive {zip_filename}") + + # Remove the CSV files after adding them to the zip file + for table_name in table_names: + csv_filename = f"tmp/{table_name}.csv" + if os.path.exists(csv_filename): + os.remove(csv_filename) + logger.info(f"Removed temporary file {csv_filename}") + + def export_table(self, table_name): + """Export a given table to a csv file in the tmp directory""" + resourcename = f"{table_name}Resource" + try: + resourceclass = getattr(registrar.admin, resourcename) + dataset = resourceclass().export() + filename = f"tmp/{table_name}.csv" + with open(filename, "w") as outputfile: + outputfile.write(dataset.csv) + logger.info(f"Successfully exported {table_name} to {filename}") + except AttributeError: + logger.error(f"Resource class {resourcename} not found in registrar.admin") + except Exception as e: + logger.error(f"Failed to export {table_name}: {e}") diff --git a/src/registrar/management/commands/import_tables.py b/src/registrar/management/commands/import_tables.py new file mode 100644 index 000000000..3594d3215 --- /dev/null +++ b/src/registrar/management/commands/import_tables.py @@ -0,0 +1,104 @@ +import logging +import os +import pyzipper +import tablib +from django.apps import apps +from django.conf import settings +from django.db import transaction +from django.core.management import BaseCommand +import registrar.admin + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Imports tables from a zip file, exported_tables.zip, containing CSV files in the tmp directory." + + def handle(self, **options): + """Extracts CSV files from a zip archive and imports them into the respective tables""" + + if settings.IS_PRODUCTION: + logger.error("import_tables cannot be run in production") + return + + table_names = [ + "User", + "Contact", + "Domain", + "Host", + "HostIp", + "DraftDomain", + "Website", + "DomainRequest", + "DomainInformation", + "UserDomainRole", + "PublicContact", + ] + + # Ensure the tmp directory exists + os.makedirs("tmp", exist_ok=True) + + # Unzip the file + zip_filename = "tmp/exported_tables.zip" + if not os.path.exists(zip_filename): + logger.error(f"Zip file {zip_filename} does not exist.") + return + + with pyzipper.AESZipFile(zip_filename, "r") as zipf: + zipf.extractall("tmp") + logger.info(f"Extracted zip file {zip_filename} into tmp directory") + + # Import each CSV file + for table_name in table_names: + self.import_table(table_name) + + def import_table(self, table_name): + """Import data from a CSV file into the given table""" + + resourcename = f"{table_name}Resource" + csv_filename = f"tmp/{table_name}.csv" + try: + if not os.path.exists(csv_filename): + logger.error(f"CSV file {csv_filename} not found.") + return + + # if table_name is Contact, clean the table first + # User table is loaded before Contact, and signals create + # rows in Contact table which break the import, so need + # to be cleaned again before running import on Contact table + if table_name == "Contact": + self.clean_table(table_name) + + resourceclass = getattr(registrar.admin, resourcename) + resource_instance = resourceclass() + with open(csv_filename, "r") as csvfile: + dataset = tablib.Dataset().load(csvfile.read(), format="csv") + result = resource_instance.import_data(dataset, dry_run=False, skip_epp_save=True) + + if result.has_errors(): + logger.error(f"Errors occurred while importing {csv_filename}: {result.row_errors()}") + else: + logger.info(f"Successfully imported {csv_filename} into {table_name}") + + except AttributeError: + logger.error(f"Resource class {resourcename} not found in registrar.admin") + except Exception as e: + logger.error(f"Failed to import {csv_filename}: {e}") + finally: + if os.path.exists(csv_filename): + os.remove(csv_filename) + logger.info(f"Removed temporary file {csv_filename}") + + def clean_table(self, table_name): + """Delete all rows in the given table""" + try: + # Get the model class dynamically + model = apps.get_model("registrar", table_name) + # Use a transaction to ensure database integrity + with transaction.atomic(): + model.objects.all().delete() + logger.info(f"Successfully cleaned table {table_name}") + except LookupError: + logger.error(f"Model for table {table_name} not found.") + except Exception as e: + logger.error(f"Error cleaning table {table_name}: {e}") diff --git a/src/registrar/migrations/0096_alter_contact_email_alter_contact_first_name_and_more.py b/src/registrar/migrations/0096_alter_contact_email_alter_contact_first_name_and_more.py new file mode 100644 index 000000000..68cbc625b --- /dev/null +++ b/src/registrar/migrations/0096_alter_contact_email_alter_contact_first_name_and_more.py @@ -0,0 +1,131 @@ +# Generated by Django 4.2.10 on 2024-05-28 14:40 + +from django.db import migrations, models +import phonenumber_field.modelfields + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0095_user_middle_name_user_title"), + ] + + operations = [ + migrations.AlterField( + model_name="contact", + name="email", + field=models.EmailField(blank=True, max_length=320, null=True), + ), + migrations.AlterField( + model_name="contact", + name="first_name", + field=models.CharField(blank=True, null=True, verbose_name="first name"), + ), + migrations.AlterField( + model_name="contact", + name="last_name", + field=models.CharField(blank=True, null=True, verbose_name="last name"), + ), + migrations.AlterField( + model_name="contact", + name="phone", + field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None), + ), + migrations.AlterField( + model_name="domaininformation", + name="organization_name", + field=models.CharField(blank=True, null=True), + ), + migrations.AlterField( + model_name="domaininformation", + name="zipcode", + field=models.CharField(blank=True, max_length=10, null=True, verbose_name="zip code"), + ), + migrations.AlterField( + model_name="domainrequest", + name="organization_name", + field=models.CharField(blank=True, null=True), + ), + migrations.AlterField( + model_name="domainrequest", + name="zipcode", + field=models.CharField(blank=True, max_length=10, null=True, verbose_name="zip code"), + ), + migrations.AlterField( + model_name="transitiondomain", + name="first_name", + field=models.CharField( + blank=True, help_text="First name / given name", null=True, verbose_name="first name" + ), + ), + migrations.AlterField( + model_name="transitiondomain", + name="organization_name", + field=models.CharField(blank=True, help_text="Organization name", null=True), + ), + migrations.AlterField( + model_name="transitiondomain", + name="zipcode", + field=models.CharField(blank=True, help_text="Zip code", max_length=10, null=True, verbose_name="zip code"), + ), + migrations.AlterField( + model_name="user", + name="phone", + field=phonenumber_field.modelfields.PhoneNumberField( + blank=True, help_text="Phone", max_length=128, null=True, region=None + ), + ), + migrations.AlterField( + model_name="verifiedbystaff", + name="email", + field=models.EmailField(max_length=254), + ), + migrations.AddIndex( + model_name="contact", + index=models.Index(fields=["user"], name="registrar_c_user_id_4059c4_idx"), + ), + migrations.AddIndex( + model_name="contact", + index=models.Index(fields=["email"], name="registrar_c_email_bde2de_idx"), + ), + migrations.AddIndex( + model_name="domain", + index=models.Index(fields=["name"], name="registrar_d_name_5b1956_idx"), + ), + migrations.AddIndex( + model_name="domain", + index=models.Index(fields=["state"], name="registrar_d_state_84c134_idx"), + ), + migrations.AddIndex( + model_name="domaininformation", + index=models.Index(fields=["domain"], name="registrar_d_domain__88838a_idx"), + ), + migrations.AddIndex( + model_name="domaininformation", + index=models.Index(fields=["domain_request"], name="registrar_d_domain__d1fba8_idx"), + ), + migrations.AddIndex( + model_name="domaininvitation", + index=models.Index(fields=["status"], name="registrar_d_status_e84571_idx"), + ), + migrations.AddIndex( + model_name="domainrequest", + index=models.Index(fields=["requested_domain"], name="registrar_d_request_6894eb_idx"), + ), + migrations.AddIndex( + model_name="domainrequest", + index=models.Index(fields=["approved_domain"], name="registrar_d_approve_ac4c46_idx"), + ), + migrations.AddIndex( + model_name="domainrequest", + index=models.Index(fields=["status"], name="registrar_d_status_a32b59_idx"), + ), + migrations.AddIndex( + model_name="user", + index=models.Index(fields=["username"], name="registrar_u_usernam_964b1b_idx"), + ), + migrations.AddIndex( + model_name="user", + index=models.Index(fields=["email"], name="registrar_u_email_c8f2c4_idx"), + ), + ] diff --git a/src/registrar/migrations/0097_alter_user_phone.py b/src/registrar/migrations/0097_alter_user_phone.py new file mode 100644 index 000000000..dfa5cfba8 --- /dev/null +++ b/src/registrar/migrations/0097_alter_user_phone.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.10 on 2024-06-06 18:38 + +from django.db import migrations +import phonenumber_field.modelfields + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0096_alter_contact_email_alter_contact_first_name_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="phone", + field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None), + ), + ] diff --git a/src/registrar/migrations/0098_alter_domainrequest_status.py b/src/registrar/migrations/0098_alter_domainrequest_status.py new file mode 100644 index 000000000..19fa1ded2 --- /dev/null +++ b/src/registrar/migrations/0098_alter_domainrequest_status.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.10 on 2024-06-07 15:27 + +from django.db import migrations +import django_fsm + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0097_alter_user_phone"), + ] + + operations = [ + migrations.AlterField( + model_name="domainrequest", + name="status", + field=django_fsm.FSMField( + choices=[ + ("in review", "In review"), + ("action needed", "Action needed"), + ("approved", "Approved"), + ("rejected", "Rejected"), + ("ineligible", "Ineligible"), + ("submitted", "Submitted"), + ("withdrawn", "Withdrawn"), + ("started", "Started"), + ], + default="started", + max_length=50, + ), + ), + ] diff --git a/src/registrar/models/contact.py b/src/registrar/models/contact.py index a5a6ff16c..f94938dd1 100644 --- a/src/registrar/models/contact.py +++ b/src/registrar/models/contact.py @@ -17,6 +17,14 @@ class Contact(TimeStampedModel): will be updated if any updates are made to it through Login.gov. """ + class Meta: + """Contains meta information about this class""" + + indexes = [ + models.Index(fields=["user"]), + models.Index(fields=["email"]), + ] + user = models.OneToOneField( "registrar.User", null=True, @@ -28,7 +36,6 @@ class Contact(TimeStampedModel): null=True, blank=True, verbose_name="first name", - db_index=True, ) middle_name = models.CharField( null=True, @@ -38,7 +45,6 @@ class Contact(TimeStampedModel): null=True, blank=True, verbose_name="last name", - db_index=True, ) title = models.CharField( null=True, @@ -48,13 +54,11 @@ class Contact(TimeStampedModel): email = models.EmailField( null=True, blank=True, - db_index=True, max_length=320, ) phone = PhoneNumberField( null=True, blank=True, - db_index=True, ) def _get_all_relations(self): @@ -119,11 +123,21 @@ class Contact(TimeStampedModel): self.user.last_name = self.last_name updated = True + # Update middle_name if necessary + if not self.user.middle_name: + self.user.middle_name = self.middle_name + updated = True + # Update phone if necessary if not self.user.phone: self.user.phone = self.phone updated = True + # Update title if necessary + if not self.user.title: + self.user.title = self.title + updated = True + # Save user if any updates were made if updated: self.user.save() diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index bc9508f30..26dcb89a7 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -65,6 +65,14 @@ class Domain(TimeStampedModel, DomainHelper): domain meets the required checks. """ + class Meta: + """Contains meta information about this class""" + + indexes = [ + models.Index(fields=["name"]), + models.Index(fields=["state"]), + ] + def __init__(self, *args, **kwargs): self._cache = {} super(Domain, self).__init__(*args, **kwargs) diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index 264e322b8..23c9e4f32 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -22,6 +22,16 @@ class DomainInformation(TimeStampedModel): the domain request once approved, so copying them that way we can make changes after its approved. Most fields here are copied from DomainRequest.""" + class Meta: + """Contains meta information about this class""" + + indexes = [ + models.Index(fields=["domain"]), + models.Index(fields=["domain_request"]), + ] + + verbose_name_plural = "Domain information" + StateTerritoryChoices = DomainRequest.StateTerritoryChoices # use the short names in Django admin @@ -111,7 +121,6 @@ class DomainInformation(TimeStampedModel): organization_name = models.CharField( null=True, blank=True, - db_index=True, ) address_line1 = models.CharField( null=True, @@ -138,7 +147,6 @@ class DomainInformation(TimeStampedModel): max_length=10, null=True, blank=True, - db_index=True, verbose_name="zip code", ) urbanization = models.CharField( @@ -336,6 +344,3 @@ class DomainInformation(TimeStampedModel): def _get_many_to_many_fields(): """Returns a set of each field.name that has the many to many relation""" return {field.name for field in DomainInformation._meta.many_to_many} # type: ignore - - class Meta: - verbose_name_plural = "Domain information" diff --git a/src/registrar/models/domain_invitation.py b/src/registrar/models/domain_invitation.py index 12082142d..c9cbc8b39 100644 --- a/src/registrar/models/domain_invitation.py +++ b/src/registrar/models/domain_invitation.py @@ -15,6 +15,13 @@ logger = logging.getLogger(__name__) class DomainInvitation(TimeStampedModel): + class Meta: + """Contains meta information about this class""" + + indexes = [ + models.Index(fields=["status"]), + ] + # Constants for status field class DomainInvitationStatus(models.TextChoices): INVITED = "invited", "Invited" diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 2501cdc87..994fd85eb 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -25,6 +25,15 @@ logger = logging.getLogger(__name__) class DomainRequest(TimeStampedModel): """A registrant's domain request for a new domain.""" + class Meta: + """Contains meta information about this class""" + + indexes = [ + models.Index(fields=["requested_domain"]), + models.Index(fields=["approved_domain"]), + models.Index(fields=["status"]), + ] + # https://django-auditlog.readthedocs.io/en/latest/usage.html#object-history # If we note any performace degradation due to this addition, # we can query the auditlogs table in admin.py and add the results to @@ -34,14 +43,14 @@ class DomainRequest(TimeStampedModel): # Constants for choice fields class DomainRequestStatus(models.TextChoices): - STARTED = "started", "Started" - SUBMITTED = "submitted", "Submitted" IN_REVIEW = "in review", "In review" ACTION_NEEDED = "action needed", "Action needed" APPROVED = "approved", "Approved" - WITHDRAWN = "withdrawn", "Withdrawn" REJECTED = "rejected", "Rejected" INELIGIBLE = "ineligible", "Ineligible" + SUBMITTED = "submitted", "Submitted" + WITHDRAWN = "withdrawn", "Withdrawn" + STARTED = "started", "Started" class StateTerritoryChoices(models.TextChoices): ALABAMA = "AL", "Alabama (AL)" @@ -331,7 +340,6 @@ class DomainRequest(TimeStampedModel): organization_name = models.CharField( null=True, blank=True, - db_index=True, ) address_line1 = models.CharField( @@ -360,7 +368,6 @@ class DomainRequest(TimeStampedModel): null=True, blank=True, verbose_name="zip code", - db_index=True, ) urbanization = models.CharField( null=True, @@ -938,3 +945,131 @@ class DomainRequest(TimeStampedModel): for field in opts.many_to_many: data[field.name] = field.value_from_object(self) return data + + 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) + + def _is_interstate_complete(self): + # Interstate -> "About your organization" page can't be empty + return self.about_your_organization is not None + + def _is_state_or_territory_complete(self): + # State -> ""Election office" page can't be empty + return self.is_election_board is not None + + def _is_tribal_complete(self): + # Tribal -> "Tribal name" and "Election office" page can't be empty + return self.tribe_name is not None and self.is_election_board is not None + + def _is_county_complete(self): + # County -> "Election office" page can't be empty + return self.is_election_board is not None + + def _is_city_complete(self): + # City -> "Election office" page can't be empty + return self.is_election_board is not None + + def _is_special_district_complete(self): + # Special District -> "Election office" and "About your organization" page can't be empty + return self.is_election_board is not None and self.about_your_organization is not None + + def _is_organization_name_and_address_complete(self): + return not ( + self.organization_name is None + and self.address_line1 is None + and self.city is None + and self.state_territory is None + and self.zipcode is None + ) + + def _is_authorizing_official_complete(self): + return self.authorizing_official is not None + + def _is_requested_domain_complete(self): + return self.requested_domain is not None + + def _is_purpose_complete(self): + return self.purpose is not None + + def _is_submitter_complete(self): + return self.submitter is not None + + def _has_other_contacts_and_filled(self): + # Other Contacts Radio button is Yes and if all required fields are filled + return ( + self.has_other_contacts() + and self.other_contacts.filter( + first_name__isnull=False, + last_name__isnull=False, + title__isnull=False, + email__isnull=False, + phone__isnull=False, + ).exists() + ) + + def _has_no_other_contacts_gives_rationale(self): + # Other Contacts Radio button is No and a rationale is provided + return self.has_other_contacts() is False and self.no_other_contacts_rationale is not None + + def _is_other_contacts_complete(self): + if self._has_other_contacts_and_filled() or self._has_no_other_contacts_gives_rationale(): + return True + return False + + def _cisa_rep_and_email_check(self): + # Has a CISA rep + email is NOT empty or NOT an empty string OR doesn't have CISA rep + return ( + self.has_cisa_representative is True + and self.cisa_representative_email is not None + and self.cisa_representative_email != "" + ) or self.has_cisa_representative is False + + def _anything_else_radio_button_and_text_field_check(self): + # Anything else boolean is True + filled text field and it's not an empty string OR the boolean is No + return ( + self.has_anything_else_text is True and self.anything_else is not None and self.anything_else != "" + ) or self.has_anything_else_text is False + + def _is_additional_details_complete(self): + return self._cisa_rep_and_email_check() and self._anything_else_radio_button_and_text_field_check() + + def _is_policy_acknowledgement_complete(self): + return self.is_policy_acknowledged is not None + + def _is_general_form_complete(self): + return ( + self._is_organization_name_and_address_complete() + and self._is_authorizing_official_complete() + and self._is_requested_domain_complete() + and self._is_purpose_complete() + and self._is_submitter_complete() + and self._is_other_contacts_complete() + and self._is_additional_details_complete() + and self._is_policy_acknowledgement_complete() + ) + + def _form_complete(self): + match self.generic_org_type: + case DomainRequest.OrganizationChoices.FEDERAL: + is_complete = self._is_federal_complete() + case DomainRequest.OrganizationChoices.INTERSTATE: + is_complete = self._is_interstate_complete() + case DomainRequest.OrganizationChoices.STATE_OR_TERRITORY: + is_complete = self._is_state_or_territory_complete() + case DomainRequest.OrganizationChoices.TRIBAL: + is_complete = self._is_tribal_complete() + case DomainRequest.OrganizationChoices.COUNTY: + is_complete = self._is_county_complete() + case DomainRequest.OrganizationChoices.CITY: + is_complete = self._is_city_complete() + case DomainRequest.OrganizationChoices.SPECIAL_DISTRICT: + is_complete = self._is_special_district_complete() + case _: + # NOTE: Shouldn't happen, this is only if somehow they didn't choose an org type + is_complete = False + + if not is_complete or not self._is_general_form_complete(): + return False + + return True diff --git a/src/registrar/models/transition_domain.py b/src/registrar/models/transition_domain.py index 2dafd6da4..0b0cffcec 100644 --- a/src/registrar/models/transition_domain.py +++ b/src/registrar/models/transition_domain.py @@ -59,7 +59,6 @@ class TransitionDomain(TimeStampedModel): null=True, blank=True, help_text="Organization name", - db_index=True, ) federal_type = models.CharField( max_length=50, @@ -85,7 +84,6 @@ class TransitionDomain(TimeStampedModel): blank=True, help_text="First name / given name", verbose_name="first name", - db_index=True, ) middle_name = models.CharField( null=True, @@ -136,7 +134,6 @@ class TransitionDomain(TimeStampedModel): blank=True, verbose_name="zip code", help_text="Zip code", - db_index=True, ) def __str__(self): diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index ce14c0a69..bb0276607 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -31,6 +31,17 @@ class User(AbstractUser): will be updated if any updates are made to it through Login.gov. """ + class Meta: + indexes = [ + models.Index(fields=["username"]), + models.Index(fields=["email"]), + ] + + permissions = [ + ("analyst_access_permission", "Analyst Access Permission"), + ("full_access_permission", "Full Access Permission"), + ] + class VerificationTypeChoices(models.TextChoices): """ Users achieve access to our system in a few different ways. @@ -76,8 +87,6 @@ class User(AbstractUser): phone = PhoneNumberField( null=True, blank=True, - help_text="Phone", - db_index=True, ) middle_name = models.CharField( @@ -98,6 +107,24 @@ class User(AbstractUser): help_text="The means through which this user was verified", ) + @property + def finished_setup(self): + """ + Tracks if the user finished their profile setup or not. This is so + we can globally enforce that new users provide additional account information before proceeding. + """ + + # Change this to self once the user and contact objects are merged. + # For now, since they are linked, lets test on the underlying contact object. + user_info = self.contact # noqa + user_values = [ + user_info.first_name, + user_info.last_name, + user_info.title, + user_info.phone, + ] + return None not in user_values + def __str__(self): # this info is pulled from Login.gov if self.first_name or self.last_name: @@ -263,9 +290,3 @@ class User(AbstractUser): """ self.check_domain_invitations_on_login() - - class Meta: - permissions = [ - ("analyst_access_permission", "Analyst Access Permission"), - ("full_access_permission", "Full Access Permission"), - ] diff --git a/src/registrar/models/utility/generic_helper.py b/src/registrar/models/utility/generic_helper.py index 0befd6627..ca6ce6c31 100644 --- a/src/registrar/models/utility/generic_helper.py +++ b/src/registrar/models/utility/generic_helper.py @@ -2,6 +2,7 @@ import time import logging +from urllib.parse import urlparse, urlunparse, urlencode logger = logging.getLogger(__name__) @@ -266,3 +267,34 @@ class CreateOrUpdateOrganizationTypeHelper: return False else: return True + + +def replace_url_queryparams(url_to_modify: str, query_params, convert_list_to_csv=False): + """ + Replaces the query parameters of a given URL. + Because this replaces them, this can be used to either add, delete, or modify. + Args: + url_to_modify (str): The URL whose query parameters need to be modified. + query_params (dict): Dictionary of query parameters to use. + convert_list_to_csv (bool): If the queryparam contains a list of items, + convert it to a csv representation instead. + Returns: + str: The modified URL with the updated query parameters. + """ + + # Ensure each key in query_params maps to a single value, not a list + if convert_list_to_csv: + for key, value in query_params.items(): + if isinstance(value, list): + query_params[key] = ",".join(value) + + # Split the URL into parts + url_parts = list(urlparse(url_to_modify)) + + # Modify the query param bit + url_parts[4] = urlencode(query_params) + + # Reassemble the URL + new_url = urlunparse(url_parts) + + return new_url diff --git a/src/registrar/models/verified_by_staff.py b/src/registrar/models/verified_by_staff.py index c09dce822..1e3e21057 100644 --- a/src/registrar/models/verified_by_staff.py +++ b/src/registrar/models/verified_by_staff.py @@ -9,7 +9,6 @@ class VerifiedByStaff(TimeStampedModel): email = models.EmailField( null=False, blank=False, - db_index=True, ) requestor = models.ForeignKey( diff --git a/src/registrar/no_cache_middleware.py b/src/registrar/no_cache_middleware.py deleted file mode 100644 index 5edfca20e..000000000 --- a/src/registrar/no_cache_middleware.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Middleware to add Cache-control: no-cache to every response. - -Used to force Cloudfront caching to leave us alone while we develop -better caching responses. -""" - - -class NoCacheMiddleware: - """Middleware to add a single header to every response.""" - - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - response = self.get_response(request) - response["Cache-Control"] = "no-cache" - return response diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py new file mode 100644 index 000000000..f9921513b --- /dev/null +++ b/src/registrar/registrar_middleware.py @@ -0,0 +1,100 @@ +""" +Contains middleware used in settings.py +""" + +from urllib.parse import parse_qs +from django.urls import reverse +from django.http import HttpResponseRedirect +from waffle.decorators import flag_is_active + +from registrar.models.utility.generic_helper import replace_url_queryparams + + +class NoCacheMiddleware: + """ + Middleware to add Cache-control: no-cache to every response. + + Used to force Cloudfront caching to leave us alone while we develop + better caching responses. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + response["Cache-Control"] = "no-cache" + return response + + +class CheckUserProfileMiddleware: + """ + Checks if the current user has finished_setup = False. + If they do, redirect them to the setup page regardless of where they are in + the application. + """ + + def __init__(self, get_response): + self.get_response = get_response + + self.setup_page = reverse("finish-user-profile-setup") + self.logout_page = reverse("logout") + self.excluded_pages = [ + self.setup_page, + self.logout_page, + ] + + def __call__(self, request): + response = self.get_response(request) + return response + + def process_view(self, request, view_func, view_args, view_kwargs): + """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: + if hasattr(request.user, "finished_setup") and not request.user.finished_setup: + return self._handle_setup_not_finished(request) + + # Continue processing the view + return None + + def _handle_setup_not_finished(self, request): + """Redirects the given user to the finish setup page. + + We set the "redirect" query param equal to where the user wants to go. + + If the user wants to go to '/request/', then we set that + information in the query param. + + Otherwise, we assume they want to go to the home page. + """ + + # In some cases, we don't want to redirect to home. This handles that. + # Can easily be generalized if need be, but for now lets keep this easy to read. + custom_redirect = "domain-request:" if request.path == "/request/" else None + + # Don't redirect on excluded pages (such as the setup page itself) + if not any(request.path.startswith(page) for page in self.excluded_pages): + + # Preserve the original query parameters, and coerce them into a dict + query_params = parse_qs(request.META["QUERY_STRING"]) + + # Set the redirect value to our redirect location + if custom_redirect is not None: + query_params["redirect"] = custom_redirect + + # Add our new query param, while preserving old ones + new_setup_page = replace_url_queryparams(self.setup_page, query_params) if query_params else self.setup_page + + return HttpResponseRedirect(new_setup_page) + else: + # Process the view as normal + return None diff --git a/src/registrar/signals.py b/src/registrar/signals.py index 4e7768ef4..bc0480b2a 100644 --- a/src/registrar/signals.py +++ b/src/registrar/signals.py @@ -24,9 +24,11 @@ def handle_profile(sender, instance, **kwargs): """ first_name = getattr(instance, "first_name", "") + middle_name = getattr(instance, "middle_name", "") last_name = getattr(instance, "last_name", "") email = getattr(instance, "email", "") phone = getattr(instance, "phone", "") + title = getattr(instance, "title", "") is_new_user = kwargs.get("created", False) @@ -39,9 +41,11 @@ def handle_profile(sender, instance, **kwargs): Contact.objects.create( user=instance, first_name=first_name, + middle_name=middle_name, last_name=last_name, email=email, phone=phone, + title=title, ) if len(contacts) >= 1 and is_new_user: # a matching contact diff --git a/src/registrar/templates/base.html b/src/registrar/templates/base.html index 958813029..fc49c19ec 100644 --- a/src/registrar/templates/base.html +++ b/src/registrar/templates/base.html @@ -139,11 +139,7 @@
{% block logo %} - + {% include "includes/gov_extended_logo.html" with logo_clickable=True %} {% endblock %}
@@ -160,7 +156,8 @@ {% if has_profile_feature_flag %}
  • {% url 'user-profile' as user_profile_url %} -
  • @@ -206,7 +203,9 @@
    {% endblock wrapper%} - {% include "includes/footer.html" %} + {% block footer %} + {% include "includes/footer.html" with show_manage_your_domains=True %} + {% endblock footer %} {% block init_js %}{% endblock %}{# useful for vars and other initializations #} diff --git a/src/registrar/templates/domain_request_form.html b/src/registrar/templates/domain_request_form.html index cde12ad80..17948a110 100644 --- a/src/registrar/templates/domain_request_form.html +++ b/src/registrar/templates/domain_request_form.html @@ -105,7 +105,7 @@ aria-describedby="Are you sure you want to submit a domain request?" data-force-action > - {% include 'includes/modal.html' with modal_heading=modal_heading|safe modal_description="Once you submit this request, you won’t be able to edit it until we review it. You’ll only be able to withdraw your request." modal_button=modal_button|safe %} + {% include 'includes/modal.html' with is_domain_request_form=True review_form_is_complete=review_form_is_complete modal_heading=modal_heading|safe modal_description=modal_description|safe modal_button=modal_button|safe %} {% block after_form_content %}{% endblock %} diff --git a/src/registrar/templates/domain_request_intro.html b/src/registrar/templates/domain_request_intro.html index d6d3b3b7f..285777a80 100644 --- a/src/registrar/templates/domain_request_intro.html +++ b/src/registrar/templates/domain_request_intro.html @@ -13,12 +13,12 @@

    We’ll use the information you provide to verify your organization’s eligibility for a .gov domain. We’ll also verify that the domain you request meets our guidelines.

    Time to complete the form

    If you have all the information you need, - completing your domain request might take around 15 minutes.

    - {% if has_profile_feature_flag %} -

    How we’ll reach you

    -

    While reviewing your domain request, we may need to reach out with questions. We’ll also email you when we complete our review If the contact information below is not correct, visit your profile to make updates.

    - {% include "includes/profile_information.html" with user=user%} - {% endif %} + completing your domain request might take around 15 minutes.

    + {% if has_profile_feature_flag %} +

    How we’ll reach you

    +

    While reviewing your domain request, we may need to reach out with questions. We’ll also email you when we complete our review If the contact information below is not correct, visit your profile to make updates.

    + {% include "includes/profile_information.html" with user=user%} + {% endif %} {% block form_buttons %} diff --git a/src/registrar/templates/domain_request_review.html b/src/registrar/templates/domain_request_review.html index 5f359e95f..1f21683a5 100644 --- a/src/registrar/templates/domain_request_review.html +++ b/src/registrar/templates/domain_request_review.html @@ -25,11 +25,11 @@ {% if step == Step.ORGANIZATION_TYPE %} {% namespaced_url 'domain-request' step as domain_request_url %} {% if domain_request.generic_org_type is not None %} - {% with title=form_titles|get_item:step value=domain_request.get_generic_org_type_display|default:"Incomplete" %} + {% with title=form_titles|get_item:step value=domain_request.get_generic_org_type_display|default:"Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% else %} - {% with title=form_titles|get_item:step value="Incomplete" %} + {% with title=form_titles|get_item:step value="Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -37,7 +37,7 @@ {% if step == Step.TRIBAL_GOVERNMENT %} {% namespaced_url 'domain-request' step as domain_request_url %} - {% with title=form_titles|get_item:step value=domain_request.tribe_name|default:"Incomplete" %} + {% with title=form_titles|get_item:step value=domain_request.tribe_name|default:"Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% if domain_request.federally_recognized_tribe %}

    Federally-recognized tribe

    {% endif %} @@ -47,7 +47,7 @@ {% if step == Step.ORGANIZATION_FEDERAL %} {% namespaced_url 'domain-request' step as domain_request_url %} - {% with title=form_titles|get_item:step value=domain_request.get_federal_type_display|default:"Incomplete" %} + {% with title=form_titles|get_item:step value=domain_request.get_federal_type_display|default:"Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -66,7 +66,7 @@ {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url address='true' %} {% endwith %} {% else %} - {% with title=form_titles|get_item:step value='Incomplete' %} + {% with title=form_titles|get_item:step value="Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -74,7 +74,7 @@ {% if step == Step.ABOUT_YOUR_ORGANIZATION %} {% namespaced_url 'domain-request' step as domain_request_url %} - {% with title=form_titles|get_item:step value=domain_request.about_your_organization|default:"Incomplete" %} + {% with title=form_titles|get_item:step value=domain_request.about_your_organization|default:"Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -86,7 +86,7 @@ {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url contact='true' %} {% endwith %} {% else %} - {% with title=form_titles|get_item:step value="Incomplete" %} + {% with title=form_titles|get_item:step value="Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -107,7 +107,7 @@ {% if step == Step.DOTGOV_DOMAIN %} {% namespaced_url 'domain-request' step as domain_request_url %} - {% with title=form_titles|get_item:step value=domain_request.requested_domain.name|default:"Incomplete" %} + {% with title=form_titles|get_item:step value=domain_request.requested_domain.name|default:"Incomplete"|safe%} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} @@ -123,7 +123,7 @@ {% if step == Step.PURPOSE %} {% namespaced_url 'domain-request' step as domain_request_url %} - {% with title=form_titles|get_item:step value=domain_request.purpose|default:"Incomplete" %} + {% with title=form_titles|get_item:step value=domain_request.purpose|default:"Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -135,7 +135,7 @@ {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url contact='true' %} {% endwith %} {% else %} - {% with title=form_titles|get_item:step value="Incomplete" %} + {% with title=form_titles|get_item:step value="Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -148,7 +148,7 @@ {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url contact='true' list='true' %} {% endwith %} {% else %} - {% with title=form_titles|get_item:step value=domain_request.no_other_contacts_rationale|default:"Incomplete" %} + {% with title=form_titles|get_item:step value=domain_request.no_other_contacts_rationale|default:"Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -157,7 +157,7 @@ {% if step == Step.ADDITIONAL_DETAILS %} {% namespaced_url 'domain-request' step as domain_request_url %} - {% with title=form_titles|get_item:step value=domain_request.requested_domain.name|default:"Incomplete" %} + {% with title=form_titles|get_item:step value=domain_request.requested_domain.name|default:"Incomplete"|safe %} {% include "includes/summary_item.html" with title=title sub_header_text='CISA regional representative' value=domain_request.cisa_representative_email heading_level=heading_level editable=True edit_link=domain_request_url custom_text_for_value_none='No' %} {% endwith %} diff --git a/src/registrar/templates/finish_profile_setup.html b/src/registrar/templates/finish_profile_setup.html new file mode 100644 index 000000000..f8070551b --- /dev/null +++ b/src/registrar/templates/finish_profile_setup.html @@ -0,0 +1,20 @@ +{% extends "profile.html" %} + +{% load static form_helpers url_helpers field_helpers %} +{% block title %} Finish setting up your profile | {% endblock %} + +{# Disable the redirect #} +{% block logo %} + {% include "includes/gov_extended_logo.html" with logo_clickable=confirm_changes %} +{% endblock %} + +{# Add the new form #} +{% block content_bottom %} + {% include "includes/finish_profile_form.html" with form=form %} + + +{% endblock content_bottom %} + +{% block footer %} + {% include "includes/footer.html" with show_manage_your_domains=confirm_changes %} +{% endblock footer %} diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html index 9a5082104..fd54769a8 100644 --- a/src/registrar/templates/home.html +++ b/src/registrar/templates/home.html @@ -63,7 +63,7 @@