Merge branch 'main' into za/2760-portfolio-domain-request-entry-point-2

This commit is contained in:
zandercymatics 2024-10-09 08:29:51 -06:00
commit cd7b45a7f4
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
26 changed files with 1633 additions and 1330 deletions

View file

@ -14,6 +14,7 @@ on:
options: options:
- ab - ab
- backup - backup
- el
- cb - cb
- dk - dk
- es - es

View file

@ -30,6 +30,7 @@ jobs:
|| startsWith(github.head_ref, 'ag/') || startsWith(github.head_ref, 'ag/')
|| startsWith(github.head_ref, 'ms/') || startsWith(github.head_ref, 'ms/')
|| startsWith(github.head_ref, 'ad/') || startsWith(github.head_ref, 'ad/')
|| startsWith(github.head_ref, 'el/')
outputs: outputs:
environment: ${{ steps.var.outputs.environment}} environment: ${{ steps.var.outputs.environment}}
runs-on: "ubuntu-latest" runs-on: "ubuntu-latest"

View file

@ -16,6 +16,7 @@ on:
- stable - stable
- staging - staging
- development - development
- el
- ad - ad
- ms - ms
- ag - ag

View file

@ -16,6 +16,7 @@ on:
options: options:
- staging - staging
- development - development
- el
- ad - ad
- ms - ms
- ag - ag

View file

@ -0,0 +1,32 @@
---
applications:
- name: getgov-el
buildpacks:
- python_buildpack
path: ../../src
instances: 1
memory: 512M
stack: cflinuxfs4
timeout: 180
command: ./run.sh
health-check-type: http
health-check-http-endpoint: /health
health-check-invocation-timeout: 40
env:
# Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup
# Tell Django where to find its configuration
DJANGO_SETTINGS_MODULE: registrar.config.settings
# Tell Django where it is being hosted
DJANGO_BASE_URL: https://getgov-el.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments
IS_PRODUCTION: False
routes:
- route: getgov-el.app.cloud.gov
services:
- getgov-credentials
- getgov-el-database

View file

@ -1976,18 +1976,30 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# If the status is not mapped properly, saving could cause # If the status is not mapped properly, saving could cause
# weird issues down the line. Instead, we should block this. # weird issues down the line. Instead, we should block this.
# NEEDS A UNIT TEST
should_proceed = False should_proceed = False
return should_proceed return (obj, should_proceed)
request_is_not_approved = obj.status != models.DomainRequest.DomainRequestStatus.APPROVED obj_is_not_approved = obj.status != models.DomainRequest.DomainRequestStatus.APPROVED
if request_is_not_approved and not obj.domain_is_not_active(): if obj_is_not_approved and not obj.domain_is_not_active():
# If an admin tried to set an approved domain request to # REDUNDANT CHECK / ERROR SCREEN AVOIDANCE:
# another status and the related domain is already # This action (moving a request from approved to
# active, shortcut the action and throw a friendly # another status) when the domain is already active (READY),
# error message. This action would still not go through # would still not go through even without this check as the rules are
# shortcut or not as the rules are duplicated on the model, # duplicated in the model and the error is raised from the model.
# but the error would be an ugly Django error screen. # This avoids an ugly Django error screen.
error_message = "This action is not permitted. The domain is already active." error_message = "This action is not permitted. The domain is already active."
elif (
original_obj.status != models.DomainRequest.DomainRequestStatus.APPROVED
and obj.status == models.DomainRequest.DomainRequestStatus.APPROVED
and original_obj.requested_domain is not None
and Domain.objects.filter(name=original_obj.requested_domain.name).exists()
):
# REDUNDANT CHECK:
# This action (approving a request when the domain exists)
# would still not go through even without this check as the rules are
# duplicated in the model and the error is raised from the model.
error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.APPROVE_DOMAIN_IN_USE)
elif obj.status == models.DomainRequest.DomainRequestStatus.REJECTED and not obj.rejection_reason: elif obj.status == models.DomainRequest.DomainRequestStatus.REJECTED and not obj.rejection_reason:
# This condition should never be triggered. # This condition should never be triggered.
# The opposite of this condition is acceptable (rejected -> other status and rejection_reason) # The opposite of this condition is acceptable (rejected -> other status and rejection_reason)

View file

