Merge pull request #2463 from cisagov/za/2323-domain-manager-csv-export

Ticket #2323: Domain manager csv export
This commit is contained in:
zandercymatics 2024-07-26 11:51:16 -06:00 committed by GitHub
commit 279fca4979
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 173 additions and 44 deletions

View file

@ -72,6 +72,29 @@ body {
} }
} }
.section--outlined__header--no-portfolio {
.section--outlined__search,
.section--outlined__utility-button {
margin-top: units(2);
}
@include at-media(tablet) {
display: flex;
column-gap: units(3);
.section--outlined__search,
.section--outlined__utility-button {
margin-top: 0;
}
.section--outlined__search {
flex-grow: 4;
// Align right
max-width: 383px;
margin-left: auto;
}
}
}
.break-word { .break-word {
word-break: break-word; word-break: break-word;
} }

View file

@ -205,3 +205,12 @@ a.usa-button--unstyled:visited {
} }
} }
.usa-icon.usa-icon--big {
margin: 0;
height: 1.5em;
width: 1.5em;
}
.margin-right-neg-4px {
margin-right: -4px;
}

View file

@ -9,7 +9,7 @@ from django.urls import include, path
from django.views.generic import RedirectView from django.views.generic import RedirectView
from registrar import views from registrar import views
from registrar.views.admin_views import ( from registrar.views.report_views import (
ExportDataDomainsGrowth, ExportDataDomainsGrowth,
ExportDataFederal, ExportDataFederal,
ExportDataFull, ExportDataFull,
@ -19,6 +19,7 @@ from registrar.views.admin_views import (
ExportDataUnmanagedDomains, ExportDataUnmanagedDomains,
AnalyticsView, AnalyticsView,
ExportDomainRequestDataFull, ExportDomainRequestDataFull,
ExportDataTypeUser,
) )
from registrar.views.domain_request import Step from registrar.views.domain_request import Step
@ -124,6 +125,11 @@ urlpatterns = [
name="analytics", name="analytics",
), ),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path(
"reports/export_data_type_user/",
ExportDataTypeUser.as_view(),
name="export_data_type_user",
),
path( path(
"domain-request/<id>/edit/", "domain-request/<id>/edit/",
views.DomainRequestWizard.as_view(), views.DomainRequestWizard.as_view(),

View file

@ -170,18 +170,11 @@ class CreateOrUpdateOrganizationTypeHelper:
# There is no avenue for this to occur in the UI, # There is no avenue for this to occur in the UI,
# as such - this can only occur if the object is initialized in this way. # as such - this can only occur if the object is initialized in this way.
# Or if there are pre-existing data. # Or if there are pre-existing data.
logger.debug(
"create_or_update_organization_type() -> is_election_board "
f"cannot exist for {generic_org_type}. Setting to None."
)
self.instance.is_election_board = None self.instance.is_election_board = None
self.instance.organization_type = generic_org_type self.instance.organization_type = generic_org_type
else: else:
# This can only happen with manual data tinkering, which causes these to be out of sync. # This can only happen with manual data tinkering, which causes these to be out of sync.
if self.instance.is_election_board is None: if self.instance.is_election_board is None:
logger.warning(
"create_or_update_organization_type() -> is_election_board is out of sync. Updating value."
)
self.instance.is_election_board = False self.instance.is_election_board = False
if self.instance.is_election_board: if self.instance.is_election_board:
@ -218,10 +211,6 @@ class CreateOrUpdateOrganizationTypeHelper:
# There is no avenue for this to occur in the UI, # There is no avenue for this to occur in the UI,
# as such - this can only occur if the object is initialized in this way. # as such - this can only occur if the object is initialized in this way.
# Or if there are pre-existing data. # Or if there are pre-existing data.
logger.warning(
"create_or_update_organization_type() -> is_election_board "
f"cannot exist for {current_org_type}. Setting to None."
)
self.instance.is_election_board = None self.instance.is_election_board = None
else: else:
# if self.instance.organization_type is set to None, then this means # if self.instance.organization_type is set to None, then this means

View file

@ -1,16 +1,14 @@
{% load static %} {% load static %}
<section class="section--outlined domains{% if portfolio is not None %} margin-top-0{% endif %}" id="domains"> <section class="section--outlined domains{% if portfolio is not None %} margin-top-0{% endif %}" id="domains">
<div class="grid-row"> <div class="section--outlined__header margin-bottom-3 {% if portfolio is None %} section--outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
<!-- Use portfolio_base_permission when merging into 2366 then delete this comment --> <!-- Use portfolio_base_permission when merging into 2366 then delete this comment -->
{% if portfolio is None %} {% if portfolio is None %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <h2 id="domains-header" class="display-inline-block">Domains</h2>
<h2 id="domains-header" class="flex-6">Domains</h2> <span class="display-none" id="no-portfolio-js-flag"></span>
</div>
<span class="display-none" id="no-portfolio-js-flag"></span>
{% endif %} {% endif %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <div class="section--outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %}">
<section aria-label="Domains search component" class="flex-6 margin-y-2"> <section aria-label="Domains 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-3 domains__reset-search display-none" type="button"> <button class="usa-button usa-button--unstyled margin-right-3 domains__reset-search display-none" type="button">
@ -37,10 +35,19 @@
</form> </form>
</section> </section>
</div> </div>
<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 %}">
<section aria-label="Domains report component" class="mobile-lg:margin-top-205">
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled" role="button">
<svg class="usa-icon usa-icon--big margin-right-neg-4px" 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> </div>
<!-- Use portfolio_base_permission when merging into 2366 then delete this comment --> <!-- Use portfolio_base_permission when merging into 2366 then delete this comment -->
{% if portfolio %} {% if portfolio %}
<div class="display-flex flex-align-center margin-top-1"> <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">

View file

@ -1,12 +1,16 @@
import io import io
from django.test import Client, RequestFactory from django.test import Client, RequestFactory
from io import StringIO from io import StringIO
from registrar.models.domain_request import DomainRequest from registrar.models import (
from registrar.models.domain import Domain DomainRequest,
Domain,
UserDomainRole,
)
from registrar.utility.csv_export import ( from registrar.utility.csv_export import (
DomainDataFull, DomainDataFull,
DomainDataType, DomainDataType,
DomainDataFederal, DomainDataFederal,
DomainDataTypeUser,
DomainGrowth, DomainGrowth,
DomainManaged, DomainManaged,
DomainUnmanaged, DomainUnmanaged,
@ -27,7 +31,7 @@ import boto3_mocking
from registrar.utility.s3_bucket import S3ClientError, S3ClientErrorCodes # type: ignore from registrar.utility.s3_bucket import S3ClientError, S3ClientErrorCodes # type: ignore
from django.utils import timezone from django.utils import timezone
from api.tests.common import less_console_noise_decorator from api.tests.common import less_console_noise_decorator
from .common import MockDb, MockEppLib, less_console_noise, get_time_aware_date from .common import MockDb, MockEppLib, less_console_noise, get_time_aware_date, create_user
class CsvReportsTest(MockDb): class CsvReportsTest(MockDb):
@ -199,8 +203,11 @@ class CsvReportsTest(MockDb):
class ExportDataTest(MockDb, MockEppLib): class ExportDataTest(MockDb, MockEppLib):
"""Tests our data exports for admin"""
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.factory = RequestFactory()
def tearDown(self): def tearDown(self):
super().tearDown() super().tearDown()
@ -208,7 +215,7 @@ class ExportDataTest(MockDb, MockEppLib):
@less_console_noise_decorator @less_console_noise_decorator
def test_domain_data_type(self): def test_domain_data_type(self):
"""Shows security contacts, domain managers, so""" """Shows security contacts, domain managers, so"""
self.maxDiff = None
# Add security email information # Add security email information
self.domain_1.name = "defaultsecurity.gov" self.domain_1.name = "defaultsecurity.gov"
self.domain_1.save() self.domain_1.save()
@ -260,6 +267,58 @@ class ExportDataTest(MockDb, MockEppLib):
self.maxDiff = None self.maxDiff = None
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator
def test_domain_data_type_user(self):
"""Shows security contacts, domain managers, so for the current user"""
# Add security email information
self.domain_1.name = "defaultsecurity.gov"
self.domain_1.save()
# Invoke setter
self.domain_1.security_contact
self.domain_2.security_contact
self.domain_3.security_contact
# Add a first ready date on the first domain. Leaving the others blank.
self.domain_1.first_ready = get_default_start_date()
self.domain_1.save()
# Create a user and associate it with some domains
user = create_user()
UserDomainRole.objects.create(user=user, domain=self.domain_1)
UserDomainRole.objects.create(user=user, domain=self.domain_2)
# Create a request object
request = self.factory.get("/")
request.user = user
# Create a CSV file in memory
csv_file = StringIO()
# Call the export functions
DomainDataTypeUser.export_data_to_csv(csv_file, request=request)
# Reset the CSV file's position to the beginning
csv_file.seek(0)
# Read the content into a variable
csv_content = csv_file.read()
# We expect only domains associated with the user
expected_content = (
"Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name,"
"City,State,SO,SO email,"
"Security contact email,Domain managers,Invited domain managers\n"
"defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,,, ,,"
'(blank),"meoward@rocks.com, info@example.com, big_lebowski@dude.co, staff@example.com",'
"woofwardthethird@rocks.com\n"
"adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,, ,,(blank),"
'"meoward@rocks.com, staff@example.com",squeaker@rocks.com\n'
)
# Normalize line endings and remove commas,
# spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_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"""
@ -370,8 +429,8 @@ class ExportDataTest(MockDb, MockEppLib):
# Call the export functions # Call the export functions
DomainGrowth.export_data_to_csv( DomainGrowth.export_data_to_csv(
csv_file, csv_file,
self.start_date.strftime("%Y-%m-%d"), start_date=self.start_date.strftime("%Y-%m-%d"),
self.end_date.strftime("%Y-%m-%d"), end_date=self.end_date.strftime("%Y-%m-%d"),
) )
# Reset the CSV file's position to the beginning # Reset the CSV file's position to the beginning
csv_file.seek(0) csv_file.seek(0)
@ -412,8 +471,8 @@ class ExportDataTest(MockDb, MockEppLib):
# Call the export functions # Call the export functions
DomainManaged.export_data_to_csv( DomainManaged.export_data_to_csv(
csv_file, csv_file,
self.start_date.strftime("%Y-%m-%d"), start_date=self.start_date.strftime("%Y-%m-%d"),
self.end_date.strftime("%Y-%m-%d"), end_date=self.end_date.strftime("%Y-%m-%d"),
) )
# Reset the CSV file's position to the beginning # Reset the CSV file's position to the beginning
csv_file.seek(0) csv_file.seek(0)
@ -449,7 +508,7 @@ class ExportDataTest(MockDb, MockEppLib):
# Create a CSV file in memory # Create a CSV file in memory
csv_file = StringIO() csv_file = StringIO()
DomainUnmanaged.export_data_to_csv( DomainUnmanaged.export_data_to_csv(
csv_file, self.start_date.strftime("%Y-%m-%d"), self.end_date.strftime("%Y-%m-%d") csv_file, start_date=self.start_date.strftime("%Y-%m-%d"), end_date=self.end_date.strftime("%Y-%m-%d")
) )
# Reset the CSV file's position to the beginning # Reset the CSV file's position to the beginning
@ -496,8 +555,8 @@ class ExportDataTest(MockDb, MockEppLib):
# Call the export functions # Call the export functions
DomainRequestGrowth.export_data_to_csv( DomainRequestGrowth.export_data_to_csv(
csv_file, csv_file,
self.start_date.strftime("%Y-%m-%d"), start_date=self.start_date.strftime("%Y-%m-%d"),
self.end_date.strftime("%Y-%m-%d"), end_date=self.end_date.strftime("%Y-%m-%d"),
) )
# Reset the CSV file's position to the beginning # Reset the CSV file's position to the beginning
csv_file.seek(0) csv_file.seek(0)

View file

@ -109,7 +109,7 @@ class BaseExport(ABC):
return Q() return Q()
@classmethod @classmethod
def get_filter_conditions(cls, start_date=None, end_date=None): def get_filter_conditions(cls, **export_kwargs):
""" """
Get a Q object of filter conditions to filter when building queryset. Get a Q object of filter conditions to filter when building queryset.
""" """
@ -145,7 +145,7 @@ class BaseExport(ABC):
return queryset return queryset
@classmethod @classmethod
def write_csv_before(cls, csv_writer, start_date=None, end_date=None): def write_csv_before(cls, csv_writer, **export_kwargs):
""" """
Write to csv file before the write_csv method. Write to csv file before the write_csv method.
Override in subclasses where needed. Override in subclasses where needed.
@ -192,7 +192,7 @@ class BaseExport(ABC):
return cls.update_queryset(queryset, **kwargs) return cls.update_queryset(queryset, **kwargs)
@classmethod @classmethod
def export_data_to_csv(cls, csv_file, start_date=None, end_date=None): def export_data_to_csv(cls, csv_file, **export_kwargs):
""" """
All domain metadata: All domain metadata:
Exports domains of all statuses plus domain managers. Exports domains of all statuses plus domain managers.
@ -205,7 +205,7 @@ class BaseExport(ABC):
prefetch_related = cls.get_prefetch_related() prefetch_related = cls.get_prefetch_related()
exclusions = cls.get_exclusions() exclusions = cls.get_exclusions()
annotations_for_sort = cls.get_annotations_for_sort() annotations_for_sort = cls.get_annotations_for_sort()
filter_conditions = cls.get_filter_conditions(start_date, end_date) filter_conditions = cls.get_filter_conditions(**export_kwargs)
computed_fields = cls.get_computed_fields() computed_fields = cls.get_computed_fields()
related_table_fields = cls.get_related_table_fields() related_table_fields = cls.get_related_table_fields()
@ -227,10 +227,13 @@ class BaseExport(ABC):
models_dict = convert_queryset_to_dict(annotated_queryset, is_model=False) models_dict = convert_queryset_to_dict(annotated_queryset, is_model=False)
# Write to csv file before the write_csv # Write to csv file before the write_csv
cls.write_csv_before(writer, start_date, end_date) cls.write_csv_before(writer, **export_kwargs)
# Write the csv file # Write the csv file
cls.write_csv(writer, columns, models_dict) rows = cls.write_csv(writer, columns, models_dict)
# Return rows that for easier parsing and testing
return rows
@classmethod @classmethod
def write_csv( def write_csv(
@ -257,6 +260,9 @@ class BaseExport(ABC):
writer.writerows(rows) writer.writerows(rows)
# Return rows for easier parsing and testing
return rows
@classmethod @classmethod
@abstractmethod @abstractmethod
def parse_row(cls, columns, model): def parse_row(cls, columns, model):
@ -554,6 +560,25 @@ class DomainDataType(DomainExport):
] ]
class DomainDataTypeUser(DomainDataType):
"""
The DomainDataType report, but sliced on the current request user
"""
@classmethod
def get_filter_conditions(cls, request=None):
"""
Get a Q object of filter conditions to filter when building queryset.
"""
if request is None or not hasattr(request, "user") or not request.user:
# Return nothing
return Q(id__in=[])
user_domain_roles = UserDomainRole.objects.filter(user=request.user)
domain_ids = user_domain_roles.values_list("domain_id", flat=True)
return Q(domain__id__in=domain_ids)
class DomainDataFull(DomainExport): class DomainDataFull(DomainExport):
""" """
Shows security contacts, filtered by state Shows security contacts, filtered by state
@ -611,7 +636,7 @@ class DomainDataFull(DomainExport):
return ["domain"] return ["domain"]
@classmethod @classmethod
def get_filter_conditions(cls, start_date=None, end_date=None): def get_filter_conditions(cls):
""" """
Get a Q object of filter conditions to filter when building queryset. Get a Q object of filter conditions to filter when building queryset.
""" """
@ -706,7 +731,7 @@ class DomainDataFederal(DomainExport):
return ["domain"] return ["domain"]
@classmethod @classmethod
def get_filter_conditions(cls, start_date=None, end_date=None): def get_filter_conditions(cls):
""" """
Get a Q object of filter conditions to filter when building queryset. Get a Q object of filter conditions to filter when building queryset.
""" """

View file

@ -158,6 +158,17 @@ class ExportDataType(View):
return response return response
class ExportDataTypeUser(View):
"""Returns a domain report for a given user on the request"""
def get(self, request, *args, **kwargs):
# match the CSV example with all the fields
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="your-domains.csv"'
csv_export.DomainDataTypeUser.export_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
@ -194,7 +205,7 @@ class ExportDataDomainsGrowth(View):
response = HttpResponse(content_type="text/csv") response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="domain-growth-report-{start_date}-to-{end_date}.csv"' response["Content-Disposition"] = f'attachment; filename="domain-growth-report-{start_date}-to-{end_date}.csv"'
csv_export.DomainGrowth.export_data_to_csv(response, start_date, end_date) csv_export.DomainGrowth.export_data_to_csv(response, start_date=start_date, end_date=end_date)
return response return response
@ -206,7 +217,7 @@ class ExportDataRequestsGrowth(View):
response = HttpResponse(content_type="text/csv") response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="requests-{start_date}-to-{end_date}.csv"' response["Content-Disposition"] = f'attachment; filename="requests-{start_date}-to-{end_date}.csv"'
csv_export.DomainRequestGrowth.export_data_to_csv(response, start_date, end_date) csv_export.DomainRequestGrowth.export_data_to_csv(response, start_date=start_date, end_date=end_date)
return response return response
@ -217,7 +228,7 @@ class ExportDataManagedDomains(View):
end_date = request.GET.get("end_date", "") end_date = request.GET.get("end_date", "")
response = HttpResponse(content_type="text/csv") response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="managed-domains-{start_date}-to-{end_date}.csv"' response["Content-Disposition"] = f'attachment; filename="managed-domains-{start_date}-to-{end_date}.csv"'
csv_export.DomainManaged.export_data_to_csv(response, start_date, end_date) csv_export.DomainManaged.export_data_to_csv(response, start_date=start_date, end_date=end_date)
return response return response
@ -228,6 +239,6 @@ class ExportDataUnmanagedDomains(View):
end_date = request.GET.get("end_date", "") end_date = request.GET.get("end_date", "")
response = HttpResponse(content_type="text/csv") response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="unmanaged-domains-{start_date}-to-{end_date}.csv"' response["Content-Disposition"] = f'attachment; filename="unmanaged-domains-{start_date}-to-{end_date}.csv"'
csv_export.DomainUnmanaged.export_data_to_csv(response, start_date, end_date) csv_export.DomainUnmanaged.export_data_to_csv(response, start_date=start_date, end_date=end_date)
return response return response