diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 5f571ac73b..ead2dab05d 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3,9 +3,9 @@ import copy from django import forms -from django.db.models.functions import Concat, Coalesce from django.db.models import Value, CharField, Q -from django.http import HttpResponse, HttpResponseRedirect +from django.db.models.functions import Concat, Coalesce +from django.http import HttpResponseRedirect from django.shortcuts import redirect from django_fsm import get_available_FIELD_transitions from django.contrib import admin, messages @@ -16,7 +16,6 @@ from dateutil.relativedelta import relativedelta # type: ignore from epplibwrapper.errors import ErrorCode, RegistryError from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website -from registrar.utility import csv_export from registrar.utility.errors import FSMApplicationError, FSMErrorCodes from registrar.views.utility.mixins import OrderableFieldsMixin from django.contrib.admin.views.main import ORDER_VAR @@ -1465,7 +1464,6 @@ def state_territory(self, obj): search_fields = ["name"] search_help_text = "Search by domain name." change_form_template = "django/admin/domain_change_form.html" - change_list_template = "django/admin/domain_change_list.html" readonly_fields = ["state", "expiration_date", "first_ready", "deleted"] # Table ordering @@ -1512,56 +1510,6 @@ def changeform_view(self, request, object_id=None, form_url="", extra_context=No return super().changeform_view(request, object_id, form_url, extra_context) - def export_data_type(self, request): - # match the CSV example with all the fields - response = HttpResponse(content_type="text/csv") - response["Content-Disposition"] = 'attachment; filename="domains-by-type.csv"' - csv_export.export_data_type_to_csv(response) - return response - - def export_data_full(self, request): - # Smaller export based on 1 - response = HttpResponse(content_type="text/csv") - response["Content-Disposition"] = 'attachment; filename="current-full.csv"' - csv_export.export_data_full_to_csv(response) - return response - - def export_data_federal(self, request): - # Federal only - response = HttpResponse(content_type="text/csv") - response["Content-Disposition"] = 'attachment; filename="current-federal.csv"' - csv_export.export_data_federal_to_csv(response) - return response - - def get_urls(self): - from django.urls import path - - urlpatterns = super().get_urls() - - # Used to extrapolate a path name, for instance - # name="{app_label}_{model_name}_export_data_type" - info = self.model._meta.app_label, self.model._meta.model_name - - my_url = [ - path( - "export_data_type/", - self.export_data_type, - name="%s_%s_export_data_type" % info, - ), - path( - "export_data_full/", - self.export_data_full, - name="%s_%s_export_data_full" % info, - ), - path( - "export_data_federal/", - self.export_data_federal, - name="%s_%s_export_data_federal" % info, - ), - ] - - return my_url + urlpatterns - def response_change(self, request, obj): # Create dictionary of action functions ACTION_FUNCTIONS = { @@ -1693,9 +1641,11 @@ def do_delete_domain(self, request, obj): else: self.message_user( request, - "Error deleting this Domain: " - f"Can't switch from state '{obj.state}' to 'deleted'" - ", must be either 'dns_needed' or 'on_hold'", + ( + "Error deleting this Domain: " + f"Can't switch from state '{obj.state}' to 'deleted'" + ", must be either 'dns_needed' or 'on_hold'" + ), messages.ERROR, ) except Exception: @@ -1707,7 +1657,7 @@ def do_delete_domain(self, request, obj): else: self.message_user( request, - ("Domain %s has been deleted. Thanks!") % obj.name, + "Domain %s has been deleted. Thanks!" % obj.name, ) return HttpResponseRedirect(".") @@ -1749,7 +1699,7 @@ def do_place_client_hold(self, request, obj): else: self.message_user( request, - ("%s is in client hold. This domain is no longer accessible on the public internet.") % obj.name, + "%s is in client hold. This domain is no longer accessible on the public internet." % obj.name, ) return HttpResponseRedirect(".") @@ -1778,7 +1728,7 @@ def do_remove_client_hold(self, request, obj): else: self.message_user( request, - ("%s is ready. This domain is accessible on the public internet.") % obj.name, + "%s is ready. This domain is accessible on the public internet." % obj.name, ) return HttpResponseRedirect(".") diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 4ed00c33f1..8c60c534f9 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -368,43 +368,6 @@ function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk, viewLink.setAttribute('title', viewLink.getAttribute('title-template').replace('selected item', elementText)); } -/** An IIFE for admin in DjangoAdmin to listen to clicks on the growth report export button, - * attach the seleted start and end dates to a url that'll trigger the view, and finally - * redirect to that url. -*/ -(function (){ - - // Get the current date in the format YYYY-MM-DD - let currentDate = new Date().toISOString().split('T')[0]; - - // Default the value of the start date input field to the current date - let startDateInput =document.getElementById('start'); - - // Default the value of the end date input field to the current date - let endDateInput =document.getElementById('end'); - - let exportGrowthReportButton = document.getElementById('exportLink'); - - if (exportGrowthReportButton) { - startDateInput.value = currentDate; - endDateInput.value = currentDate; - - exportGrowthReportButton.addEventListener('click', function() { - // Get the selected start and end dates - let startDate = startDateInput.value; - let endDate = endDateInput.value; - let exportUrl = document.getElementById('exportLink').dataset.exportUrl; - - // Build the URL with parameters - exportUrl += "?start_date=" + startDate + "&end_date=" + endDate; - - // Redirect to the export URL - window.location.href = exportUrl; - }); - } - -})(); - /** An IIFE for admin in DjangoAdmin to listen to changes on the domain request * status select amd to show/hide the rejection reason */ diff --git a/src/registrar/assets/js/get-gov-reports.js b/src/registrar/assets/js/get-gov-reports.js new file mode 100644 index 0000000000..d10cf2dc6c --- /dev/null +++ b/src/registrar/assets/js/get-gov-reports.js @@ -0,0 +1,117 @@ +/** An IIFE for admin in DjangoAdmin to listen to clicks on the growth report export button, + * attach the seleted start and end dates to a url that'll trigger the view, and finally + * redirect to that url. + * + * This function also sets the start and end dates to match the url params if they exist +*/ +(function () { + // Function to get URL parameter value by name + function getParameterByName(name, url) { + if (!url) url = window.location.href; + name = name.replace(/[\[\]]/g, '\\$&'); + var regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'), + results = regex.exec(url); + if (!results) return null; + if (!results[2]) return ''; + return decodeURIComponent(results[2].replace(/\+/g, ' ')); + } + + // Get the current date in the format YYYY-MM-DD + let currentDate = new Date().toISOString().split('T')[0]; + + // Default the value of the start date input field to the current date + let startDateInput = document.getElementById('start'); + + // Default the value of the end date input field to the current date + let endDateInput = document.getElementById('end'); + + let exportButtons = document.querySelectorAll('.exportLink'); + + if (exportButtons.length > 0) { + // Check if start and end dates are present in the URL + let urlStartDate = getParameterByName('start_date'); + let urlEndDate = getParameterByName('end_date'); + + // Set input values based on URL parameters or current date + startDateInput.value = urlStartDate || currentDate; + endDateInput.value = urlEndDate || currentDate; + + exportButtons.forEach((btn) => { + btn.addEventListener('click', function () { + // Get the selected start and end dates + let startDate = startDateInput.value; + let endDate = endDateInput.value; + let exportUrl = btn.dataset.exportUrl; + + // Build the URL with parameters + exportUrl += "?start_date=" + startDate + "&end_date=" + endDate; + + // Redirect to the export URL + window.location.href = exportUrl; + }); + }); + } + +})(); + +document.addEventListener("DOMContentLoaded", function () { + createComparativeColumnChart("myChart1", "Managed domains", "Start Date", "End Date"); + createComparativeColumnChart("myChart2", "Unmanaged domains", "Start Date", "End Date"); + createComparativeColumnChart("myChart3", "Deleted domains", "Start Date", "End Date"); + createComparativeColumnChart("myChart4", "Ready domains", "Start Date", "End Date"); + createComparativeColumnChart("myChart5", "Submitted requests", "Start Date", "End Date"); + createComparativeColumnChart("myChart6", "All requests", "Start Date", "End Date"); +}); + +function createComparativeColumnChart(canvasId, title, labelOne, labelTwo) { + var canvas = document.getElementById(canvasId); + var ctx = canvas.getContext("2d"); + + var listOne = JSON.parse(canvas.getAttribute('data-list-one')); + var listTwo = JSON.parse(canvas.getAttribute('data-list-two')); + + var data = { + labels: ["Total", "Federal", "Interstate", "State/Territory", "Tribal", "County", "City", "Special District", "School District", "Election Board"], + datasets: [ + { + label: labelOne, + backgroundColor: "rgba(255, 99, 132, 0.2)", + borderColor: "rgba(255, 99, 132, 1)", + borderWidth: 1, + data: listOne, + }, + { + label: labelTwo, + backgroundColor: "rgba(75, 192, 192, 0.2)", + borderColor: "rgba(75, 192, 192, 1)", + borderWidth: 1, + data: listTwo, + }, + ], + }; + + var options = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + }, + title: { + display: true, + text: title + } + }, + scales: { + y: { + beginAtZero: true, + }, + }, + }; + + new Chart(ctx, { + type: "bar", + data: data, + options: options, + }); +} \ No newline at end of file diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index 4f715de3c0..18025a9cbb 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -112,7 +112,8 @@ html[data-theme="light"] { .change-list .usa-table thead th, body.dashboard, body.change-list, - body.change-form { + body.change-form, + .analytics { color: var(--body-fg); } } @@ -304,7 +305,36 @@ input.admin-confirm-button { } } -.django-admin-modal .usa-prose ul > li { +.usa-button-group { + margin-left: -0.25rem!important; + padding-left: 0!important; + .usa-button-group__item { + list-style-type: none; + line-height: normal; + } + .button { + display: inline-block; + padding: 10px 8px; + line-height: normal; + } + .usa-icon { + top: 2px; + } + a.button:active, a.button:focus { + text-decoration: none; + } +} + +.module--custom { + a { + font-size: 13px; + font-weight: 600; + border: solid 1px var(--darkened-bg); + background: var(--darkened-bg); + } +} + +.usa-modal--django-admin .usa-prose ul > li { list-style-type: inherit; // Styling based off of the