@ -515,10 +515,14 @@ document.addEventListener('DOMContentLoaded', function() {
const formLabel = document.querySelector('label[for="id_action_needed_reason_email"]'); const formLabel = document.querySelector('label[for="id_action_needed_reason_email"]');
let lastSentEmailContent = document.getElementById("last-sent-email-content"); let lastSentEmailContent = document.getElementById("last-sent-email-content");
const initialDropdownValue = dropdown ? dropdown.value : null; const initialDropdownValue = dropdown ? dropdown.value : null;
const initialEmailValue = textarea.value; let initialEmailValue;
if (textarea)
initialEmailValue = textarea.value
// We will use the const to control the modal // We will use the const to control the modal
let isEmailAlreadySentConst = lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, ''); let isEmailAlreadySentConst;
if (lastSentEmailContent)
isEmailAlreadySentConst = lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, '');
// We will use the function to control the label and help // We will use the function to control the label and help
function isEmailAlreadySent() { function isEmailAlreadySent() {
return lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, ''); return lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, '');
@ -706,18 +710,6 @@ document.addEventListener('DOMContentLoaded', function() {
} }
return ''; return '';
} }
// Extract the submitter name, title, email, and phone number
const submitterDiv = document.querySelector('.form-row.field-submitter');
const submitterNameElement = document.getElementById('id_submitter');
// We have to account for different superuser and analyst markups
const submitterName = submitterNameElement
? submitterNameElement.options[submitterNameElement.selectedIndex].text
: submitterDiv.querySelector('a').text;
const submitterTitle = extractTextById('contact_info_title', submitterDiv);
const submitterEmail = extractTextById('contact_info_email', submitterDiv);
const submitterPhone = extractTextById('contact_info_phone', submitterDiv);
let submitterInfo = `${submitterName}${submitterTitle}${submitterEmail}${submitterPhone}`;
//------ Senior Official //------ Senior Official
const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official'); const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official');
@ -734,7 +726,6 @@ document.addEventListener('DOMContentLoaded', function() {
`<strong>Current Websites:</strong> ${existingWebsites.join(', ')}</br>` + `<strong>Current Websites:</strong> ${existingWebsites.join(', ')}</br>` +
`<strong>Rationale:</strong></br>` + `<strong>Rationale:</strong></br>` +
`<strong>Alternative Domains:</strong> ${alternativeDomains.join(', ')}</br>` + `<strong>Alternative Domains:</strong> ${alternativeDomains.join(', ')}</br>` +
`<strong>Submitter:</strong> ${submitterInfo}</br>` +
`<strong>Senior Official:</strong> ${seniorOfficialInfo}</br>` + `<strong>Senior Official:</strong> ${seniorOfficialInfo}</br>` +
`<strong>Other Employees:</strong> ${otherContactsSummary}</br>`; `<strong>Other Employees:</strong> ${otherContactsSummary}</br>`;

View file

@ -1498,12 +1498,23 @@ class DomainsTable extends LoadTableBase {
} }
} }
class DomainRequestsTable extends LoadTableBase { class DomainRequestsTable extends LoadTableBase {
constructor() { 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'); 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');
} }
toggleExportButton(requests) {
const exportButton = document.getElementById('export-csv');
if (exportButton) {
if (requests.length > 0) {
showElement(exportButton);
} else {
hideElement(exportButton);
}
}
}
/** /**
* Loads rows in the domains list, as well as updates pagination around the domains list * Loads rows in the domains list, as well as updates pagination around the domains list
* based on the supplied attributes. * based on the supplied attributes.
@ -1517,6 +1528,7 @@ class DomainRequestsTable extends LoadTableBase {
*/ */
loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm = this.currentSearchTerm, portfolio = this.portfolioValue) { 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"); let baseUrl = document.getElementById("get_domain_requests_json_url");
if (!baseUrl) { if (!baseUrl) {
return; return;
} }
@ -1548,6 +1560,9 @@ class DomainRequestsTable extends LoadTableBase {
return; return;
} }
// Manage "export as CSV" visibility for domain requests
this.toggleExportButton(data.domain_requests);
// handle the display of proper messaging in the event that no requests exist in the list or search returns no results // handle the display of proper messaging in the event that no requests exist in the list or search returns no results
this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm); this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm);

View file

