diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 0cf419056..574362f2a 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1,13 +1,12 @@ from datetime import date import logging -import datetime import copy from django import forms -from django.db.models import Avg, F, Value, CharField, Q +from django.db.models import Value, CharField, Q from django.db.models.functions import Concat, Coalesce from django.http import HttpResponseRedirect -from django.shortcuts import redirect, render +from django.shortcuts import redirect from django_fsm import get_available_FIELD_transitions from django.contrib import admin, messages from django.contrib.auth.admin import UserAdmin as BaseUserAdmin @@ -17,7 +16,6 @@ from django.urls import reverse 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 @@ -437,123 +435,6 @@ class UserContactInline(admin.StackedInline): model = models.Contact -def analytics(request): - """View for the reports page.""" - - thirty_days_ago = datetime.datetime.today() - datetime.timedelta(days=30) - - 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=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 = f"{avg_approval_time.days} days" - else: - avg_approval_time = "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) - managed_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_managed_domains_end_date) - - 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) - unmanaged_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_unmanaged_domains_end_date) - - filter_ready_domains_start_date = { - "domain__state__in": [Domain.State.READY], - "domain__first_ready__lte": start_date_formatted, - } - filter_ready_domains_end_date = { - "domain__state__in": [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": [Domain.State.DELETED], - "domain__deleted__lte": start_date_formatted, - } - filter_deleted_domains_end_date = { - "domain__state__in": [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": DomainRequest.DomainRequestStatus.SUBMITTED, - "submission_date__lte": start_date_formatted, - } - filter_submitted_requests_end_date = { - "status": 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( - **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.all().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, - 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 MyUserAdmin(BaseUserAdmin): """Custom user admin class to use our inlines.""" diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index b67c9356b..646b7298f 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -330,7 +330,8 @@ CSP_FORM_ACTION = allowed_sources # 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 +# 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 38e490db8..663c6914c 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -9,7 +9,6 @@ from django.urls import include, path from django.views.generic import RedirectView from registrar import views -from registrar.admin import analytics from registrar.views.admin_views import ( ExportDataDomainsGrowth, ExportDataFederal, @@ -18,6 +17,7 @@ from registrar.views.admin_views import ( ExportDataRequestsGrowth, ExportDataType, ExportDataUnmanagedDomains, + AnalyticsView, ) from registrar.views.domain_request import Step @@ -96,7 +96,7 @@ urlpatterns = [ ), path( "admin/analytics/", - admin.site.admin_view(analytics), + AnalyticsView.as_view(), name="analytics", ), path("admin/", admin.site.urls), diff --git a/src/registrar/templates/admin/analytics.html b/src/registrar/templates/admin/analytics.html index da7f25c66..2c5963e75 100644 --- a/src/registrar/templates/admin/analytics.html +++ b/src/registrar/templates/admin/analytics.html @@ -46,7 +46,7 @@ Current Federal + Current federal @@ -87,7 +87,7 @@
  • @@ -132,7 +132,7 @@ data-list-one="{{data.unmanaged_domains_sliced_at_start_date}}" data-list-two="{{data.unmanaged_domains_sliced_at_end_date}}" > -

    Chart: Unanaged domains

    +

    Chart: Unmanaged domains

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

    diff --git a/src/registrar/templates/admin/app_list.html b/src/registrar/templates/admin/app_list.html index dd7e27f33..49fb59e79 100644 --- a/src/registrar/templates/admin/app_list.html +++ b/src/registrar/templates/admin/app_list.html @@ -64,12 +64,11 @@ {% endfor %} +
    +

    Analytics

    + Dashboard +
    {% else %}

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

    {% endif %} - -
    -

    Analytics

    - Dashboard -
    diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index f1c7841d1..48b42f47c 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -475,7 +475,7 @@ class AuditedAdminMockData: class MockDb(TestCase): - """Hardcoded mocks make test case assertions sraightforward.""" + """Hardcoded mocks make test case assertions straightforward.""" def setUp(self): super().setUp() @@ -622,19 +622,19 @@ class MockDb(TestCase): ) with less_console_noise(): - self.domain_request_1 = completed_application( + self.domain_request_1 = completed_domain_request( status=DomainRequest.DomainRequestStatus.STARTED, name="city1.gov" ) - self.domain_request_2 = completed_application( + self.domain_request_2 = completed_domain_request( status=DomainRequest.DomainRequestStatus.IN_REVIEW, name="city2.gov" ) - self.domain_request_3 = completed_application( + self.domain_request_3 = completed_domain_request( status=DomainRequest.DomainRequestStatus.STARTED, name="city3.gov" ) - self.domain_request_4 = completed_application( + self.domain_request_4 = completed_domain_request( status=DomainRequest.DomainRequestStatus.STARTED, name="city4.gov" ) - self.domain_request_5 = completed_application( + self.domain_request_5 = completed_domain_request( status=DomainRequest.DomainRequestStatus.APPROVED, name="city5.gov" ) self.domain_request_3.submit() diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index 475076711..b91f3bd18 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -5,6 +5,8 @@ from io import StringIO from registrar.models.domain_request import DomainRequest from registrar.models.domain import Domain from registrar.utility.csv_export import ( + export_data_managed_domains_to_csv, + export_data_unmanaged_domains_to_csv, get_sliced_domains, get_sliced_requests, write_domains_csv, @@ -530,68 +532,10 @@ class ExportDataTest(MockDb, MockEppLib): 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", - "Domain type", - ] - sort_fields = [ - "domain__name", - ] - filter_managed_domains_start_date = { - "domain__permissions__isnull": False, - "domain__first_ready__lte": self.start_date, - } - managed_domains_sliced_at_start_date = get_sliced_domains(filter_managed_domains_start_date) - # Call the export functions - 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": self.end_date, - } - managed_domains_sliced_at_end_date = get_sliced_domains(filter_managed_domains_end_date) - 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, + 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 @@ -627,68 +571,10 @@ class ExportDataTest(MockDb, MockEppLib): 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", - "Domain type", - ] - sort_fields = [ - "domain__name", - ] - filter_unmanaged_domains_start_date = { - "domain__permissions__isnull": True, - "domain__first_ready__lte": self.start_date, - } - unmanaged_domains_sliced_at_start_date = get_sliced_domains(filter_unmanaged_domains_start_date) - # Call the export functions - writer.writerow(["UNMANAGED DOMAINS COUNTS 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": self.end_date, - } - unmanaged_domains_sliced_at_end_date = get_sliced_domains(filter_unmanaged_domains_end_date) - writer.writerow(["UNMANAGED DOMAINS COUNTS 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, + 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 @@ -696,12 +582,12 @@ class ExportDataTest(MockDb, MockEppLib): self.maxDiff = None # We expect the READY domain names with the domain managers: Their counts, and listing at end_date. expected_content = ( - "UNMANAGED DOMAINS COUNTS AT START DATE\n" + "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 COUNTS AT END DATE\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" @@ -729,16 +615,17 @@ class ExportDataTest(MockDb, MockEppLib): 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", - "Submission date", ] sort_fields = [ "requested_domain__name", ] filter_condition = { - "status": DomainRequest.RequestStatus.SUBMITTED, + "status": DomainRequest.DomainRequestStatus.SUBMITTED, "submission_date__lte": self.end_date, "submission_date__gte": self.start_date, } @@ -750,9 +637,9 @@ class ExportDataTest(MockDb, MockEppLib): # 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,Submission date\n" - "city3.gov,Federal - Executive,2024-03-05\n" - "city4.gov,Federal - Executive,2024-03-05\n" + "Requested domain,Organization type\n" + "city3.gov,Federal - Executive\n" + "city4.gov,Federal - Executive\n" ) # Normalize line endings and remove commas, @@ -785,16 +672,22 @@ class HelperFunctions(MockDb): "domain__permissions__isnull": False, "domain__first_ready__lte": self.end_date, } - managed_domains_sliced_at_end_date = get_sliced_domains(filter_condition) + # 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.RequestStatus.SUBMITTED, + "status": DomainRequest.DomainRequestStatus.SUBMITTED, "submission_date__lte": self.end_date, } submitted_requests_sliced_at_end_date = get_sliced_requests(filter_condition) diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 060c39804..e8746eafb 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -1,3 +1,4 @@ +from collections import Counter import csv import logging from datetime import datetime @@ -25,7 +26,8 @@ def write_header(writer, columns): def get_domain_infos(filter_condition, sort_fields): domain_infos = ( - DomainInformation.objects.prefetch_related("domain", "authorizing_official", "domain__permissions") + DomainInformation.objects.select_related("domain", "authorizing_official") + .prefetch_related("domain__permissions") .filter(**filter_condition) .order_by(*sort_fields) .distinct() @@ -190,7 +192,7 @@ def write_domains_csv( def get_requests(filter_condition, sort_fields): - requests = DomainRequest.objects.all().filter(**filter_condition).order_by(*sort_fields).distinct() + requests = DomainRequest.objects.filter(**filter_condition).order_by(*sort_fields).distinct() return requests @@ -236,10 +238,10 @@ def write_requests_csv( """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_requetsts = get_requests(filter_condition, sort_fields) + all_requests = get_requests(filter_condition, sort_fields) # Reduce the memory overhead when performing the write operation - paginator = Paginator(all_requetsts, 1000) + paginator = Paginator(all_requests, 1000) for page_num in paginator.page_range: page = paginator.page(page_num) @@ -443,26 +445,37 @@ def export_data_domain_growth_to_csv(csv_file, start_date, end_date): ) -def get_sliced_domains(filter_condition): - """Get fitered domains counts sliced by org type and election office.""" +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. + """ - domains = DomainInformation.objects.all().filter(**filter_condition).distinct() - domains_count = domains.count() - federal = domains.filter(organization_type=DomainRequest.OrganizationChoices.FEDERAL).distinct().count() - interstate = domains.filter(organization_type=DomainRequest.OrganizationChoices.INTERSTATE).count() - state_or_territory = ( - domains.filter(organization_type=DomainRequest.OrganizationChoices.STATE_OR_TERRITORY).distinct().count() - ) - tribal = domains.filter(organization_type=DomainRequest.OrganizationChoices.TRIBAL).distinct().count() - county = domains.filter(organization_type=DomainRequest.OrganizationChoices.COUNTY).distinct().count() - city = domains.filter(organization_type=DomainRequest.OrganizationChoices.CITY).distinct().count() - special_district = ( - domains.filter(organization_type=DomainRequest.OrganizationChoices.SPECIAL_DISTRICT).distinct().count() - ) - school_district = ( - domains.filter(organization_type=DomainRequest.OrganizationChoices.SCHOOL_DISTRICT).distinct().count() - ) - election_board = domains.filter(is_election_board=True).distinct().count() + # 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, @@ -478,26 +491,34 @@ def get_sliced_domains(filter_condition): ] -def get_sliced_requests(filter_condition): - """Get fitered requests counts sliced by org type and election office.""" +def get_sliced_requests(filter_condition, distinct=False): + """Get filtered requests counts sliced by org type and election office.""" - requests = DomainRequest.objects.all().filter(**filter_condition).distinct() - requests_count = requests.count() - federal = requests.filter(organization_type=DomainRequest.OrganizationChoices.FEDERAL).distinct().count() - interstate = requests.filter(organization_type=DomainRequest.OrganizationChoices.INTERSTATE).distinct().count() - state_or_territory = ( - requests.filter(organization_type=DomainRequest.OrganizationChoices.STATE_OR_TERRITORY).distinct().count() - ) - tribal = requests.filter(organization_type=DomainRequest.OrganizationChoices.TRIBAL).distinct().count() - county = requests.filter(organization_type=DomainRequest.OrganizationChoices.COUNTY).distinct().count() - city = requests.filter(organization_type=DomainRequest.OrganizationChoices.CITY).distinct().count() - special_district = ( - requests.filter(organization_type=DomainRequest.OrganizationChoices.SPECIAL_DISTRICT).distinct().count() - ) - school_district = ( - requests.filter(organization_type=DomainRequest.OrganizationChoices.SCHOOL_DISTRICT).distinct().count() - ) - election_board = requests.filter(is_election_board=True).distinct().count() + # 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, @@ -531,7 +552,7 @@ def export_data_managed_domains_to_csv(csv_file, start_date, end_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) + 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( @@ -555,7 +576,7 @@ def export_data_managed_domains_to_csv(csv_file, start_date, 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) + 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( @@ -604,7 +625,7 @@ def export_data_unmanaged_domains_to_csv(csv_file, start_date, end_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) + unmanaged_domains_sliced_at_start_date = get_sliced_domains(filter_unmanaged_domains_start_date, True) writer.writerow(["UNMANAGED DOMAINS AT START DATE"]) writer.writerow( @@ -628,7 +649,7 @@ def export_data_unmanaged_domains_to_csv(csv_file, start_date, 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) + unmanaged_domains_sliced_at_end_date = get_sliced_domains(filter_unmanaged_domains_end_date, True) writer.writerow(["UNMANAGED DOMAINS AT END DATE"]) writer.writerow( diff --git a/src/registrar/views/admin_views.py b/src/registrar/views/admin_views.py index 04fcaa6f2..eba8423ed 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,6 +16,129 @@ import logging logger = logging.getLogger(__name__) +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