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

View file

@ -170,18 +170,11 @@ class CreateOrUpdateOrganizationTypeHelper:
# 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.
# 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.organization_type = generic_org_type
else:
# This can only happen with manual data tinkering, which causes these to be out of sync.
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
if self.instance.is_election_board:
@ -218,10 +211,6 @@ class CreateOrUpdateOrganizationTypeHelper:
# 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.
# 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
else:
# if self.instance.organization_type is set to None, then this means

View file

@ -1,16 +1,14 @@
{% load static %}
<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 -->
{% if portfolio is None %}
<div class="mobile:grid-col-12 desktop:grid-col-6">
<h2 id="domains-header" class="flex-6">Domains</h2>
</div>
<h2 id="domains-header" class="display-inline-block">Domains</h2>
<span class="display-none" id="no-portfolio-js-flag"></span>
{% endif %}
<div class="mobile:grid-col-12 desktop:grid-col-6">
<section aria-label="Domains search component" class="flex-6 margin-y-2">
<div class="section--outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %}">
<section aria-label="Domains search component" class="margin-top-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-3 domains__reset-search display-none" type="button">
@ -37,10 +35,19 @@
</form>
</section>
</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>
<!-- Use portfolio_base_permission when merging into 2366 then delete this comment -->
{% 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>
<div class="usa-accordion usa-accordion--select margin-right-2">
<div class="usa-accordion__heading">

View file

@ -1,12 +1,16 @@
import io
from django.test import Client, RequestFactory
from io import StringIO
from registrar.models.domain_request import DomainRequest
from registrar.models.domain import Domain
from registrar.models import (
DomainRequest,
Domain,
UserDomainRole,
)
from registrar.utility.csv_export import (
DomainDataFull,
DomainDataType,
DomainDataFederal,
DomainDataTypeUser,
DomainGrowth,
DomainManaged,
DomainUnmanaged,
@ -27,7 +31,7 @@ import boto3_mocking
from registrar.utility.s3_bucket import S3ClientError, S3ClientErrorCodes # type: ignore
from django.utils import timezone
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):
@ -199,8 +203,11 @@ class CsvReportsTest(MockDb):
class ExportDataTest(MockDb, MockEppLib):
"""Tests our data exports for admin"""
def setUp(self):
super().setUp()
self.factory = RequestFactory()
def tearDown(self):
super().tearDown()
@ -208,7 +215,7 @@ class ExportDataTest(MockDb, MockEppLib):
@less_console_noise_decorator
def test_domain_data_type(self):
"""Shows security contacts, domain managers, so"""
self.maxDiff = None
# Add security email information
self.domain_1.name = "defaultsecurity.gov"
self.domain_1.save()
@ -260,6 +267,58 @@ class ExportDataTest(MockDb, MockEppLib):
self.maxDiff = None
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
def test_domain_data_full(self):
"""Shows security contacts, filtered by state"""
@ -370,8 +429,8 @@ class ExportDataTest(MockDb, MockEppLib):
# Call the export functions
DomainGrowth.export_data_to_csv(
csv_file,
self.start_date.strftime("%Y-%m-%d"),
self.end_date.strftime("%Y-%m-%d"),
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
csv_file.seek(0)
@ -412,8 +471,8 @@ class ExportDataTest(MockDb, MockEppLib):
# Call the export functions
DomainManaged.export_data_to_csv(
csv_file,
self.start_date.strftime("%Y-%m-%d"),
self.end_date.strftime("%Y-%m-%d"),
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
csv_file.seek(0)
@ -449,7 +508,7 @@ class ExportDataTest(MockDb, MockEppLib):
# Create a CSV file in memory
csv_file = StringIO()
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
@ -496,8 +555,8 @@ class ExportDataTest(MockDb, MockEppLib):
# Call the export functions
DomainRequestGrowth.export_data_to_csv(
csv_file,
self.start_date.strftime("%Y-%m-%d"),
self.end_date.strftime("%Y-%m-%d"),
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
csv_file.seek(0)

View file

@ -109,7 +109,7 @@ class BaseExport(ABC):
return Q()
@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.
"""
@ -145,7 +145,7 @@ class BaseExport(ABC):
return queryset
@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.
Override in subclasses where needed.
@ -192,7 +192,7 @@ class BaseExport(ABC):
return cls.update_queryset(queryset, **kwargs)
@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:
Exports domains of all statuses plus domain managers.
@ -205,7 +205,7 @@ class BaseExport(ABC):
prefetch_related = cls.get_prefetch_related()
exclusions = cls.get_exclusions()
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()
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)
# 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
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
def write_csv(
@ -257,6 +260,9 @@ class BaseExport(ABC):
writer.writerows(rows)
# Return rows for easier parsing and testing
return rows
@classmethod
@abstractmethod
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):
"""
Shows security contacts, filtered by state
@ -611,7 +636,7 @@ class DomainDataFull(DomainExport):
return ["domain"]
@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.
"""
@ -706,7 +731,7 @@ class DomainDataFederal(DomainExport):
return ["domain"]
@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.
"""

View file

@ -158,6 +158,17 @@ class ExportDataType(View):
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):
def get(self, request, *args, **kwargs):
# Smaller export based on 1
@ -194,7 +205,7 @@ class ExportDataDomainsGrowth(View):
response = HttpResponse(content_type="text/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
@ -206,7 +217,7 @@ class ExportDataRequestsGrowth(View):
response = HttpResponse(content_type="text/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
@ -217,7 +228,7 @@ class ExportDataManagedDomains(View):
end_date = request.GET.get("end_date", "")
response = HttpResponse(content_type="text/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
@ -228,6 +239,6 @@ class ExportDataUnmanagedDomains(View):
end_date = request.GET.get("end_date", "")
response = HttpResponse(content_type="text/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