@ -385,6 +385,7 @@ a.button,
font-kerning: auto; font-kerning: auto;
font-family: inherit; font-family: inherit;
font-weight: normal; font-weight: normal;
text-decoration: none !important;
} }
.button svg, .button svg,
.button span, .button span,
@ -392,6 +393,9 @@ a.button,
.usa-button--dja span { .usa-button--dja span {
vertical-align: middle; vertical-align: middle;
} }
.usa-button--dja.usa-button--unstyled {
color: var(--link-fg);
}
.usa-button--dja:not(.usa-button--unstyled, .usa-button--outline, .usa-modal__close, .usa-button--secondary) { .usa-button--dja:not(.usa-button--unstyled, .usa-button--outline, .usa-modal__close, .usa-button--secondary) {
background: var(--button-bg); background: var(--button-bg);
} }
@ -421,11 +425,34 @@ input[type=submit].button--dja-toolbar {
input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-toolbar:hover { input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-toolbar:hover {
border-color: var(--body-quiet-color); border-color: var(--body-quiet-color);
} }
// Targets the DJA buttom with a nested icon .admin-icon-group {
button .usa-icon, position: relative;
.button .usa-icon, display: inline;
.button--clipboard .usa-icon { align-items: center;
vertical-align: middle;
input {
// Allow for padding around the copy button
padding-right: 35px !important;
}
button {
width: max-content;
}
@media (max-width: 1000px) {
button {
display: block;
}
}
span {
padding-left: 0.05rem;
}
}
.usa-button__small-text,
.usa-button__small-text span {
font-size: 13px;
} }
.module--custom { .module--custom {
@ -673,71 +700,10 @@ address.dja-address-contact-list {
} }
} }
// Make the clipboard button "float" inside of the input box
.admin-icon-group {
position: relative;
display: inline;
align-items: center;
input {
// Allow for padding around the copy button
padding-right: 35px !important;
// Match the height of other inputs
min-height: 2.25rem !important;
}
button {
line-height: 14px;
width: max-content;
font-size: unset;
text-decoration: none !important;
}
@media (max-width: 1000px) {
button {
display: block;
padding-top: 8px;
}
}
span {
padding-left: 0.1rem;
}
}
.admin-icon-group.admin-icon-group__clipboard-link {
position: relative;
display: inline;
align-items: center;
.usa-button--icon {
position: absolute;
right: auto;
left: 4px;
height: 100%;
top: -1px;
}
button {
font-size: unset !important;
display: inline-flex;
padding-top: 4px;
line-height: 14px;
width: max-content;
font-size: unset;
text-decoration: none !important;
}
}
.no-outline-on-click:focus { .no-outline-on-click:focus {
outline: none !important; outline: none !important;
} }
.usa-button__small-text {
font-size: small;
}
// Get rid of padding on all help texts // Get rid of padding on all help texts
form .aligned p.help, form .aligned div.help { form .aligned p.help, form .aligned div.help {
padding-left: 0px !important; padding-left: 0px !important;
@ -887,6 +853,9 @@ div.dja__model-description{
padding-top: 0 !important; padding-top: 0 !important;
} }
.padding-bottom-0 {
padding-bottom: 0 !important;
}
.flex-container { .flex-container {
@media screen and (min-width: 700px) and (max-width: 1150px) { @media screen and (min-width: 700px) and (max-width: 1150px) {

View file

@ -723,6 +723,7 @@ ALLOWED_HOSTS = [
"getgov-stable.app.cloud.gov", "getgov-stable.app.cloud.gov",
"getgov-staging.app.cloud.gov", "getgov-staging.app.cloud.gov",
"getgov-development.app.cloud.gov", "getgov-development.app.cloud.gov",
"getgov-el.app.cloud.gov",
"getgov-ad.app.cloud.gov", "getgov-ad.app.cloud.gov",
"getgov-ms.app.cloud.gov", "getgov-ms.app.cloud.gov",
"getgov-ag.app.cloud.gov", "getgov-ag.app.cloud.gov",

View file

@ -20,6 +20,7 @@ from registrar.views.report_views import (
AnalyticsView, AnalyticsView,
ExportDomainRequestDataFull, ExportDomainRequestDataFull,
ExportDataTypeUser, ExportDataTypeUser,
ExportDataTypeRequests,
) )
# --jsons # --jsons
@ -180,6 +181,16 @@ urlpatterns = [
ExportDataTypeUser.as_view(), ExportDataTypeUser.as_view(),
name="export_data_type_user", name="export_data_type_user",
), ),
path(
"reports/export_data_type_requests/",
ExportDataTypeRequests.as_view(),
name="export_data_type_requests",
),
path(
"reports/export_data_type_requests/",
ExportDataTypeRequests.as_view(),
name="export_data_type_requests",
),
path( path(
"domain-request/<int:id>/edit/", "domain-request/<int:id>/edit/",
views.DomainRequestWizard.as_view(), views.DomainRequestWizard.as_view(),

View file

@ -229,6 +229,10 @@ class User(AbstractUser):
"""Determines if the current user can view all available domains in a given portfolio""" """Determines if the current user can view all available domains in a given portfolio"""
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS) return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
def has_view_all_domain_requests_portfolio_permission(self, portfolio):
"""Determines if the current user can view all available domains in a given portfolio"""
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)
def has_any_requests_portfolio_permission(self, portfolio): def has_any_requests_portfolio_permission(self, portfolio):
# BEGIN # BEGIN
# Note code below is to add organization_request feature # Note code below is to add organization_request feature
@ -458,3 +462,12 @@ class User(AbstractUser):
return DomainInformation.objects.filter(portfolio=portfolio).values_list("domain_id", flat=True) return DomainInformation.objects.filter(portfolio=portfolio).values_list("domain_id", flat=True)
else: else:
return UserDomainRole.objects.filter(user=self).values_list("domain_id", flat=True) return UserDomainRole.objects.filter(user=self).values_list("domain_id", flat=True)
def get_user_domain_request_ids(self, request):
"""Returns either the domain request ids associated with this user on UserDomainRole or Portfolio"""
portfolio = request.session.get("portfolio")
if self.is_org_user(request) and self.has_view_all_domain_requests_portfolio_permission(portfolio):
return DomainRequest.objects.filter(portfolio=portfolio).values_list("id", flat=True)
else:
return UserDomainRole.objects.filter(user=self).values_list("id", flat=True)

View file

@ -20,10 +20,11 @@
</li> </li>
{% if opts.model_name == 'domainrequest' %} {% if opts.model_name == 'domainrequest' %}
<li> <li>
<a id="id-copy-to-clipboard-summary" class="button--clipboard" type="button" href="#"> <a id="id-copy-to-clipboard-summary" class="usa-button--dja" type="button" href="#">
<svg class="usa-icon" > <svg class="usa-icon" >
<use xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use> <use xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg> </svg>
<!-- the span is targeted in JS, do not remove -->
<span>{% translate "Copy request summary" %}</span> <span>{% translate "Copy request summary" %}</span>
</a> </a>
</li> </li>

View file

@ -8,7 +8,7 @@ Template for an input field with a clipboard
<div class="admin-icon-group"> <div class="admin-icon-group">
{{ field }} {{ field }}
<button <button
class="usa-button usa-button--unstyled padding-left-1 usa-button--icon button--clipboard copy-to-clipboard" class="usa-button--dja usa-button usa-button__small-text usa-button--unstyled padding-left-1 usa-button--icon copy-to-clipboard"
type="button" type="button"
> >
<div class="no-outline-on-click"> <div class="no-outline-on-click">
@ -17,23 +17,27 @@ Template for an input field with a clipboard
> >
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use> <use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg> </svg>
Copy <!-- the span is targeted in JS, do not remove -->
<span>Copy</span>
</div> </div>
</button> </button>
</div> </div>
{% else %} {% else %}
<div class="admin-icon-group admin-icon-group__clipboard-link"> <div class="admin-icon-group">
<input aria-hidden="true" class="display-none" value="{{ field.email }}" /> <input aria-hidden="true" class="display-none" value="{{ field.email }}" />
<button {% if field.email is not None %}
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard text-no-underline padding-left-05" <button
type="button" class="usa-button--dja usa-button usa-button__small-text usa-button--unstyled padding-right-1 usa-button--icon copy-to-clipboard text-no-underline padding-left-05"
> type="button"
<svg
class="usa-icon"
> >
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use> <svg
</svg> class="usa-icon"
Copy >
</button> <use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
<!-- the span is targeted in JS, do not remove -->
<span>Copy</span>
</button>
{% endif %}
</div> </div>
{% endif %} {% endif %}

View file

@ -26,7 +26,7 @@
{% if user.email %} {% if user.email %}
<span id="contact_info_email">{{ user.email }}</span> <span id="contact_info_email">{{ user.email }}</span>
{% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %} {% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
<br class="admin-icon-group__br"> <br>
{% else %} {% else %}
None<br> None<br>
{% endif %} {% endif %}

View file

@ -254,7 +254,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
<table> <table>
<thead> <thead>
<tr> <tr>
<th colspan="4">Other contact information</th> <th colspan="5">Other contact information</th>
<tr> <tr>
</thead> </thead>
<tbody> <tbody>
@ -267,18 +267,31 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
</td> </td>
<td class="padding-left-1">{{ contact.phone }}</td> <td class="padding-left-1">{{ contact.phone }}</td>
<td class="padding-left-1 text-size-small"> <td class="padding-left-1 text-size-small">
<input aria-hidden="true" class="display-none" value="{{ contact.email }}" /> {% if contact.email %}
<button <input aria-hidden="true" class="display-none" value="{{ contact.email }}" />
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard usa-button__small-text text-no-underline" <button
type="button" class="
> usa-button--dja
<svg usa-button
class="usa-icon" usa-button__small-text
usa-button--unstyled
padding-right-1
padding-top-0
padding-bottom-0
usa-button--icon
copy-to-clipboard
text-no-underline"
type="button"
> >
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use> <svg
</svg> class="usa-icon"
<span>Copy email</span> >
</button> <use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
<!-- the span is targeted in JS, do not remove -->
<span>Copy email</span>
</button>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View file

@ -3,204 +3,203 @@
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %} {% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get_domain_requests_json' as url %} {% url 'get_domain_requests_json' as url %}
<span id="get_domain_requests_json_url" class="display-none">{{url}}</span> <span id="get_domain_requests_json_url" class="display-none">{{url}}</span>
<section class="section-outlined domain-requests{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domain-requests"> <section class="section-outlined domain-requests{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domain-requests">
<div class="grid-row"> <div class="section-outlined__header margin-bottom-3 {% if not portfolio %} section-outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
{% if not portfolio %} {% if not portfolio %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <h2 id="domain-requests-header" class="display-inline-block">Domain requests</h2>
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
</div>
{% else %} {% else %}
<!-- Embedding the portfolio value in a data attribute --> <!-- Embedding the portfolio value in a data attribute -->
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span> <span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
{% endif %} {% endif %}
<div class="mobile:grid-col-12 desktop:grid-col-6">
<section aria-label="Domain requests search component" class="flex-6 margin-y-2"> <div class="section-outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %} {% if is_widescreen_mode %} section-outlined__search--widescreen {% endif %}">
<form class="usa-search usa-search--small" method="POST" role="search"> <section aria-label="Domain requests search component" class="margin-top-2">
{% csrf_token %} <form class="usa-search usa-search--small" method="POST" role="search">
<button class="usa-button usa-button--unstyled margin-right-2 domain-requests__reset-search display-none" type="button"> {% csrf_token %}
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24"> <button class="usa-button usa-button--unstyled margin-right-3 domain-requests__reset-search display-none" type="button">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
</svg> <use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
Reset </svg>
</button> Reset
{% if portfolio %} </button>
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name or creator</label> {% if portfolio %}
{% else %} <label class="usa-sr-only" for="domain-requests__search-field">Search by domain name or creator</label>
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name</label> {% else %}
{% endif %} <label class="usa-sr-only" for="domain-requests__search-field">Search by domain name</label>
<input {% endif %}
class="usa-input" <input
id="domain-requests__search-field" class="usa-input"
type="search" id="domain-requests__search-field"
name="search" type="search"
{% if portfolio %} name="search"
placeholder="Search by domain name or creator" {% if portfolio %}
{% else %} placeholder="Search by domain name or creator"
placeholder="Search by domain name" {% else %}
{% endif %} placeholder="Search by domain name"
/> {% endif %}
<button class="usa-button" type="submit" id="domain-requests__search-field-submit"> />
<img <button class="usa-button" type="submit" id="domain-requests__search-field-submit">
src="{% static 'img/usa-icons-bg/search--white.svg' %}" <img
class="usa-search__submit-icon" src="{% static 'img/usa-icons-bg/search--white.svg' %}"
alt="Search" class="usa-search__submit-icon"
/> alt="Search"
</button> />
</form> </button>
</section> </form>
</div> </section>
</div>
{% if portfolio %}
<div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}" id="export-csv">
<section aria-label="Domain Requests report component" class="margin-top-205">
<a href="{% url 'export_data_type_requests' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right" role="button">
<svg class="usa-icon usa-icon--big" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{% static 'img/sprite.svg' %}#file_download"></use>
</svg>Export as CSV
</a>
</section>
</div>
{% endif %}
</div> </div>
{% if portfolio %} {% if portfolio %}
<div class="display-flex flex-align-center"> <div class="display-flex flex-align-center">
<span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span> <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 usa-accordion--select margin-right-2">
<div class="usa-accordion__heading"> <div class="usa-accordion__heading">
<button <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" type="button"
class="usa-button usa-button--small padding--8-8-9 usa-button--outline usa-button--filter usa-accordion__button" class="usa-button usa-button--small padding--8-12-9-12 usa-button--outline usa-button--filter domain-requests__reset-filters display-none"
aria-expanded="false" >
aria-controls="filter-status" Clear filters
> <svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
<span class="filter-indicator text-bold display-none"></span> Status <use xlink:href="/public/img/sprite.svg#close"></use>
<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> </svg>
</button> </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> </div>
{% endif %} {% endif %}
<div class="domain-requests__table-wrapper display-none usa-table-container--scrollable margin-top-0" tabindex="0"> <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"> <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> <caption class="sr-only">Your domain requests</caption>
<thead> <thead>
<tr> <tr>
<th data-sortable="requested_domain__name" scope="col" role="columnheader">Domain name</th> <th data-sortable="requested_domain__name" scope="col" role="columnheader">Domain name</th>
<th data-sortable="last_submitted_date" scope="col" role="columnheader">Submitted</th> <th data-sortable="last_submitted_date" scope="col" role="columnheader">Submitted</th>
{% if portfolio %} {% if portfolio %}
<th data-sortable="creator" scope="col" role="columnheader">Created by</th> <th data-sortable="creator" scope="col" role="columnheader">Created by</th>
{% endif %} {% endif %}
<th data-sortable="status" scope="col" role="columnheader">Status</th> <th data-sortable="status" scope="col" role="columnheader">Status</th>
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th> <th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th>
<!-- AJAX will conditionally add a th for delete actions --> <!-- AJAX will conditionally add a th for delete actions -->
</tr> </tr>
</thead> </thead>
<tbody id="domain-requests-tbody"> <tbody id="domain-requests-tbody">
<!-- AJAX will populate this tbody --> <!-- AJAX will populate this tbody -->
</tbody> </tbody>
</table> </table>
<div <div class="usa-sr-only usa-table__announcement-region" aria-live="polite"></div>
class="usa-sr-only usa-table__announcement-region"
aria-live="polite"
></div>
</div> </div>
<div class="domain-requests__no-data display-none"> <div class="domain-requests__no-data display-none">
<p>You haven't requested any domains.</p> <p>You haven't requested any domains.</p>
</div> </div>
<div class="domain-requests__no-search-results display-none"> <div class="domain-requests__no-search-results display-none">
<p>No results found</p> <p>No results found</p>
</div> </div>
</section> </section>
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination">
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination">
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1"> <span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
<!-- Count will be dynamically populated by JS --> <!-- Count will be dynamically populated by JS -->
</span> </span>

View file

@ -654,7 +654,7 @@ class TestDomainInformationAdmin(TestCase):
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
# Test for the copy link # Test for the copy link
self.assertContains(response, "button--clipboard", count=3) self.assertContains(response, "copy-to-clipboard", count=3)
# cleanup this test # cleanup this test
domain_info.delete() domain_info.delete()

View file

@ -535,7 +535,7 @@ class TestDomainAdminWithClient(TestCase):
self.assertContains(response, "Testy Tester") self.assertContains(response, "Testy Tester")
# Test for the copy link # Test for the copy link
self.assertContains(response, "button--clipboard") self.assertContains(response, "copy-to-clipboard")
# cleanup from this test # cleanup from this test
domain.delete() domain.delete()

View file

@ -1511,7 +1511,7 @@ class TestDomainRequestAdmin(MockEppLib):
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
# Test for the copy link # Test for the copy link
self.assertContains(response, "button--clipboard", count=4) self.assertContains(response, "copy-to-clipboard", count=4)
# Test that Creator counts display properly # Test that Creator counts display properly
self.assertNotContains(response, "Approved domains") self.assertNotContains(response, "Approved domains")
@ -1846,6 +1846,58 @@ class TestDomainRequestAdmin(MockEppLib):
def test_side_effects_when_saving_approved_to_ineligible(self): def test_side_effects_when_saving_approved_to_ineligible(self):
self.trigger_saving_approved_to_another_state(False, DomainRequest.DomainRequestStatus.INELIGIBLE) self.trigger_saving_approved_to_another_state(False, DomainRequest.DomainRequestStatus.INELIGIBLE)
@less_console_noise
def test_error_when_saving_to_approved_and_domain_exists(self):
"""Redundant admin check on model transition not allowed."""
Domain.objects.create(name="wabbitseason.gov")
new_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.SUBMITTED, name="wabbitseason.gov"
)
# Create a request object with a superuser
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(new_request.pk))
request.user = self.superuser
request.session = {}
# Use ExitStack to combine patch contexts
with ExitStack() as stack:
# Patch django.contrib.messages.error
stack.enter_context(patch.object(messages, "error"))
new_request.status = DomainRequest.DomainRequestStatus.APPROVED
self.admin.save_model(request, new_request, None, True)
messages.error.assert_called_once_with(
request,
"Cannot approve. Requested domain is already in use.",
)
@less_console_noise
def test_no_error_when_saving_to_approved_and_domain_exists(self):
"""The negative of the redundant admin check on model transition not allowed."""
new_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.SUBMITTED)
# Create a request object with a superuser
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(new_request.pk))
request.user = self.superuser
request.session = {}
# Use ExitStack to combine patch contexts
with ExitStack() as stack:
# Patch Domain.is_active and django.contrib.messages.error simultaneously
stack.enter_context(patch.object(messages, "error"))
new_request.status = DomainRequest.DomainRequestStatus.APPROVED
self.admin.save_model(request, new_request, None, True)
# Assert that the error message was never called
messages.error.assert_not_called()
def test_has_correct_filters(self): def test_has_correct_filters(self):
""" """
This test verifies that DomainRequestAdmin has the correct filters set up. This test verifies that DomainRequestAdmin has the correct filters set up.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@ from registrar.models import (
Domain, Domain,
UserDomainRole, UserDomainRole,
) )
from registrar.models import Portfolio from registrar.models import Portfolio, DraftDomain
from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from registrar.utility.csv_export import ( from registrar.utility.csv_export import (
@ -14,6 +14,7 @@ from registrar.utility.csv_export import (
DomainDataType, DomainDataType,
DomainDataFederal, DomainDataFederal,
DomainDataTypeUser, DomainDataTypeUser,
DomainRequestsDataType,
DomainGrowth, DomainGrowth,
DomainManaged, DomainManaged,
DomainUnmanaged, DomainUnmanaged,
@ -389,6 +390,77 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
return csv_content return csv_content
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
def test_domain_request_data_type_user_with_portfolio(self):
"""Tests DomainRequestsDataType export with portfolio permissions"""
# Create a portfolio and assign it to the user
portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio")
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(portfolio=portfolio, user=self.user)
# Create DraftDomain objects
dd_1 = DraftDomain.objects.create(name="example1.com")
dd_2 = DraftDomain.objects.create(name="example2.com")
dd_3 = DraftDomain.objects.create(name="example3.com")
# Create some domain requests
dr_1 = DomainRequest.objects.create(creator=self.user, requested_domain=dd_1, portfolio=portfolio)
dr_2 = DomainRequest.objects.create(creator=self.user, requested_domain=dd_2)
dr_3 = DomainRequest.objects.create(creator=self.user, requested_domain=dd_3, portfolio=portfolio)
# Set up user permissions
portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
portfolio_permission.save()
portfolio_permission.refresh_from_db()
# Make a GET request using self.client to get a request object
request = get_wsgi_request_object(client=self.client, user=self.user)
# Get the CSV content
csv_content = self._run_domain_request_data_type_user_export(request)
# We expect only domain requests associated with the user's portfolio
self.assertIn(dd_1.name, csv_content)
self.assertIn(dd_3.name, csv_content)
self.assertNotIn(dd_2.name, csv_content)
# Get the csv content
csv_content = self._run_domain_request_data_type_user_export(request)
self.assertIn(dd_1.name, csv_content)
self.assertIn(dd_3.name, csv_content)
self.assertNotIn(dd_2.name, csv_content)
portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
portfolio_permission.save()
portfolio_permission.refresh_from_db()
# Domain Request NOT in Portfolio
csv_content = self._run_domain_request_data_type_user_export(request)
self.assertNotIn(dd_1.name, csv_content)
self.assertNotIn(dd_3.name, csv_content)
self.assertNotIn(dd_2.name, csv_content)
# Clean up the created objects
dr_1.delete()
dr_2.delete()
dr_3.delete()
portfolio.delete()
def _run_domain_request_data_type_user_export(self, request):
"""Helper function to run the exporting_dr_data_to_csv function on DomainRequestsDataType"""
csv_file = StringIO()
DomainRequestsDataType.exporting_dr_data_to_csv(csv_file, request=request)
csv_file.seek(0)
csv_content = csv_file.read()
return csv_content
@less_console_noise_decorator @less_console_noise_decorator
def test_domain_data_full(self): def test_domain_data_full(self):
"""Shows security contacts, filtered by state""" """Shows security contacts, filtered by state"""

View file

@ -82,7 +82,6 @@ class DomainRequestTests(TestWithUser, WebTest):
response = self.app.get(f"/domain-request/{domain_request.id}") response = self.app.get(f"/domain-request/{domain_request.id}")
# Ensure that the date is still set to None # Ensure that the date is still set to None
self.assertIsNone(domain_request.last_status_update) 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 # 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, "Started on:")
self.assertContains(response, fixed_date.strftime("%B %-d, %Y")) self.assertContains(response, fixed_date.strftime("%B %-d, %Y"))

View file

@ -583,6 +583,105 @@ class DomainDataTypeUser(DomainDataType):
return Q(domain__id__in=request.user.get_user_domain_ids(request)) return Q(domain__id__in=request.user.get_user_domain_ids(request))
class DomainRequestsDataType:
"""
The DomainRequestsDataType report, but filtered based on the current request user
"""
@classmethod
def get_filter_conditions(cls, request=None):
if request is None or not hasattr(request, "user") or not request.user.is_authenticated:
return Q(id__in=[])
request_ids = request.user.get_user_domain_request_ids(request)
return Q(id__in=request_ids)
@classmethod
def get_queryset(cls, request):
return DomainRequest.objects.filter(cls.get_filter_conditions(request))
def safe_get(attribute, default="N/A"):
# Return the attribute value or default if not present
return attribute if attribute is not None else default
@classmethod
def exporting_dr_data_to_csv(cls, response, request=None):
import csv
writer = csv.writer(response)
# CSV headers
writer.writerow(
[
"Domain request",
"Region",
"Status",
"Election office",
"Federal type",
"Domain type",
"Request additional details",
"Creator approved domains count",
"Creator active requests count",
"Alternative domains",
"Other contacts",
"Current websites",
"Federal agency",
"SO first name",
"SO last name",
"SO email",
"SO title/role",
"Creator first name",
"Creator last name",
"Creator email",
"Organization name",
"City",
"State/territory",
"Request purpose",
"CISA regional representative",
"Last submitted date",
"First submitted date",
"Last status update",
]
)
queryset = cls.get_queryset(request)
for request in queryset:
writer.writerow(
[
request.requested_domain,
cls.safe_get(getattr(request, "region_field", None)),
request.status,
cls.safe_get(getattr(request, "election_office", None)),
request.federal_type,
cls.safe_get(getattr(request, "domain_type", None)),
cls.safe_get(getattr(request, "additional_details", None)),
cls.safe_get(getattr(request, "creator_approved_domains_count", None)),
cls.safe_get(getattr(request, "creator_active_requests_count", None)),
cls.safe_get(getattr(request, "all_alternative_domains", None)),
cls.safe_get(getattr(request, "all_other_contacts", None)),
cls.safe_get(getattr(request, "all_current_websites", None)),
cls.safe_get(getattr(request, "federal_agency", None)),
cls.safe_get(getattr(request.senior_official, "first_name", None)),
cls.safe_get(getattr(request.senior_official, "last_name", None)),
cls.safe_get(getattr(request.senior_official, "email", None)),
cls.safe_get(getattr(request.senior_official, "title", None)),
cls.safe_get(getattr(request.creator, "first_name", None)),
cls.safe_get(getattr(request.creator, "last_name", None)),
cls.safe_get(getattr(request.creator, "email", None)),
cls.safe_get(getattr(request, "organization_name", None)),
cls.safe_get(getattr(request, "city", None)),
cls.safe_get(getattr(request, "state_territory", None)),
cls.safe_get(getattr(request, "purpose", None)),
cls.safe_get(getattr(request, "cisa_representative_email", None)),
cls.safe_get(getattr(request, "last_submitted_date", None)),
cls.safe_get(getattr(request, "first_submitted_date", None)),
cls.safe_get(getattr(request, "last_status_update", None)),
]
)
return response
class DomainDataFull(DomainExport): class DomainDataFull(DomainExport):
""" """
Shows security contacts, filtered by state Shows security contacts, filtered by state

View file

@ -169,6 +169,17 @@ class ExportDataTypeUser(View):
return response return response
class ExportDataTypeRequests(View):
"""Returns a domain requests report for a given user on the request"""
def get(self, request, *args, **kwargs):
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="domain-requests.csv"'
csv_export.DomainRequestsDataType.exporting_dr_data_to_csv(response, request=request)
return response
class ExportDataFull(View): class ExportDataFull(View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
# Smaller export based on 1 # Smaller export based on 1