styling in django admin line-height: 1.5; diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 39074aab2d..646b7298f4 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -330,8 +330,9 @@ # Google analytics requires that we relax our otherwise # strict CSP by allowing scripts to run from their domain -# and inline with a nonce, as well as allowing connections back to their domain -CSP_SCRIPT_SRC_ELEM = ["'self'", "https://www.googletagmanager.com/"] +# and inline with a nonce, as well as allowing connections back to their domain. +# Note: If needed, we can embed chart.js instead of using the CDN +CSP_SCRIPT_SRC_ELEM = ["'self'", "https://www.googletagmanager.com/", "https://cdn.jsdelivr.net/npm/chart.js"] CSP_CONNECT_SRC = ["'self'", "https://www.google-analytics.com/"] CSP_INCLUDE_NONCE_IN = ["script-src-elem"] diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index c743aed0c4..3918fa0871 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -9,9 +9,16 @@ from django.views.generic import RedirectView from registrar import views - -from registrar.views.admin_views import ExportData - +from registrar.views.admin_views import ( + ExportDataDomainsGrowth, + ExportDataFederal, + ExportDataFull, + ExportDataManagedDomains, + ExportDataRequestsGrowth, + ExportDataType, + ExportDataUnmanagedDomains, + AnalyticsView, +) from registrar.views.domain_request import Step from registrar.views.utility import always_404 @@ -52,7 +59,46 @@ "admin/logout/", RedirectView.as_view(pattern_name="logout", permanent=False), ), - path("export_data/", ExportData.as_view(), name="admin_export_data"), + path( + "admin/analytics/export_data_type/", + ExportDataType.as_view(), + name="export_data_type", + ), + path( + "admin/analytics/export_data_full/", + ExportDataFull.as_view(), + name="export_data_full", + ), + path( + "admin/analytics/export_data_federal/", + ExportDataFederal.as_view(), + name="export_data_federal", + ), + path( + "admin/analytics/export_domains_growth/", + ExportDataDomainsGrowth.as_view(), + name="export_domains_growth", + ), + path( + "admin/analytics/export_requests_growth/", + ExportDataRequestsGrowth.as_view(), + name="export_requests_growth", + ), + path( + "admin/analytics/export_managed_domains/", + ExportDataManagedDomains.as_view(), + name="export_managed_domains", + ), + path( + "admin/analytics/export_unmanaged_domains/", + ExportDataUnmanagedDomains.as_view(), + name="export_unmanaged_domains", + ), + path( + "admin/analytics/", + AnalyticsView.as_view(), + name="analytics", + ), path("admin/", admin.site.urls), path( "domain-request//edit/", diff --git a/src/registrar/public/img/registrar/dotgov_401_illo.svg b/src/registrar/public/img/registrar/dotgov_401_illo.svg deleted file mode 100644 index 71de33eaa5..0000000000 --- a/src/registrar/public/img/registrar/dotgov_401_illo.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/src/registrar/public/img/registrar/dotgov_404_illo.svg b/src/registrar/public/img/registrar/dotgov_404_illo.svg deleted file mode 100644 index 3c9adab7ea..0000000000 --- a/src/registrar/public/img/registrar/dotgov_404_illo.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/src/registrar/public/img/registrar/dotgov_500_illo.svg b/src/registrar/public/img/registrar/dotgov_500_illo.svg deleted file mode 100644 index 6dd5386440..0000000000 --- a/src/registrar/public/img/registrar/dotgov_500_illo.svg +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/registrar/templates/admin/analytics.html b/src/registrar/templates/admin/analytics.html new file mode 100644 index 0000000000..e73f22ec51 --- /dev/null +++ b/src/registrar/templates/admin/analytics.html @@ -0,0 +1,196 @@ +{% extends "admin/base_site.html" %} +{% load static %} + +{% block content_title %}

Registrar Analytics

{% endblock %} + +{% block content %} + +
+ +
+
+
+

At a glance

+
+
    +
  • User Count: {{ data.user_count }}
  • +
  • Domain Count: {{ data.domain_count }}
  • +
  • Domains in READY state: {{ data.ready_domain_count }}
  • +
  • Domain applications (last 30 days): {{ data.last_30_days_applications }}
  • +
  • Approved applications (last 30 days): {{ data.last_30_days_approved_applications }}
  • +
  • Average approval time for applications (last 30 days): {{ data.average_application_approval_time_last_30_days }}
  • +
+
+
+
+ +
+ +
+
+
+

Growth reports

+
+ {% comment %} + Inputs of type date suck for accessibility. + We'll need to replace those guys with a django form once we figure out how to hook one onto this page. + See the commit "Review for ticket #999" + {% endcomment %} +
+
+ + +
+
+ + +
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ +
+
+ +

Chart: Managed domains

+

{{ data.managed_domains_sliced_at_end_date.0 }} managed domains for {{ data.end_date }}

+
+
+
+ +

Chart: Unmanaged domains

+

{{ data.unmanaged_domains_sliced_at_end_date.0 }} unmanaged domains for {{ data.end_date }}

+
+
+
+ +
+
+ +

Chart: Deleted domains

+

{{ data.deleted_domains_sliced_at_end_date.0 }} deleted domains for {{ data.end_date }}

+
+
+
+ +

Chart: Ready domains

+

{{ data.ready_domains_sliced_at_end_date.0 }} ready domains for {{ data.end_date }}

+
+
+
+ +
+
+ +

Chart: Submitted requests

+

{{ data.submitted_requests_sliced_at_end_date.0 }} submitted requests for {{ data.end_date }}

+
+
+
+ +

Chart: All requests

+

{{ data.requests_sliced_at_end_date.0 }} requests for {{ data.end_date }}

+
+
+
+ +
+
+
+
+
+{% endblock %} diff --git a/src/registrar/templates/admin/app_list.html b/src/registrar/templates/admin/app_list.html index 49df75beb3..49fb59e79e 100644 --- a/src/registrar/templates/admin/app_list.html +++ b/src/registrar/templates/admin/app_list.html @@ -64,6 +64,11 @@ {% endfor %} +
+

Analytics

+ Dashboard +
{% else %}

{% translate 'You don’t have permission to view or edit anything.' %}

-{% endif %} \ No newline at end of file +{% endif %} + diff --git a/src/registrar/templates/admin/base_site.html b/src/registrar/templates/admin/base_site.html index 73e9ba1f0d..58843421af 100644 --- a/src/registrar/templates/admin/base_site.html +++ b/src/registrar/templates/admin/base_site.html @@ -20,7 +20,9 @@ > + + {% endblock %} {% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} diff --git a/src/registrar/templates/admin/index.html b/src/registrar/templates/admin/index.html deleted file mode 100644 index 04601ef32e..0000000000 --- a/src/registrar/templates/admin/index.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "admin/index.html" %} - -{% block content %} -
- {% include "admin/app_list.html" with app_list=app_list show_changelinks=True %} -
-

Reports

-

Domain growth report

