Merge pull request #2878 from cisagov/rh/2592-domain-request-export

#2592: Domain Request CSV Export - [RH]
This commit is contained in:
Rebecca H. 2024-10-07 14:24:23 -07:00 committed by GitHub
commit 286cf8e417
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 399 additions and 184 deletions

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

@ -20,6 +20,7 @@ from registrar.views.report_views import (
AnalyticsView, AnalyticsView,
ExportDomainRequestDataFull, ExportDomainRequestDataFull,
ExportDataTypeUser, ExportDataTypeUser,
ExportDataTypeRequests,
) )
# --jsons # --jsons
@ -177,6 +178,11 @@ 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( path(
"domain-request/<id>/edit/", "domain-request/<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

@ -3,21 +3,21 @@
{% 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 %}">
<section aria-label="Domain requests search component" class="margin-top-2">
<form class="usa-search usa-search--small" method="POST" role="search"> <form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %} {% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-2 domain-requests__reset-search display-none" type="button"> <button class="usa-button usa-button--unstyled margin-right-3 domain-requests__reset-search display-none" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use> <use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg> </svg>
@ -49,7 +49,19 @@
</form> </form>
</section> </section>
</div> </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> </div>
{% endif %}
</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>
@ -79,9 +91,7 @@
name="filter-status" name="filter-status"
value="started" value="started"
/> />
<label class="usa-checkbox__label" for="filter-status-started" <label class="usa-checkbox__label" for="filter-status-started">Started</label>
>Started</label
>
</div> </div>
<div class="usa-checkbox"> <div class="usa-checkbox">
<input <input
@ -91,9 +101,7 @@
name="filter-status" name="filter-status"
value="submitted" value="submitted"
/> />
<label class="usa-checkbox__label" for="filter-status-submitted" <label class="usa-checkbox__label" for="filter-status-submitted">Submitted</label>
>Submitted</label
>
</div> </div>
<div class="usa-checkbox"> <div class="usa-checkbox">
<input <input
@ -103,9 +111,7 @@
name="filter-status" name="filter-status"
value="in review" value="in review"
/> />
<label class="usa-checkbox__label" for="filter-status-in-review" <label class="usa-checkbox__label" for="filter-status-in-review">In review</label>
>In review</label
>
</div> </div>
<div class="usa-checkbox"> <div class="usa-checkbox">
<input <input
@ -115,9 +121,7 @@
name="filter-status" name="filter-status"
value="action needed" value="action needed"
/> />
<label class="usa-checkbox__label" for="filter-status-action-needed" <label class="usa-checkbox__label" for="filter-status-action-needed">Action needed</label>
>Action needed</label
>
</div> </div>
<div class="usa-checkbox"> <div class="usa-checkbox">
<input <input
@ -127,9 +131,7 @@
name="filter-status" name="filter-status"
value="rejected" value="rejected"
/> />
<label class="usa-checkbox__label" for="filter-status-rejected" <label class="usa-checkbox__label" for="filter-status-rejected">Rejected</label>
>Rejected</label
>
</div> </div>
<div class="usa-checkbox"> <div class="usa-checkbox">
<input <input
@ -139,9 +141,7 @@
name="filter-status" name="filter-status"
value="withdrawn" value="withdrawn"
/> />
<label class="usa-checkbox__label" for="filter-status-withdrawn" <label class="usa-checkbox__label" for="filter-status-withdrawn">Withdrawn</label>
>Withdrawn</label
>
</div> </div>
<div class="usa-checkbox"> <div class="usa-checkbox">
<input <input
@ -151,9 +151,7 @@
name="filter-status" name="filter-status"
value="ineligible" value="ineligible"
/> />
<label class="usa-checkbox__label" for="filter-status-ineligible" <label class="usa-checkbox__label" for="filter-status-ineligible">Ineligible</label>
>Ineligible</label
>
</div> </div>
</fieldset> </fieldset>
</div> </div>
@ -169,6 +167,7 @@
</button> </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>
@ -188,18 +187,18 @@
<!-- 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 -->

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

@ -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