- - {% comment %} - Inputs of type date suck for accessibility. - We'll need to replace those guys with a django form once we figure out how to hook one onto this page. - The challenge is in the path definition in urls. Itdoes NOT like admin/export_data/ - - See the commit "Review for ticket #999" - {% endcomment %} - -
-
- - -
-
- - -
- - -
- -
-
-{% endblock %} \ No newline at end of file diff --git a/src/registrar/templates/django/admin/domain_change_form.html b/src/registrar/templates/django/admin/domain_change_form.html index 818522c8dc..44fe6851b9 100644 --- a/src/registrar/templates/django/admin/domain_change_form.html +++ b/src/registrar/templates/django/admin/domain_change_form.html @@ -54,7 +54,7 @@ {# Create a modal for the _extend_expiration_date button #}
{# Create a modal for the _on_hold button #}
{# Create a modal for the _remove_domain button #}
-
  • - Export all domain metadata -
  • -
  • - Export current-full.csv -
  • -
  • - Export current-federal.csv -
  • - {% if has_add_permission %} -
  • - - Add domain - -
  • - {% endif %} - -{% endblock %} \ No newline at end of file diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 73bd08b62c..04eb4c82db 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -1,4 +1,3 @@ -import datetime import os import logging @@ -13,6 +12,8 @@ from django.conf import settings from django.contrib.auth import get_user_model, login from django.utils.timezone import make_aware +from datetime import date, datetime, timedelta +from django.utils import timezone from registrar.models import ( Contact, @@ -35,6 +36,7 @@ ErrorCode, responses, ) +from registrar.models.user_domain_role import UserDomainRole from registrar.models.utility.contact_error import ContactError, ContactErrorCodes @@ -492,6 +494,184 @@ def create_full_dummy_domain_object( return domain_request +class MockDb(TestCase): + """Hardcoded mocks make test case assertions straightforward.""" + + def setUp(self): + super().setUp() + username = "test_user" + first_name = "First" + last_name = "Last" + email = "info@example.com" + self.user = get_user_model().objects.create( + username=username, first_name=first_name, last_name=last_name, email=email + ) + + # Create a time-aware current date + current_datetime = timezone.now() + # Extract the date part + current_date = current_datetime.date() + # Create start and end dates using timedelta + self.end_date = current_date + timedelta(days=2) + self.start_date = current_date - timedelta(days=2) + + self.domain_1, _ = Domain.objects.get_or_create( + name="cdomain1.gov", state=Domain.State.READY, first_ready=timezone.now() + ) + self.domain_2, _ = Domain.objects.get_or_create(name="adomain2.gov", state=Domain.State.DNS_NEEDED) + self.domain_3, _ = Domain.objects.get_or_create(name="ddomain3.gov", state=Domain.State.ON_HOLD) + self.domain_4, _ = Domain.objects.get_or_create(name="bdomain4.gov", state=Domain.State.UNKNOWN) + self.domain_4, _ = Domain.objects.get_or_create(name="bdomain4.gov", state=Domain.State.UNKNOWN) + self.domain_5, _ = Domain.objects.get_or_create( + name="bdomain5.gov", state=Domain.State.DELETED, deleted=timezone.make_aware(datetime(2023, 11, 1)) + ) + self.domain_6, _ = Domain.objects.get_or_create( + name="bdomain6.gov", state=Domain.State.DELETED, deleted=timezone.make_aware(datetime(1980, 10, 16)) + ) + self.domain_7, _ = Domain.objects.get_or_create( + name="xdomain7.gov", state=Domain.State.DELETED, deleted=timezone.now() + ) + self.domain_8, _ = Domain.objects.get_or_create( + name="sdomain8.gov", state=Domain.State.DELETED, deleted=timezone.now() + ) + # We use timezone.make_aware to sync to server time a datetime object with the current date (using date.today()) + # and a specific time (using datetime.min.time()). + # Deleted yesterday + self.domain_9, _ = Domain.objects.get_or_create( + name="zdomain9.gov", + state=Domain.State.DELETED, + deleted=timezone.make_aware(datetime.combine(date.today() - timedelta(days=1), datetime.min.time())), + ) + # ready tomorrow + self.domain_10, _ = Domain.objects.get_or_create( + name="adomain10.gov", + state=Domain.State.READY, + first_ready=timezone.make_aware(datetime.combine(date.today() + timedelta(days=1), datetime.min.time())), + ) + + self.domain_information_1, _ = DomainInformation.objects.get_or_create( + creator=self.user, + domain=self.domain_1, + organization_type="federal", + federal_agency="World War I Centennial Commission", + federal_type="executive", + is_election_board=True, + ) + self.domain_information_2, _ = DomainInformation.objects.get_or_create( + creator=self.user, domain=self.domain_2, organization_type="interstate", is_election_board=True + ) + self.domain_information_3, _ = DomainInformation.objects.get_or_create( + creator=self.user, + domain=self.domain_3, + organization_type="federal", + federal_agency="Armed Forces Retirement Home", + is_election_board=True, + ) + self.domain_information_4, _ = DomainInformation.objects.get_or_create( + creator=self.user, + domain=self.domain_4, + organization_type="federal", + federal_agency="Armed Forces Retirement Home", + is_election_board=True, + ) + self.domain_information_5, _ = DomainInformation.objects.get_or_create( + creator=self.user, + domain=self.domain_5, + organization_type="federal", + federal_agency="Armed Forces Retirement Home", + is_election_board=False, + ) + self.domain_information_6, _ = DomainInformation.objects.get_or_create( + creator=self.user, + domain=self.domain_6, + organization_type="federal", + federal_agency="Armed Forces Retirement Home", + is_election_board=False, + ) + self.domain_information_7, _ = DomainInformation.objects.get_or_create( + creator=self.user, + domain=self.domain_7, + organization_type="federal", + federal_agency="Armed Forces Retirement Home", + is_election_board=False, + ) + self.domain_information_8, _ = DomainInformation.objects.get_or_create( + creator=self.user, + domain=self.domain_8, + organization_type="federal", + federal_agency="Armed Forces Retirement Home", + is_election_board=False, + ) + self.domain_information_9, _ = DomainInformation.objects.get_or_create( + creator=self.user, + domain=self.domain_9, + organization_type="federal", + federal_agency="Armed Forces Retirement Home", + is_election_board=False, + ) + self.domain_information_10, _ = DomainInformation.objects.get_or_create( + creator=self.user, + domain=self.domain_10, + organization_type="federal", + federal_agency="Armed Forces Retirement Home", + is_election_board=False, + ) + + meoward_user = get_user_model().objects.create( + username="meoward_username", first_name="first_meoward", last_name="last_meoward", email="meoward@rocks.com" + ) + + lebowski_user = get_user_model().objects.create( + username="big_lebowski", first_name="big", last_name="lebowski", email="big_lebowski@dude.co" + ) + + _, created = UserDomainRole.objects.get_or_create( + user=meoward_user, domain=self.domain_1, role=UserDomainRole.Roles.MANAGER + ) + + _, created = UserDomainRole.objects.get_or_create( + user=self.user, domain=self.domain_1, role=UserDomainRole.Roles.MANAGER + ) + + _, created = UserDomainRole.objects.get_or_create( + user=lebowski_user, domain=self.domain_1, role=UserDomainRole.Roles.MANAGER + ) + + _, created = UserDomainRole.objects.get_or_create( + user=meoward_user, domain=self.domain_2, role=UserDomainRole.Roles.MANAGER + ) + + with less_console_noise(): + self.domain_request_1 = completed_domain_request( + status=DomainRequest.DomainRequestStatus.STARTED, name="city1.gov" + ) + self.domain_request_2 = completed_domain_request( + status=DomainRequest.DomainRequestStatus.IN_REVIEW, name="city2.gov" + ) + self.domain_request_3 = completed_domain_request( + status=DomainRequest.DomainRequestStatus.STARTED, name="city3.gov" + ) + self.domain_request_4 = completed_domain_request( + status=DomainRequest.DomainRequestStatus.STARTED, name="city4.gov" + ) + self.domain_request_5 = completed_domain_request( + status=DomainRequest.DomainRequestStatus.APPROVED, name="city5.gov" + ) + self.domain_request_3.submit() + self.domain_request_3.save() + self.domain_request_4.submit() + self.domain_request_4.save() + + def tearDown(self): + super().tearDown() + PublicContact.objects.all().delete() + Domain.objects.all().delete() + DomainInformation.objects.all().delete() + DomainRequest.objects.all().delete() + User.objects.all().delete() + UserDomainRole.objects.all().delete() + + def mock_user(): """A simple user.""" user_kwargs = dict( @@ -680,7 +860,7 @@ def dummyInfoContactResultData( self, id, email, - cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), + cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), pw="thisisnotapassword", ): fake = info.InfoContactResultData( @@ -718,82 +898,82 @@ def dummyInfoContactResultData( mockDataInfoDomain = fakedEppObject( "fakePw", - cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), + cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)], hosts=["fake.host.com"], statuses=[ common.Status(state="serverTransferProhibited", description="", lang="en"), common.Status(state="inactive", description="", lang="en"), ], - ex_date=datetime.date(2023, 5, 25), + ex_date=date(2023, 5, 25), ) mockDataInfoDomainSubdomain = fakedEppObject( "fakePw", - cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), + cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)], hosts=["fake.meoward.gov"], statuses=[ common.Status(state="serverTransferProhibited", description="", lang="en"), common.Status(state="inactive", description="", lang="en"), ], - ex_date=datetime.date(2023, 5, 25), + ex_date=date(2023, 5, 25), ) mockDataInfoDomainSubdomainAndIPAddress = fakedEppObject( "fakePw", - cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), + cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)], hosts=["fake.meow.gov"], statuses=[ common.Status(state="serverTransferProhibited", description="", lang="en"), common.Status(state="inactive", description="", lang="en"), ], - ex_date=datetime.date(2023, 5, 25), + ex_date=date(2023, 5, 25), addrs=[common.Ip(addr="2.0.0.8")], ) mockDataInfoDomainNotSubdomainNoIP = fakedEppObject( "fakePw", - cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), + cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)], hosts=["fake.meow.com"], statuses=[ common.Status(state="serverTransferProhibited", description="", lang="en"), common.Status(state="inactive", description="", lang="en"), ], - ex_date=datetime.date(2023, 5, 25), + ex_date=date(2023, 5, 25), ) mockDataInfoDomainSubdomainNoIP = fakedEppObject( "fakePw", - cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), + cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)], hosts=["fake.subdomainwoip.gov"], statuses=[ common.Status(state="serverTransferProhibited", description="", lang="en"), common.Status(state="inactive", description="", lang="en"), ], - ex_date=datetime.date(2023, 5, 25), + ex_date=date(2023, 5, 25), ) mockDataExtensionDomain = fakedEppObject( "fakePw", - cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), + cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)], hosts=["fake.host.com"], statuses=[ common.Status(state="serverTransferProhibited", description="", lang="en"), common.Status(state="inactive", description="", lang="en"), ], - ex_date=datetime.date(2023, 11, 15), + ex_date=date(2023, 11, 15), ) mockDataInfoContact = mockDataInfoDomain.dummyInfoContactResultData( - "123", "123@mail.gov", datetime.datetime(2023, 5, 25, 19, 45, 35), "lastPw" + "123", "123@mail.gov", datetime(2023, 5, 25, 19, 45, 35), "lastPw" ) InfoDomainWithContacts = fakedEppObject( "fakepw", - cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), + cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), contacts=[ common.DomainContact( contact="securityContact", @@ -818,7 +998,7 @@ def dummyInfoContactResultData( InfoDomainWithDefaultSecurityContact = fakedEppObject( "fakepw", - cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), + cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), contacts=[ common.DomainContact( contact="defaultSec", @@ -833,11 +1013,11 @@ def dummyInfoContactResultData( ) mockVerisignDataInfoContact = mockDataInfoDomain.dummyInfoContactResultData( - "defaultVeri", "registrar@dotgov.gov", datetime.datetime(2023, 5, 25, 19, 45, 35), "lastPw" + "defaultVeri", "registrar@dotgov.gov", datetime(2023, 5, 25, 19, 45, 35), "lastPw" ) InfoDomainWithVerisignSecurityContact = fakedEppObject( "fakepw", - cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), + cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), contacts=[ common.DomainContact( contact="defaultVeri", @@ -853,7 +1033,7 @@ def dummyInfoContactResultData( InfoDomainWithDefaultTechnicalContact = fakedEppObject( "fakepw", - cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), + cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), contacts=[ common.DomainContact( contact="defaultTech", @@ -878,14 +1058,14 @@ def dummyInfoContactResultData( infoDomainNoContact = fakedEppObject( "security", - cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), + cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), contacts=[], hosts=["fake.host.com"], ) infoDomainThreeHosts = fakedEppObject( "my-nameserver.gov", - cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), + cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), contacts=[], hosts=[ "ns1.my-nameserver-1.com", @@ -896,43 +1076,43 @@ def dummyInfoContactResultData( infoDomainNoHost = fakedEppObject( "my-nameserver.gov", - cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), + cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), contacts=[], hosts=[], ) infoDomainTwoHosts = fakedEppObject( "my-nameserver.gov", - cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), + cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), contacts=[], hosts=["ns1.my-nameserver-1.com", "ns1.my-nameserver-2.com"], ) mockDataInfoHosts = fakedEppObject( "lastPw", - cr_date=make_aware(datetime.datetime(2023, 8, 25, 19, 45, 35)), + cr_date=make_aware(datetime(2023, 8, 25, 19, 45, 35)), addrs=[common.Ip(addr="1.2.3.4"), common.Ip(addr="2.3.4.5")], ) mockDataInfoHosts1IP = fakedEppObject( "lastPw", - cr_date=make_aware(datetime.datetime(2023, 8, 25, 19, 45, 35)), + cr_date=make_aware(datetime(2023, 8, 25, 19, 45, 35)), addrs=[common.Ip(addr="2.0.0.8")], ) mockDataInfoHostsNotSubdomainNoIP = fakedEppObject( "lastPw", - cr_date=make_aware(datetime.datetime(2023, 8, 26, 19, 45, 35)), + cr_date=make_aware(datetime(2023, 8, 26, 19, 45, 35)), addrs=[], ) mockDataInfoHostsSubdomainNoIP = fakedEppObject( "lastPw", - cr_date=make_aware(datetime.datetime(2023, 8, 27, 19, 45, 35)), + cr_date=make_aware(datetime(2023, 8, 27, 19, 45, 35)), addrs=[], ) - mockDataHostChange = fakedEppObject("lastPw", cr_date=make_aware(datetime.datetime(2023, 8, 25, 19, 45, 35))) + mockDataHostChange = fakedEppObject("lastPw", cr_date=make_aware(datetime(2023, 8, 25, 19, 45, 35))) addDsData1 = { "keyTag": 1234, "alg": 3, @@ -964,7 +1144,7 @@ def dummyInfoContactResultData( infoDomainHasIP = fakedEppObject( "nameserverwithip.gov", - cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), + cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), contacts=[ common.DomainContact( contact="securityContact", @@ -989,7 +1169,7 @@ def dummyInfoContactResultData( justNameserver = fakedEppObject( "justnameserver.com", - cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), + cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), contacts=[ common.DomainContact( contact="securityContact", @@ -1012,7 +1192,7 @@ def dummyInfoContactResultData( infoDomainCheckHostIPCombo = fakedEppObject( "nameserversubdomain.gov", - cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)), + cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)), contacts=[], hosts=[ "ns1.nameserversubdomain.gov", @@ -1022,27 +1202,27 @@ def dummyInfoContactResultData( mockRenewedDomainExpDate = fakedEppObject( "fake.gov", - ex_date=datetime.date(2023, 5, 25), + ex_date=date(2023, 5, 25), ) mockButtonRenewedDomainExpDate = fakedEppObject( "fake.gov", - ex_date=datetime.date(2025, 5, 25), + ex_date=date(2025, 5, 25), ) mockDnsNeededRenewedDomainExpDate = fakedEppObject( "fakeneeded.gov", - ex_date=datetime.date(2023, 2, 15), + ex_date=date(2023, 2, 15), ) mockMaximumRenewedDomainExpDate = fakedEppObject( "fakemaximum.gov", - ex_date=datetime.date(2024, 12, 31), + ex_date=date(2024, 12, 31), ) mockRecentRenewedDomainExpDate = fakedEppObject( "waterbutpurple.gov", - ex_date=datetime.date(2024, 11, 15), + ex_date=date(2024, 11, 15), ) def _mockDomainName(self, _name, _avail=False): diff --git a/src/registrar/tests/test_admin_views.py b/src/registrar/tests/test_admin_views.py index aa150d55c0..cc4b3f1c7b 100644 --- a/src/registrar/tests/test_admin_views.py +++ b/src/registrar/tests/test_admin_views.py @@ -3,7 +3,7 @@ from registrar.tests.common import create_superuser -class TestViews(TestCase): +class TestAdminViews(TestCase): def setUp(self): self.client = Client(HTTP_HOST="localhost:8080") self.superuser = create_superuser() @@ -26,7 +26,7 @@ def test_export_data_view(self): # Construct the URL for the export data view with start_date and end_date parameters: # This stuff is currently done in JS - export_data_url = reverse("admin_export_data") + f"?start_date={start_date}&end_date={end_date}" + export_data_url = reverse("export_domains_growth") + f"?start_date={start_date}&end_date={end_date}" # Make a GET request to the export data page response = self.client.get(export_data_url) diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index 011c60b93d..b91f3bd18d 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -1,18 +1,18 @@ import csv import io -from django.test import Client, RequestFactory, TestCase +from django.test import Client, RequestFactory from io import StringIO -from registrar.models.domain_information import DomainInformation +from registrar.models.domain_request import DomainRequest from registrar.models.domain import Domain -from registrar.models.public_contact import PublicContact -from registrar.models.user import User -from django.contrib.auth import get_user_model -from registrar.models.user_domain_role import UserDomainRole -from registrar.tests.common import MockEppLib from registrar.utility.csv_export import ( - write_csv, + export_data_managed_domains_to_csv, + export_data_unmanaged_domains_to_csv, + get_sliced_domains, + get_sliced_requests, + write_domains_csv, get_default_start_date, get_default_end_date, + write_requests_csv, ) from django.core.management import call_command @@ -22,62 +22,19 @@ from botocore.exceptions import ClientError import boto3_mocking from registrar.utility.s3_bucket import S3ClientError, S3ClientErrorCodes # type: ignore -from datetime import date, datetime, timedelta +from datetime import datetime from django.utils import timezone -from .common import less_console_noise +from .common import MockDb, MockEppLib, less_console_noise -class CsvReportsTest(TestCase): +class CsvReportsTest(MockDb): """Tests to determine if we are uploading our reports correctly""" def setUp(self): """Create fake domain data""" + super().setUp() self.client = Client(HTTP_HOST="localhost:8080") self.factory = RequestFactory() - username = "test_user" - first_name = "First" - last_name = "Last" - email = "info@example.com" - self.user = get_user_model().objects.create( - username=username, first_name=first_name, last_name=last_name, email=email - ) - - self.domain_1, _ = Domain.objects.get_or_create(name="cdomain1.gov", state=Domain.State.READY) - self.domain_2, _ = Domain.objects.get_or_create(name="adomain2.gov", state=Domain.State.DNS_NEEDED) - self.domain_3, _ = Domain.objects.get_or_create(name="ddomain3.gov", state=Domain.State.ON_HOLD) - self.domain_4, _ = Domain.objects.get_or_create(name="bdomain4.gov", state=Domain.State.UNKNOWN) - - self.domain_information_1, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_1, - organization_type="federal", - federal_agency="World War I Centennial Commission", - federal_type="executive", - ) - self.domain_information_2, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_2, - organization_type="interstate", - ) - self.domain_information_3, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_3, - organization_type="federal", - federal_agency="Armed Forces Retirement Home", - ) - self.domain_information_4, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_4, - organization_type="federal", - federal_agency="Armed Forces Retirement Home", - ) - - def tearDown(self): - """Delete all faked data""" - Domain.objects.all().delete() - DomainInformation.objects.all().delete() - User.objects.all().delete() - super().tearDown() @boto3_mocking.patching def test_generate_federal_report(self): @@ -88,6 +45,7 @@ def test_generate_federal_report(self): expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,, \r\n"), + call("adomain10.gov,Federal,Armed Forces Retirement Home,,,, \r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,, \r\n"), ] # We don't actually want to write anything for a test case, @@ -108,6 +66,7 @@ def test_generate_full_report(self): expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,, \r\n"), + call("adomain10.gov,Federal,Armed Forces Retirement Home,,,, \r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,, \r\n"), call("adomain2.gov,Interstate,,,,, \r\n"), ] @@ -166,6 +125,7 @@ def side_effect(Bucket, Key): @boto3_mocking.patching def test_load_federal_report(self): """Tests the get_current_federal api endpoint""" + with less_console_noise(): mock_client = MagicMock() mock_client_instance = mock_client.return_value @@ -199,6 +159,7 @@ def test_load_federal_report(self): @boto3_mocking.patching def test_load_full_report(self): """Tests the current-federal api link""" + with less_console_noise(): mock_client = MagicMock() mock_client_instance = mock_client.return_value @@ -231,141 +192,17 @@ def test_load_full_report(self): self.assertEqual(expected_file_content, response.content) -class ExportDataTest(MockEppLib): +class ExportDataTest(MockDb, MockEppLib): def setUp(self): super().setUp() - username = "test_user" - first_name = "First" - last_name = "Last" - email = "info@example.com" - self.user = get_user_model().objects.create( - username=username, first_name=first_name, last_name=last_name, email=email - ) - - self.domain_1, _ = Domain.objects.get_or_create( - name="cdomain1.gov", state=Domain.State.READY, first_ready=timezone.now() - ) - self.domain_2, _ = Domain.objects.get_or_create(name="adomain2.gov", state=Domain.State.DNS_NEEDED) - self.domain_3, _ = Domain.objects.get_or_create(name="ddomain3.gov", state=Domain.State.ON_HOLD) - self.domain_4, _ = Domain.objects.get_or_create(name="bdomain4.gov", state=Domain.State.UNKNOWN) - self.domain_4, _ = Domain.objects.get_or_create(name="bdomain4.gov", state=Domain.State.UNKNOWN) - self.domain_5, _ = Domain.objects.get_or_create( - name="bdomain5.gov", state=Domain.State.DELETED, deleted=timezone.make_aware(datetime(2023, 11, 1)) - ) - self.domain_6, _ = Domain.objects.get_or_create( - name="bdomain6.gov", state=Domain.State.DELETED, deleted=timezone.make_aware(datetime(1980, 10, 16)) - ) - self.domain_7, _ = Domain.objects.get_or_create( - name="xdomain7.gov", state=Domain.State.DELETED, deleted=timezone.now() - ) - self.domain_8, _ = Domain.objects.get_or_create( - name="sdomain8.gov", state=Domain.State.DELETED, deleted=timezone.now() - ) - # We use timezone.make_aware to sync to server time a datetime object with the current date (using date.today()) - # and a specific time (using datetime.min.time()). - # Deleted yesterday - self.domain_9, _ = Domain.objects.get_or_create( - name="zdomain9.gov", - state=Domain.State.DELETED, - deleted=timezone.make_aware(datetime.combine(date.today() - timedelta(days=1), datetime.min.time())), - ) - # ready tomorrow - self.domain_10, _ = Domain.objects.get_or_create( - name="adomain10.gov", - state=Domain.State.READY, - first_ready=timezone.make_aware(datetime.combine(date.today() + timedelta(days=1), datetime.min.time())), - ) - - self.domain_information_1, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_1, - organization_type="federal", - federal_agency="World War I Centennial Commission", - federal_type="executive", - ) - self.domain_information_2, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_2, - organization_type="interstate", - ) - self.domain_information_3, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_3, - organization_type="federal", - federal_agency="Armed Forces Retirement Home", - ) - self.domain_information_4, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_4, - organization_type="federal", - federal_agency="Armed Forces Retirement Home", - ) - self.domain_information_5, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_5, - organization_type="federal", - federal_agency="Armed Forces Retirement Home", - ) - self.domain_information_6, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_6, - organization_type="federal", - federal_agency="Armed Forces Retirement Home", - ) - self.domain_information_7, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_7, - organization_type="federal", - federal_agency="Armed Forces Retirement Home", - ) - self.domain_information_8, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_8, - organization_type="federal", - federal_agency="Armed Forces Retirement Home", - ) - self.domain_information_9, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_9, - organization_type="federal", - federal_agency="Armed Forces Retirement Home", - ) - self.domain_information_10, _ = DomainInformation.objects.get_or_create( - creator=self.user, - domain=self.domain_10, - organization_type="federal", - federal_agency="Armed Forces Retirement Home", - ) - - meoward_user = get_user_model().objects.create( - username="meoward_username", first_name="first_meoward", last_name="last_meoward", email="meoward@rocks.com" - ) - - # Test for more than 1 domain manager - _, created = UserDomainRole.objects.get_or_create( - user=meoward_user, domain=self.domain_1, role=UserDomainRole.Roles.MANAGER - ) - - _, created = UserDomainRole.objects.get_or_create( - user=self.user, domain=self.domain_1, role=UserDomainRole.Roles.MANAGER - ) - - # Test for just 1 domain manager - _, created = UserDomainRole.objects.get_or_create( - user=meoward_user, domain=self.domain_2, role=UserDomainRole.Roles.MANAGER - ) def tearDown(self): - PublicContact.objects.all().delete() - Domain.objects.all().delete() - DomainInformation.objects.all().delete() - User.objects.all().delete() - UserDomainRole.objects.all().delete() super().tearDown() def test_export_domains_to_writer_security_emails(self): """Test that export_domains_to_writer returns the expected security email""" + with less_console_noise(): # Add security email information self.domain_1.name = "defaultsecurity.gov" @@ -403,7 +240,7 @@ def test_export_domains_to_writer_security_emails(self): } self.maxDiff = None # Call the export functions - write_csv( + write_domains_csv( writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True ) @@ -427,10 +264,11 @@ def test_export_domains_to_writer_security_emails(self): expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() self.assertEqual(csv_content, expected_content) - def test_write_csv(self): + def test_write_domains_csv(self): """Test that write_body returns the existing domain, test that sort by domain name works, test that filter works""" + with less_console_noise(): # Create a CSV file in memory csv_file = StringIO() @@ -462,7 +300,7 @@ def test_write_csv(self): ], } # Call the export functions - write_csv( + write_domains_csv( writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True ) # Reset the CSV file's position to the beginning @@ -486,8 +324,9 @@ def test_write_csv(self): expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() self.assertEqual(csv_content, expected_content) - def test_write_body_additional(self): + def test_write_domains_body_additional(self): """An additional test for filters and multi-column sort""" + with less_console_noise(): # Create a CSV file in memory csv_file = StringIO() @@ -512,7 +351,7 @@ def test_write_body_additional(self): ], } # Call the export functions - write_csv( + write_domains_csv( writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True ) # Reset the CSV file's position to the beginning @@ -535,27 +374,23 @@ def test_write_body_additional(self): expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() self.assertEqual(csv_content, expected_content) - def test_write_body_with_date_filter_pulls_domains_in_range(self): + def test_write_domains_body_with_date_filter_pulls_domains_in_range(self): """Test that domains that are 1. READY and their first_ready dates are in range 2. DELETED and their deleted dates are in range are pulled when the growth report conditions are applied to export_domains_to_writed. Test that ready domains are sorted by first_ready/deleted dates first, names second. - We considered testing export_data_growth_to_csv which calls write_body + We considered testing export_data_domain_growth_to_csv which calls write_body and would have been easy to set up, but expected_content would contain created_at dates which are hard to mock. - TODO: Simplify is created_at is not needed for the report.""" + TODO: Simplify if created_at is not needed for the report.""" + with less_console_noise(): # Create a CSV file in memory csv_file = StringIO() writer = csv.writer(csv_file) - # We use timezone.make_aware to sync to server time a datetime object with the current date - # (using date.today()) and a specific time (using datetime.min.time()). - end_date = timezone.make_aware(datetime.combine(date.today() + timedelta(days=2), datetime.min.time())) - start_date = timezone.make_aware(datetime.combine(date.today() - timedelta(days=2), datetime.min.time())) - # Define columns, sort fields, and filter condition columns = [ "Domain name", @@ -579,19 +414,19 @@ def test_write_body_with_date_filter_pulls_domains_in_range(self): "domain__state__in": [ Domain.State.READY, ], - "domain__first_ready__lte": end_date, - "domain__first_ready__gte": start_date, + "domain__first_ready__lte": self.end_date, + "domain__first_ready__gte": self.start_date, } filter_conditions_for_deleted_domains = { "domain__state__in": [ Domain.State.DELETED, ], - "domain__deleted__lte": end_date, - "domain__deleted__gte": start_date, + "domain__deleted__lte": self.end_date, + "domain__deleted__gte": self.start_date, } # Call the export functions - write_csv( + write_domains_csv( writer, columns, sort_fields, @@ -599,7 +434,7 @@ def test_write_body_with_date_filter_pulls_domains_in_range(self): get_domain_managers=False, should_write_header=True, ) - write_csv( + write_domains_csv( writer, columns, sort_fields_for_deleted_domains, @@ -634,13 +469,13 @@ def test_write_body_with_date_filter_pulls_domains_in_range(self): def test_export_domains_to_writer_domain_managers(self): """Test that export_domains_to_writer returns the - expected domain managers""" + expected domain managers.""" + with less_console_noise(): # Create a CSV file in memory csv_file = StringIO() writer = csv.writer(csv_file) # Define columns, sort fields, and filter condition - columns = [ "Domain name", "Status", @@ -664,7 +499,7 @@ def test_export_domains_to_writer_domain_managers(self): } self.maxDiff = None # Call the export functions - write_csv( + write_domains_csv( writer, columns, sort_fields, filter_condition, get_domain_managers=True, should_write_header=True ) @@ -677,11 +512,11 @@ def test_export_domains_to_writer_domain_managers(self): expected_content = ( "Domain name,Status,Expiration date,Domain type,Agency," "Organization name,City,State,AO,AO email," - "Security contact email,Domain manager email 1,Domain manager email 2,\n" + "Security contact email,Domain manager email 1,Domain manager email 2,Domain manager email 3\n" "adomain10.gov,Ready,,Federal,Armed Forces Retirement Home,,,, , ,\n" "adomain2.gov,Dns needed,,Interstate,,,,, , , ,meoward@rocks.com\n" "cdomain1.gov,Ready,,Federal - Executive,World War I Centennial Commission,,," - ", , , ,meoward@rocks.com,info@example.com\n" + ", , , ,meoward@rocks.com,info@example.com,big_lebowski@dude.co\n" "ddomain3.gov,On hold,,Federal,Armed Forces Retirement Home,,,, , , ,,\n" ) # Normalize line endings and remove commas, @@ -690,8 +525,132 @@ def test_export_domains_to_writer_domain_managers(self): expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() self.assertEqual(csv_content, expected_content) + def test_export_data_managed_domains_to_csv(self): + """Test get counts for domains that have domain managers for two different dates, + get list of managed domains at end_date.""" + + with less_console_noise(): + # Create a CSV file in memory + csv_file = StringIO() + export_data_managed_domains_to_csv( + csv_file, self.start_date.strftime("%Y-%m-%d"), self.end_date.strftime("%Y-%m-%d") + ) + + # Reset the CSV file's position to the beginning + csv_file.seek(0) + # Read the content into a variable + csv_content = csv_file.read() + self.maxDiff = None + # We expect the READY domain names with the domain managers: Their counts, and listing at end_date. + expected_content = ( + "MANAGED DOMAINS COUNTS AT START DATE\n" + "Total,Federal,Interstate,State or territory,Tribal,County,City,Special district," + "School district,Election office\n" + "0,0,0,0,0,0,0,0,0,0\n" + "\n" + "MANAGED DOMAINS COUNTS AT END DATE\n" + "Total,Federal,Interstate,State or territory,Tribal,County,City," + "Special district,School district,Election office\n" + "1,1,0,0,0,0,0,0,0,1\n" + "\n" + "Domain name,Domain type,Domain manager email 1,Domain manager email 2,Domain manager email 3\n" + "cdomain1.gov,Federal - Executive,meoward@rocks.com,info@example.com,big_lebowski@dude.co\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.assertEqual(csv_content, expected_content) + + def test_export_data_unmanaged_domains_to_csv(self): + """Test get counts for domains that do not have domain managers for two different dates, + get list of unmanaged domains at end_date.""" + + with less_console_noise(): + # Create a CSV file in memory + csv_file = StringIO() + export_data_unmanaged_domains_to_csv( + csv_file, self.start_date.strftime("%Y-%m-%d"), self.end_date.strftime("%Y-%m-%d") + ) + + # Reset the CSV file's position to the beginning + csv_file.seek(0) + # Read the content into a variable + csv_content = csv_file.read() + self.maxDiff = None + # We expect the READY domain names with the domain managers: Their counts, and listing at end_date. + expected_content = ( + "UNMANAGED DOMAINS AT START DATE\n" + "Total,Federal,Interstate,State or territory,Tribal,County,City,Special district," + "School district,Election office\n" + "0,0,0,0,0,0,0,0,0,0\n" + "\n" + "UNMANAGED DOMAINS AT END DATE\n" + "Total,Federal,Interstate,State or territory,Tribal,County,City,Special district," + "School district,Election office\n" + "1,1,0,0,0,0,0,0,0,0\n" + "\n" + "Domain name,Domain type\n" + "adomain10.gov,Federal\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.assertEqual(csv_content, expected_content) + + def test_write_requests_body_with_date_filter_pulls_requests_in_range(self): + """Test that requests that are + 1. SUBMITTED and their submission_date are in range + are pulled when the growth report conditions are applied to export_requests_to_writed. + Test that requests are sorted by requested domain name. + """ -class HelperFunctions(TestCase): + with less_console_noise(): + # Create a CSV file in memory + csv_file = StringIO() + writer = csv.writer(csv_file) + # Define columns, sort fields, and filter condition + # We'll skip submission date because it's dynamic and therefore + # impossible to set in expected_content + columns = [ + "Requested domain", + "Organization type", + ] + sort_fields = [ + "requested_domain__name", + ] + filter_condition = { + "status": DomainRequest.DomainRequestStatus.SUBMITTED, + "submission_date__lte": self.end_date, + "submission_date__gte": self.start_date, + } + write_requests_csv(writer, columns, sort_fields, filter_condition, should_write_header=True) + # 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 READY domains first, created between today-2 and today+2, sorted by created_at then name + # and DELETED domains deleted between today-2 and today+2, sorted by deleted then name + expected_content = ( + "Requested domain,Organization type\n" + "city3.gov,Federal - Executive\n" + "city4.gov,Federal - Executive\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.assertEqual(csv_content, expected_content) + + +class HelperFunctions(MockDb): """This asserts that 1=1. Its limited usefulness lies in making sure the helper methods stay healthy.""" def test_get_default_start_date(self): @@ -704,3 +663,33 @@ def test_get_default_end_date(self): expected_date = timezone.now() actual_date = get_default_end_date() self.assertEqual(actual_date.date(), expected_date.date()) + + def test_get_sliced_domains(self): + """Should get fitered domains counts sliced by org type and election office.""" + + with less_console_noise(): + filter_condition = { + "domain__permissions__isnull": False, + "domain__first_ready__lte": self.end_date, + } + # Test with distinct + managed_domains_sliced_at_end_date = get_sliced_domains(filter_condition, True) + expected_content = [1, 1, 0, 0, 0, 0, 0, 0, 0, 1] + self.assertEqual(managed_domains_sliced_at_end_date, expected_content) + + # Test without distinct + managed_domains_sliced_at_end_date = get_sliced_domains(filter_condition) + expected_content = [1, 3, 0, 0, 0, 0, 0, 0, 0, 1] + self.assertEqual(managed_domains_sliced_at_end_date, expected_content) + + def test_get_sliced_requests(self): + """Should get fitered requests counts sliced by org type and election office.""" + + with less_console_noise(): + filter_condition = { + "status": DomainRequest.DomainRequestStatus.SUBMITTED, + "submission_date__lte": self.end_date, + } + submitted_requests_sliced_at_end_date = get_sliced_requests(filter_condition) + expected_content = [2, 2, 0, 0, 0, 0, 0, 0, 0, 0] + self.assertEqual(submitted_requests_sliced_at_end_date, expected_content) diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 50d580d36f..916e0b0c84 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -1,7 +1,9 @@ +from collections import Counter import csv import logging from datetime import datetime from registrar.models.domain import Domain +from registrar.models.domain_request import DomainRequest from registrar.models.domain_information import DomainInformation from django.utils import timezone from django.core.paginator import Paginator @@ -19,16 +21,22 @@ def write_header(writer, columns): Receives params from the parent methods and outputs a CSV with a header row. Works with write_header as long as the same writer object is passed. """ - writer.writerow(columns) def get_domain_infos(filter_condition, sort_fields): + """ + Returns DomainInformation objects filtered and sorted based on the provided conditions. + filter_condition -> A dictionary of conditions to filter the objects. + sort_fields -> A list of fields to sort the resulting query set. + returns: A queryset of DomainInformation objects + """ domain_infos = ( DomainInformation.objects.select_related("domain", "authorizing_official") .prefetch_related("domain__permissions") .filter(**filter_condition) .order_by(*sort_fields) + .distinct() ) # Do a mass concat of the first and last name fields for authorizing_official. @@ -45,7 +53,7 @@ def get_domain_infos(filter_condition, sort_fields): return domain_infos_cleaned -def parse_row(columns, domain_info: DomainInformation, security_emails_dict=None, get_domain_managers=False): +def parse_domain_row(columns, domain_info: DomainInformation, security_emails_dict=None, get_domain_managers=False): """Given a set of columns, generate a new row from cleaned column data""" # Domain should never be none when parsing this information @@ -129,7 +137,7 @@ def _get_security_emails(sec_contact_ids): return security_emails_dict -def write_csv( +def write_domains_csv( writer, columns, sort_fields, @@ -138,10 +146,10 @@ def write_csv( should_write_header=True, ): """ - Receives params from the parent methods and outputs a CSV with fltered and sorted domains. - Works with write_header as longas the same writer object is passed. + Receives params from the parent methods and outputs a CSV with filtered and sorted domains. + Works with write_header as long as the same writer object is passed. get_domain_managers: Conditional bc we only use domain manager info for export_data_full_to_csv - should_write_header: Conditional bc export_data_growth_to_csv calls write_body twice + should_write_header: Conditional bc export_data_domain_growth_to_csv calls write_body twice """ all_domain_infos = get_domain_infos(filter_condition, sort_fields) @@ -175,7 +183,7 @@ def write_csv( columns.append(column_name) try: - row = parse_row(columns, domain_info, security_emails_dict, get_domain_managers) + row = parse_domain_row(columns, domain_info, security_emails_dict, get_domain_managers) rows.append(row) except ValueError: # This should not happen. If it does, just skip this row. @@ -189,6 +197,82 @@ def write_csv( writer.writerows(total_body_rows) +def get_requests(filter_condition, sort_fields): + """ + Returns DomainRequest objects filtered and sorted based on the provided conditions. + filter_condition -> A dictionary of conditions to filter the objects. + sort_fields -> A list of fields to sort the resulting query set. + returns: A queryset of DomainRequest objects + """ + requests = DomainRequest.objects.filter(**filter_condition).order_by(*sort_fields).distinct() + return requests + + +def parse_request_row(columns, request: DomainRequest): + """Given a set of columns, generate a new row from cleaned column data""" + + requested_domain_name = "No requested domain" + + if request.requested_domain is not None: + requested_domain_name = request.requested_domain.name + + if request.federal_type: + request_type = f"{request.get_organization_type_display()} - {request.get_federal_type_display()}" + else: + request_type = request.get_organization_type_display() + + # create a dictionary of fields which can be included in output + FIELDS = { + "Requested domain": requested_domain_name, + "Status": request.get_status_display(), + "Organization type": request_type, + "Agency": request.federal_agency, + "Organization name": request.organization_name, + "City": request.city, + "State": request.state_territory, + "AO email": request.authorizing_official.email if request.authorizing_official else " ", + "Security contact email": request, + "Created at": request.created_at, + "Submission date": request.submission_date, + } + + row = [FIELDS.get(column, "") for column in columns] + return row + + +def write_requests_csv( + writer, + columns, + sort_fields, + filter_condition, + should_write_header=True, +): + """Receives params from the parent methods and outputs a CSV with filtered and sorted requests. + Works with write_header as long as the same writer object is passed.""" + + all_requests = get_requests(filter_condition, sort_fields) + + # Reduce the memory overhead when performing the write operation + paginator = Paginator(all_requests, 1000) + + for page_num in paginator.page_range: + page = paginator.page(page_num) + rows = [] + for request in page.object_list: + try: + row = parse_request_row(columns, request) + rows.append(row) + except ValueError: + # This should not happen. If it does, just skip this row. + # It indicates that DomainInformation.domain is None. + logger.error("csv_export -> Error when parsing row, domain was None") + continue + + if should_write_header: + write_header(writer, columns) + writer.writerows(rows) + + def export_data_type_to_csv(csv_file): """All domains report with extra columns""" @@ -223,7 +307,9 @@ def export_data_type_to_csv(csv_file): Domain.State.ON_HOLD, ], } - write_csv(writer, columns, sort_fields, filter_condition, get_domain_managers=True, should_write_header=True) + write_domains_csv( + writer, columns, sort_fields, filter_condition, get_domain_managers=True, should_write_header=True + ) def export_data_full_to_csv(csv_file): @@ -254,7 +340,9 @@ def export_data_full_to_csv(csv_file): Domain.State.ON_HOLD, ], } - write_csv(writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True) + write_domains_csv( + writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True + ) def export_data_federal_to_csv(csv_file): @@ -286,7 +374,9 @@ def export_data_federal_to_csv(csv_file): Domain.State.ON_HOLD, ], } - write_csv(writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True) + write_domains_csv( + writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True + ) def get_default_start_date(): @@ -299,7 +389,15 @@ def get_default_end_date(): return timezone.now() -def export_data_growth_to_csv(csv_file, start_date, end_date): +def format_start_date(start_date): + return timezone.make_aware(datetime.strptime(start_date, "%Y-%m-%d")) if start_date else get_default_start_date() + + +def format_end_date(end_date): + return timezone.make_aware(datetime.strptime(end_date, "%Y-%m-%d")) if end_date else get_default_end_date() + + +def export_data_domain_growth_to_csv(csv_file, start_date, end_date): """ Growth report: Receive start and end dates from the view, parse them. @@ -308,16 +406,9 @@ def export_data_growth_to_csv(csv_file, start_date, end_date): the start and end dates. Specify sort params for both lists. """ - start_date_formatted = ( - timezone.make_aware(datetime.strptime(start_date, "%Y-%m-%d")) if start_date else get_default_start_date() - ) - - end_date_formatted = ( - timezone.make_aware(datetime.strptime(end_date, "%Y-%m-%d")) if end_date else get_default_end_date() - ) - + start_date_formatted = format_start_date(start_date) + end_date_formatted = format_end_date(end_date) writer = csv.writer(csv_file) - # define columns to include in export columns = [ "Domain name", @@ -353,8 +444,10 @@ def export_data_growth_to_csv(csv_file, start_date, end_date): "domain__deleted__gte": start_date_formatted, } - write_csv(writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True) - write_csv( + write_domains_csv( + writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True + ) + write_domains_csv( writer, columns, sort_fields_for_deleted_domains, @@ -362,3 +455,266 @@ def export_data_growth_to_csv(csv_file, start_date, end_date): get_domain_managers=False, should_write_header=False, ) + + +def get_sliced_domains(filter_condition, distinct=False): + """Get filtered domains counts sliced by org type and election office. + Pass distinct=True when filtering by permissions so we do not to count multiples + when a domain has more that one manager. + """ + + # Round trip 1: Get distinct domain names based on filter condition + domains_count = DomainInformation.objects.filter(**filter_condition).distinct().count() + + # Round trip 2: Get counts for other slices + if distinct: + organization_types_query = ( + DomainInformation.objects.filter(**filter_condition).values_list("organization_type", flat=True).distinct() + ) + else: + organization_types_query = DomainInformation.objects.filter(**filter_condition).values_list( + "organization_type", flat=True + ) + organization_type_counts = Counter(organization_types_query) + + federal = organization_type_counts.get(DomainRequest.OrganizationChoices.FEDERAL, 0) + interstate = organization_type_counts.get(DomainRequest.OrganizationChoices.INTERSTATE, 0) + state_or_territory = organization_type_counts.get(DomainRequest.OrganizationChoices.STATE_OR_TERRITORY, 0) + tribal = organization_type_counts.get(DomainRequest.OrganizationChoices.TRIBAL, 0) + county = organization_type_counts.get(DomainRequest.OrganizationChoices.COUNTY, 0) + city = organization_type_counts.get(DomainRequest.OrganizationChoices.CITY, 0) + special_district = organization_type_counts.get(DomainRequest.OrganizationChoices.SPECIAL_DISTRICT, 0) + school_district = organization_type_counts.get(DomainRequest.OrganizationChoices.SCHOOL_DISTRICT, 0) + + # Round trip 3 + election_board = DomainInformation.objects.filter(is_election_board=True, **filter_condition).distinct().count() + + return [ + domains_count, + federal, + interstate, + state_or_territory, + tribal, + county, + city, + special_district, + school_district, + election_board, + ] + + +def get_sliced_requests(filter_condition, distinct=False): + """Get filtered requests counts sliced by org type and election office.""" + + # Round trip 1: Get distinct requests based on filter condition + requests_count = DomainRequest.objects.filter(**filter_condition).distinct().count() + + # Round trip 2: Get counts for other slices + if distinct: + organization_types_query = ( + DomainRequest.objects.filter(**filter_condition).values_list("organization_type", flat=True).distinct() + ) + else: + organization_types_query = DomainRequest.objects.filter(**filter_condition).values_list( + "organization_type", flat=True + ) + organization_type_counts = Counter(organization_types_query) + + federal = organization_type_counts.get(DomainRequest.OrganizationChoices.FEDERAL, 0) + interstate = organization_type_counts.get(DomainRequest.OrganizationChoices.INTERSTATE, 0) + state_or_territory = organization_type_counts.get(DomainRequest.OrganizationChoices.STATE_OR_TERRITORY, 0) + tribal = organization_type_counts.get(DomainRequest.OrganizationChoices.TRIBAL, 0) + county = organization_type_counts.get(DomainRequest.OrganizationChoices.COUNTY, 0) + city = organization_type_counts.get(DomainRequest.OrganizationChoices.CITY, 0) + special_district = organization_type_counts.get(DomainRequest.OrganizationChoices.SPECIAL_DISTRICT, 0) + school_district = organization_type_counts.get(DomainRequest.OrganizationChoices.SCHOOL_DISTRICT, 0) + + # Round trip 3 + election_board = DomainRequest.objects.filter(is_election_board=True, **filter_condition).distinct().count() + + return [ + requests_count, + federal, + interstate, + state_or_territory, + tribal, + county, + city, + special_district, + school_district, + election_board, + ] + + +def export_data_managed_domains_to_csv(csv_file, start_date, end_date): + """Get counts for domains that have domain managers for two different dates, + get list of managed domains at end_date.""" + + start_date_formatted = format_start_date(start_date) + end_date_formatted = format_end_date(end_date) + writer = csv.writer(csv_file) + columns = [ + "Domain name", + "Domain type", + ] + sort_fields = [ + "domain__name", + ] + filter_managed_domains_start_date = { + "domain__permissions__isnull": False, + "domain__first_ready__lte": start_date_formatted, + } + managed_domains_sliced_at_start_date = get_sliced_domains(filter_managed_domains_start_date, True) + + writer.writerow(["MANAGED DOMAINS COUNTS AT START DATE"]) + writer.writerow( + [ + "Total", + "Federal", + "Interstate", + "State or territory", + "Tribal", + "County", + "City", + "Special district", + "School district", + "Election office", + ] + ) + writer.writerow(managed_domains_sliced_at_start_date) + writer.writerow([]) + + filter_managed_domains_end_date = { + "domain__permissions__isnull": False, + "domain__first_ready__lte": end_date_formatted, + } + managed_domains_sliced_at_end_date = get_sliced_domains(filter_managed_domains_end_date, True) + + writer.writerow(["MANAGED DOMAINS COUNTS AT END DATE"]) + writer.writerow( + [ + "Total", + "Federal", + "Interstate", + "State or territory", + "Tribal", + "County", + "City", + "Special district", + "School district", + "Election office", + ] + ) + writer.writerow(managed_domains_sliced_at_end_date) + writer.writerow([]) + + write_domains_csv( + writer, + columns, + sort_fields, + filter_managed_domains_end_date, + get_domain_managers=True, + should_write_header=True, + ) + + +def export_data_unmanaged_domains_to_csv(csv_file, start_date, end_date): + """Get counts for domains that do not have domain managers for two different dates, + get list of unmanaged domains at end_date.""" + + start_date_formatted = format_start_date(start_date) + end_date_formatted = format_end_date(end_date) + writer = csv.writer(csv_file) + columns = [ + "Domain name", + "Domain type", + ] + sort_fields = [ + "domain__name", + ] + + filter_unmanaged_domains_start_date = { + "domain__permissions__isnull": True, + "domain__first_ready__lte": start_date_formatted, + } + unmanaged_domains_sliced_at_start_date = get_sliced_domains(filter_unmanaged_domains_start_date, True) + + writer.writerow(["UNMANAGED DOMAINS AT START DATE"]) + writer.writerow( + [ + "Total", + "Federal", + "Interstate", + "State or territory", + "Tribal", + "County", + "City", + "Special district", + "School district", + "Election office", + ] + ) + writer.writerow(unmanaged_domains_sliced_at_start_date) + writer.writerow([]) + + filter_unmanaged_domains_end_date = { + "domain__permissions__isnull": True, + "domain__first_ready__lte": end_date_formatted, + } + unmanaged_domains_sliced_at_end_date = get_sliced_domains(filter_unmanaged_domains_end_date, True) + + writer.writerow(["UNMANAGED DOMAINS AT END DATE"]) + writer.writerow( + [ + "Total", + "Federal", + "Interstate", + "State or territory", + "Tribal", + "County", + "City", + "Special district", + "School district", + "Election office", + ] + ) + writer.writerow(unmanaged_domains_sliced_at_end_date) + writer.writerow([]) + + write_domains_csv( + writer, + columns, + sort_fields, + filter_unmanaged_domains_end_date, + get_domain_managers=False, + should_write_header=True, + ) + + +def export_data_requests_growth_to_csv(csv_file, start_date, end_date): + """ + Growth report: + Receive start and end dates from the view, parse them. + Request from write_requests_body SUBMITTED requests that are created between + the start and end dates. Specify sort params. + """ + + start_date_formatted = format_start_date(start_date) + end_date_formatted = format_end_date(end_date) + writer = csv.writer(csv_file) + # define columns to include in export + columns = [ + "Requested domain", + "Organization type", + "Submission date", + ] + sort_fields = [ + "requested_domain__name", + ] + filter_condition = { + "status": DomainRequest.DomainRequestStatus.SUBMITTED, + "submission_date__lte": end_date_formatted, + "submission_date__gte": start_date_formatted, + } + + write_requests_csv(writer, columns, sort_fields, filter_condition, should_write_header=True) diff --git a/src/registrar/views/admin_views.py b/src/registrar/views/admin_views.py index f7164663b1..eba8423ed6 100644 --- a/src/registrar/views/admin_views.py +++ b/src/registrar/views/admin_views.py @@ -2,6 +2,12 @@ from django.http import HttpResponse from django.views import View +from django.shortcuts import render +from django.contrib import admin +from django.db.models import Avg, F +from .. import models +import datetime +from django.utils import timezone from registrar.utility import csv_export @@ -10,7 +16,157 @@ logger = logging.getLogger(__name__) -class ExportData(View): +class AnalyticsView(View): + def get(self, request): + thirty_days_ago = datetime.datetime.today() - datetime.timedelta(days=30) + thirty_days_ago = timezone.make_aware(thirty_days_ago) + + last_30_days_applications = models.DomainRequest.objects.filter(created_at__gt=thirty_days_ago) + last_30_days_approved_applications = models.DomainRequest.objects.filter( + created_at__gt=thirty_days_ago, status=models.DomainRequest.DomainRequestStatus.APPROVED + ) + avg_approval_time = last_30_days_approved_applications.annotate( + approval_time=F("approved_domain__created_at") - F("submission_date") + ).aggregate(Avg("approval_time"))["approval_time__avg"] + # Format the timedelta to display only days + if avg_approval_time is not None: + avg_approval_time_display = f"{avg_approval_time.days} days" + else: + avg_approval_time_display = "No approvals to use" + + # The start and end dates are passed as url params + start_date = request.GET.get("start_date", "") + end_date = request.GET.get("end_date", "") + + start_date_formatted = csv_export.format_start_date(start_date) + end_date_formatted = csv_export.format_end_date(end_date) + + filter_managed_domains_start_date = { + "domain__permissions__isnull": False, + "domain__first_ready__lte": start_date_formatted, + } + filter_managed_domains_end_date = { + "domain__permissions__isnull": False, + "domain__first_ready__lte": end_date_formatted, + } + managed_domains_sliced_at_start_date = csv_export.get_sliced_domains(filter_managed_domains_start_date, True) + managed_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_managed_domains_end_date, True) + + filter_unmanaged_domains_start_date = { + "domain__permissions__isnull": True, + "domain__first_ready__lte": start_date_formatted, + } + filter_unmanaged_domains_end_date = { + "domain__permissions__isnull": True, + "domain__first_ready__lte": end_date_formatted, + } + unmanaged_domains_sliced_at_start_date = csv_export.get_sliced_domains( + filter_unmanaged_domains_start_date, True + ) + unmanaged_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_unmanaged_domains_end_date, True) + + filter_ready_domains_start_date = { + "domain__state__in": [models.Domain.State.READY], + "domain__first_ready__lte": start_date_formatted, + } + filter_ready_domains_end_date = { + "domain__state__in": [models.Domain.State.READY], + "domain__first_ready__lte": end_date_formatted, + } + ready_domains_sliced_at_start_date = csv_export.get_sliced_domains(filter_ready_domains_start_date) + ready_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_ready_domains_end_date) + + filter_deleted_domains_start_date = { + "domain__state__in": [models.Domain.State.DELETED], + "domain__deleted__lte": start_date_formatted, + } + filter_deleted_domains_end_date = { + "domain__state__in": [models.Domain.State.DELETED], + "domain__deleted__lte": end_date_formatted, + } + deleted_domains_sliced_at_start_date = csv_export.get_sliced_domains(filter_deleted_domains_start_date) + deleted_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_deleted_domains_end_date) + + filter_requests_start_date = { + "created_at__lte": start_date_formatted, + } + filter_requests_end_date = { + "created_at__lte": end_date_formatted, + } + requests_sliced_at_start_date = csv_export.get_sliced_requests(filter_requests_start_date) + requests_sliced_at_end_date = csv_export.get_sliced_requests(filter_requests_end_date) + + filter_submitted_requests_start_date = { + "status": models.DomainRequest.DomainRequestStatus.SUBMITTED, + "submission_date__lte": start_date_formatted, + } + filter_submitted_requests_end_date = { + "status": models.DomainRequest.DomainRequestStatus.SUBMITTED, + "submission_date__lte": end_date_formatted, + } + submitted_requests_sliced_at_start_date = csv_export.get_sliced_requests(filter_submitted_requests_start_date) + submitted_requests_sliced_at_end_date = csv_export.get_sliced_requests(filter_submitted_requests_end_date) + + context = dict( + # Generate a dictionary of context variables that are common across all admin templates + # (site_header, site_url, ...), + # include it in the larger context dictionary so it's available in the template rendering context. + # This ensures that the admin interface styling and behavior are consistent with other admin pages. + **admin.site.each_context(request), + data=dict( + user_count=models.User.objects.all().count(), + domain_count=models.Domain.objects.all().count(), + ready_domain_count=models.Domain.objects.filter(state=models.Domain.State.READY).count(), + last_30_days_applications=last_30_days_applications.count(), + last_30_days_approved_applications=last_30_days_approved_applications.count(), + average_application_approval_time_last_30_days=avg_approval_time_display, + managed_domains_sliced_at_start_date=managed_domains_sliced_at_start_date, + unmanaged_domains_sliced_at_start_date=unmanaged_domains_sliced_at_start_date, + managed_domains_sliced_at_end_date=managed_domains_sliced_at_end_date, + unmanaged_domains_sliced_at_end_date=unmanaged_domains_sliced_at_end_date, + ready_domains_sliced_at_start_date=ready_domains_sliced_at_start_date, + deleted_domains_sliced_at_start_date=deleted_domains_sliced_at_start_date, + ready_domains_sliced_at_end_date=ready_domains_sliced_at_end_date, + deleted_domains_sliced_at_end_date=deleted_domains_sliced_at_end_date, + requests_sliced_at_start_date=requests_sliced_at_start_date, + submitted_requests_sliced_at_start_date=submitted_requests_sliced_at_start_date, + requests_sliced_at_end_date=requests_sliced_at_end_date, + submitted_requests_sliced_at_end_date=submitted_requests_sliced_at_end_date, + start_date=start_date, + end_date=end_date, + ), + ) + return render(request, "admin/analytics.html", context) + + +class ExportDataType(View): + 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="domains-by-type.csv"' + csv_export.export_data_type_to_csv(response) + return response + + +class ExportDataFull(View): + def get(self, request, *args, **kwargs): + # Smaller export based on 1 + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="current-full.csv"' + csv_export.export_data_full_to_csv(response) + return response + + +class ExportDataFederal(View): + def get(self, request, *args, **kwargs): + # Federal only + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="current-federal.csv"' + csv_export.export_data_federal_to_csv(response) + return response + + +class ExportDataDomainsGrowth(View): def get(self, request, *args, **kwargs): # Get start_date and end_date from the request's GET parameters # #999: not needed if we switch to django forms @@ -19,8 +175,50 @@ def get(self, request, *args, **kwargs): response = HttpResponse(content_type="text/csv") response["Content-Disposition"] = f'attachment; filename="domain-growth-report-{start_date}-to-{end_date}.csv"' - # For #999: set export_data_growth_to_csv to return the resulting queryset, which we can then use + # For #999: set export_data_domain_growth_to_csv to return the resulting queryset, which we can then use # in context to display this data in the template. - csv_export.export_data_growth_to_csv(response, start_date, end_date) + csv_export.export_data_domain_growth_to_csv(response, start_date, end_date) + + return response + + +class ExportDataRequestsGrowth(View): + def get(self, request, *args, **kwargs): + # Get start_date and end_date from the request's GET parameters + # #999: not needed if we switch to django forms + start_date = request.GET.get("start_date", "") + end_date = request.GET.get("end_date", "") + + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = f'attachment; filename="requests-{start_date}-to-{end_date}.csv"' + # For #999: set export_data_domain_growth_to_csv to return the resulting queryset, which we can then use + # in context to display this data in the template. + csv_export.export_data_requests_growth_to_csv(response, start_date, end_date) + + return response + + +class ExportDataManagedDomains(View): + def get(self, request, *args, **kwargs): + # Get start_date and end_date from the request's GET parameters + # #999: not needed if we switch to django forms + start_date = request.GET.get("start_date", "") + 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.export_data_managed_domains_to_csv(response, start_date, end_date) + + return response + + +class ExportDataUnmanagedDomains(View): + def get(self, request, *args, **kwargs): + # Get start_date and end_date from the request's GET parameters + # #999: not needed if we switch to django forms + start_date = request.GET.get("start_date", "") + end_date = request.GET.get("end_date", "") + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = f'attachment; filename="unamanaged-domains-{start_date}-to-{end_date}.csv"' + csv_export.export_data_unmanaged_domains_to_csv(response, start_date, end_date) return response