Merge branch 'main' into za/1848-copy-contact-email-to-clipboard

This commit is contained in:
zandercymatics 2024-03-20 14:36:55 -06:00
commit 76b05d9540
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
23 changed files with 1414 additions and 575 deletions

View file

@ -1,41 +0,0 @@
# This workflow is to for testing a change to our deploy structure and will be deleted when testing finishes
name: Deploy Main
run-name: Run deploy for ${{ github.event.inputs.environment }}
on:
workflow_dispatch:
inputs:
environment:
type: choice
description: Which environment should we run deploy for?
options:
- development
- backup
- ky
- es
- nl
- rh
- za
- gd
- rb
- ko
- ab
- rjm
- dk
jobs:
deploy:
runs-on: ubuntu-latest
env:
CF_USERNAME: CF_${{ github.event.inputs.environment }}_USERNAME
CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
steps:
- name: Deploy to cloud.gov sandbox
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov
cf_space: ${{ github.event.inputs.environment }}
cf_command: "push -f ops/manifests/manifest-${{ github.event.inputs.environment }}.yaml --strategy rolling"

View file

@ -4,7 +4,7 @@ applications:
buildpacks: buildpacks:
- python_buildpack - python_buildpack
path: ../../src path: ../../src
instances: 2 instances: 1
memory: 512M memory: 512M
stack: cflinuxfs4 stack: cflinuxfs4
timeout: 180 timeout: 180

View file

@ -3,9 +3,9 @@ import logging
import copy import copy
from django import forms from django import forms
from django.db.models.functions import Concat, Coalesce
from django.db.models import Value, CharField, Q 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.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions from django_fsm import get_available_FIELD_transitions
from django.contrib import admin, messages from django.contrib import admin, messages
@ -16,7 +16,6 @@ from django.urls import reverse
from dateutil.relativedelta import relativedelta # type: ignore from dateutil.relativedelta import relativedelta # type: ignore
from epplibwrapper.errors import ErrorCode, RegistryError from epplibwrapper.errors import ErrorCode, RegistryError
from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website 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.utility.errors import FSMApplicationError, FSMErrorCodes
from registrar.views.utility.mixins import OrderableFieldsMixin from registrar.views.utility.mixins import OrderableFieldsMixin
from django.contrib.admin.views.main import ORDER_VAR from django.contrib.admin.views.main import ORDER_VAR
@ -1486,7 +1485,6 @@ class DomainAdmin(ListHeaderAdmin):
search_fields = ["name"] search_fields = ["name"]
search_help_text = "Search by domain name." search_help_text = "Search by domain name."
change_form_template = "django/admin/domain_change_form.html" 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"] readonly_fields = ["state", "expiration_date", "first_ready", "deleted"]
# Table ordering # Table ordering
@ -1533,56 +1531,6 @@ class DomainAdmin(ListHeaderAdmin):
return super().changeform_view(request, object_id, form_url, extra_context) 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): def response_change(self, request, obj):
# Create dictionary of action functions # Create dictionary of action functions
ACTION_FUNCTIONS = { ACTION_FUNCTIONS = {
@ -1714,9 +1662,11 @@ class DomainAdmin(ListHeaderAdmin):
else: else:
self.message_user( self.message_user(
request, request,
"Error deleting this Domain: " (
f"Can't switch from state '{obj.state}' to 'deleted'" "Error deleting this Domain: "
", must be either 'dns_needed' or 'on_hold'", f"Can't switch from state '{obj.state}' to 'deleted'"
", must be either 'dns_needed' or 'on_hold'"
),
messages.ERROR, messages.ERROR,
) )
except Exception: except Exception:
@ -1728,7 +1678,7 @@ class DomainAdmin(ListHeaderAdmin):
else: else:
self.message_user( self.message_user(
request, request,
("Domain %s has been deleted. Thanks!") % obj.name, "Domain %s has been deleted. Thanks!" % obj.name,
) )
return HttpResponseRedirect(".") return HttpResponseRedirect(".")
@ -1770,7 +1720,7 @@ class DomainAdmin(ListHeaderAdmin):
else: else:
self.message_user( self.message_user(
request, 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(".") return HttpResponseRedirect(".")
@ -1799,7 +1749,7 @@ class DomainAdmin(ListHeaderAdmin):
else: else:
self.message_user( self.message_user(
request, 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(".") return HttpResponseRedirect(".")

View file

@ -427,43 +427,6 @@ function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk,
viewLink.setAttribute('title', viewLink.getAttribute('title-template').replace('selected item', elementText)); 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 /** An IIFE for admin in DjangoAdmin to listen to changes on the domain request
* status select amd to show/hide the rejection reason * status select amd to show/hide the rejection reason
*/ */

View file

@ -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,
});
}

View file

@ -112,7 +112,8 @@ html[data-theme="light"] {
.change-list .usa-table thead th, .change-list .usa-table thead th,
body.dashboard, body.dashboard,
body.change-list, body.change-list,
body.change-form { body.change-form,
.analytics {
color: var(--body-fg); 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; list-style-type: inherit;
// Styling based off of the <p> styling in django admin // Styling based off of the <p> styling in django admin
line-height: 1.5; line-height: 1.5;

View file

@ -330,8 +330,9 @@ CSP_FORM_ACTION = allowed_sources
# Google analytics requires that we relax our otherwise # Google analytics requires that we relax our otherwise
# strict CSP by allowing scripts to run from their domain # 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.
CSP_SCRIPT_SRC_ELEM = ["'self'", "https://www.googletagmanager.com/"] # 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_CONNECT_SRC = ["'self'", "https://www.google-analytics.com/"]
CSP_INCLUDE_NONCE_IN = ["script-src-elem"] CSP_INCLUDE_NONCE_IN = ["script-src-elem"]

View file

@ -9,9 +9,16 @@ from django.urls import include, path
from django.views.generic import RedirectView from django.views.generic import RedirectView
from registrar import views from registrar import views
from registrar.views.admin_views import (
from registrar.views.admin_views import ExportData ExportDataDomainsGrowth,
ExportDataFederal,
ExportDataFull,
ExportDataManagedDomains,
ExportDataRequestsGrowth,
ExportDataType,
ExportDataUnmanagedDomains,
AnalyticsView,
)
from registrar.views.domain_request import Step from registrar.views.domain_request import Step
from registrar.views.utility import always_404 from registrar.views.utility import always_404
@ -52,7 +59,46 @@ urlpatterns = [
"admin/logout/", "admin/logout/",
RedirectView.as_view(pattern_name="logout", permanent=False), 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("admin/", admin.site.urls),
path( path(
"domain-request/<id>/edit/", "domain-request/<id>/edit/",

View file

@ -1,20 +0,0 @@
<svg width="404" height="409" viewBox="0 0 404 409" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M291.707 328.743C240.024 358.583 133.444 374.87 78.8899 280.379C14.3648 168.618 78.2559 99.3488 140.956 63.1491C203.655 26.9495 296.801 80.4848 337.226 150.503C377.652 220.522 343.391 298.903 291.707 328.743Z" fill="#F5F8FA"/>
<circle cx="276.88" cy="130.594" r="8" transform="rotate(135 276.88 130.594)" fill="#7AA5C1"/>
<circle cx="288.196" cy="119.279" r="8" transform="rotate(135 288.196 119.279)" fill="#7AA5C1"/>
<circle cx="231.626" cy="175.849" r="8" transform="rotate(135 231.626 175.849)" fill="#7AA5C1"/>
<circle cx="186.371" cy="221.104" r="8" transform="rotate(135 186.371 221.104)" fill="#7AA5C1"/>
<circle cx="242.939" cy="164.535" r="8" transform="rotate(135 242.939 164.535)" fill="#7AA5C1"/>
<circle cx="197.686" cy="209.788" r="8" transform="rotate(135 197.686 209.788)" fill="#7AA5C1"/>
<circle cx="220.312" cy="187.163" r="8" transform="rotate(135 220.312 187.163)" fill="#7AA5C1"/>
<circle cx="175.057" cy="232.417" r="8" transform="rotate(135 175.057 232.417)" fill="#7AA5C1"/>
<circle cx="163.743" cy="243.731" r="8" transform="rotate(135 163.743 243.731)" fill="#7AA5C1"/>
<circle cx="152.43" cy="255.045" r="8" transform="rotate(135 152.43 255.045)" fill="#7AA5C1"/>
<circle cx="141.116" cy="266.358" r="8" transform="rotate(135 141.116 266.358)" fill="#7AA5C1"/>
<circle cx="129.802" cy="277.672" r="8" transform="rotate(135 129.802 277.672)" fill="#7AA5C1"/>
<circle cx="118.489" cy="288.986" r="8" transform="rotate(135 118.489 288.986)" fill="#7AA5C1"/>
<circle cx="254.253" cy="153.221" r="8" transform="rotate(135 254.253 153.221)" fill="#7AA5C1"/>
<circle cx="265.566" cy="141.908" r="8" transform="rotate(135 265.566 141.908)" fill="#7AA5C1"/>
<circle cx="208.998" cy="198.476" r="8" transform="rotate(135 208.998 198.476)" fill="#7AA5C1"/>
<circle cx="203.342" cy="203.999" r="120.001" stroke="#7AA5C1" stroke-width="16"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 53 KiB

View file

@ -1,59 +0,0 @@
<svg width="409" height="214" viewBox="0 0 409 214" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M366.004 90.4612C372.017 135.603 322.608 199.102 196.168 205.902C-32.9139 218.22 19.0655 18.8457 205.511 8.81994C299.204 3.78172 359.99 45.3195 366.004 90.4612Z" fill="#F5F8FA"/>
<circle cx="213.873" cy="37.4943" r="6.56803" fill="#7AA5C1"/>
<circle cx="212.214" cy="58.4272" r="6.56803" fill="#7AA5C1"/>
<circle cx="231.089" cy="66.2297" r="6.56803" fill="#7AA5C1"/>
<circle cx="235.451" cy="85.568" r="6.56803" fill="#7AA5C1"/>
<circle cx="252.535" cy="99.7787" r="6.56803" fill="#7AA5C1"/>
<circle cx="273.981" cy="115.121" r="6.56803" fill="#7AA5C1"/>
<circle cx="372.072" cy="182.568" r="6.56803" fill="#7AA5C1"/>
<circle cx="402.423" cy="189.642" r="6.56803" fill="#7AA5C1"/>
<circle cx="220.441" cy="99.6379" r="6.56803" fill="#7AA5C1"/>
<circle cx="231.089" cy="118.336" r="6.56803" fill="#7AA5C1"/>
<circle cx="262.722" cy="143.568" r="6.56803" fill="#7AA5C1"/>
<circle cx="248.722" cy="125.568" r="6.56803" fill="#7AA5C1"/>
<circle cx="288.537" cy="138.04" r="6.56803" fill="#7AA5C1"/>
<circle cx="300.051" cy="160.304" r="6.56803" fill="#7AA5C1"/>
<circle cx="338.722" cy="170.568" r="6.56803" fill="#7AA5C1"/>
<circle cx="349.914" cy="189.575" r="6.56803" fill="#7AA5C1"/>
<circle cx="330.21" cy="189.575" r="6.56803" fill="#7AA5C1"/>
<circle cx="316.722" cy="173.568" r="6.56803" fill="#7AA5C1"/>
<circle cx="305.722" cy="190.568" r="6.56803" fill="#7AA5C1"/>
<circle cx="287.722" cy="184.568" r="6.56803" fill="#7AA5C1"/>
<circle cx="269.894" cy="183.007" r="6.56803" fill="#7AA5C1"/>
<circle cx="250.19" cy="189.575" r="6.56803" fill="#7AA5C1"/>
<circle cx="220.722" cy="192.568" r="6.56803" fill="#7AA5C1"/>
<circle cx="233.722" cy="173.568" r="6.56803" fill="#7AA5C1"/>
<circle cx="252.722" cy="165.568" r="6.56803" fill="#7AA5C1"/>
<circle cx="180.203" cy="189.575" r="6.56803" fill="#7AA5C1"/>
<circle cx="161.329" cy="196.143" r="6.56803" fill="#7AA5C1"/>
<circle cx="140.795" cy="189.575" r="6.56803" fill="#7AA5C1"/>
<path d="M122.733 189.575C122.733 193.203 119.793 196.143 116.165 196.143C112.538 196.143 109.597 193.203 109.597 189.575C109.597 185.948 112.538 183.007 116.165 183.007C119.793 183.007 122.733 185.948 122.733 189.575Z" fill="#7AA5C1"/>
<circle cx="91.5343" cy="189.642" r="6.56803" fill="#7AA5C1"/>
<circle cx="57.8852" cy="185.432" r="6.56803" fill="#7AA5C1"/>
<circle cx="198.722" cy="195.568" r="6.56803" fill="#7AA5C1"/>
<circle cx="193.34" cy="70.2917" r="6.56803" fill="#7AA5C1"/>
<circle cx="98.9323" cy="156.735" r="6.56803" fill="#7AA5C1"/>
<circle cx="25.247" cy="189.642" r="6.56803" fill="#7AA5C1"/>
<circle cx="7.26164" cy="190.584" r="6.56803" fill="#7AA5C1"/>
<circle cx="76.7592" cy="173.44" r="6.56803" fill="#7AA5C1"/>
<circle cx="129.722" cy="172.568" r="6.56803" fill="#7AA5C1"/>
<circle cx="147.722" cy="164.568" r="6.56803" fill="#7AA5C1"/>
<circle cx="164.722" cy="173.568" r="6.56803" fill="#7AA5C1"/>
<circle cx="174.465" cy="150.167" r="6.56803" fill="#7AA5C1"/>
<circle cx="186.771" cy="169.871" r="6.56803" fill="#7AA5C1"/>
<circle cx="198.266" cy="150.167" r="6.56803" fill="#7AA5C1"/>
<circle cx="232.747" cy="151.176" r="6.56803" fill="#7AA5C1"/>
<circle cx="273.722" cy="162.568" r="6.56803" fill="#7AA5C1"/>
<circle cx="210.589" cy="163.303" r="6.56803" fill="#7AA5C1"/>
<circle cx="204.834" cy="124.904" r="6.56803" fill="#7AA5C1"/>
<circle cx="193.34" cy="108.553" r="6.56803" fill="#7AA5C1"/>
<circle cx="171.181" cy="119.345" r="6.56803" fill="#7AA5C1"/>
<circle cx="160.499" cy="138.04" r="6.56803" fill="#7AA5C1"/>
<circle cx="139.153" cy="144.608" r="6.56803" fill="#7AA5C1"/>
<circle cx="170.351" cy="95.4167" r="6.56803" fill="#7AA5C1"/>
<circle cx="151.477" cy="115.121" r="6.56803" fill="#7AA5C1"/>
<circle cx="125.204" cy="137.031" r="6.56803" fill="#7AA5C1"/>
<circle cx="112.881" cy="163.303" r="6.56803" fill="#7AA5C1"/>
<circle cx="204.834" cy="86.6427" r="6.56803" fill="#7AA5C1"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -0,0 +1,196 @@
{% extends "admin/base_site.html" %}
{% load static %}
{% block content_title %}<h1>Registrar Analytics</h1>{% endblock %}
{% block content %}
<div id="content-main" class="analytics">
<div class="grid-row grid-gap-2">
<div class="tablet:grid-col-6 margin-top-2">
<div class="module height-full">
<h2>At a glance</h2>
<div class="padding-top-2 padding-x-2">
<ul>
<li>User Count: {{ data.user_count }}</li>
<li>Domain Count: {{ data.domain_count }}</li>
<li>Domains in READY state: {{ data.ready_domain_count }}</li>
<li>Domain applications (last 30 days): {{ data.last_30_days_applications }}</li>
<li>Approved applications (last 30 days): {{ data.last_30_days_approved_applications }}</li>
<li>Average approval time for applications (last 30 days): {{ data.average_application_approval_time_last_30_days }}</li>
</ul>
</div>
</div>
</div>
<div class="tablet:grid-col-6 margin-top-2">
<div class="module height-full">
<h2>Current domains</h2>
<div class="padding-top-2 padding-x-2">
<ul class="usa-button-group">
<li class="usa-button-group__item">
<a href="{% url 'export_data_type' %}" class="button" role="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg><span class="margin-left-05">All domain metadata</span>
</a>
</li>
<li class="usa-button-group__item">
<a href="{% url 'export_data_full' %}" class="button" role="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg><span class="margin-left-05">Current full</span>
</a>
</li>
<li class="usa-button-group__item">
<a href="{% url 'export_data_federal' %}" class="button" role="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg><span class="margin-left-05">Current federal</span>
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="grid-row grid-gap-2 margin-top-2">
<div class="grid-col">
<div class="module">
<h2>Growth reports</h2>
<div class="padding-2">
{% 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 %}
<div class="display-flex flex-align-baseline margin-top-1 margin-bottom-2">
<div class="margin-right-1">
<label for="start">Start date:</label>
<input type="date" id="start" name="start" value="2018-07-22" min="2018-01-01" />
</div>
<div>
<label for="end">End date:</label>
<input type="date" id="end" name="end" value="2023-12-01" min="2023-12-01" />
</div>
</div>
<ul class="usa-button-group">
<li class="usa-button-group__item">
<button class="button exportLink" data-export-url="{% url 'export_domains_growth' %}" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg><span class="margin-left-05">Domain growth</span>
</button>
</li>
<li class="usa-button-group__item">
<button class="button exportLink" data-export-url="{% url 'export_requests_growth' %}" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg><span class="margin-left-05">Request growth</span>
</button>
</li>
<li class="usa-button-group__item">
<button class="button exportLink" data-export-url="{% url 'export_managed_domains' %}" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg><span class="margin-left-05">Managed domains</span>
</button>
</li>
<li class="usa-button-group__item">
<button class="button exportLink" data-export-url="{% url 'export_unmanaged_domains' %}" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg><span class="margin-left-05">Unmanaged domains</span>
</button>
</li>
<li class="usa-button-group__item">
<button class="button exportLink usa-button--secondary" data-export-url="{% url 'analytics' %}" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#assessment"></use>
</svg><span class="margin-left-05">Update charts</span>
</button>
</li>
</ul>
<div class="grid-row grid-gap-2 margin-y-2">
<div class="grid-col">
<canvas id="myChart1" width="400" height="200"
aria-label="Chart: {{ data.managed_domains_sliced_at_end_date.0 }} managed domains for {{ data.end_date }}"
role="img"
data-list-one="{{data.managed_domains_sliced_at_start_date}}"
data-list-two="{{data.managed_domains_sliced_at_end_date}}"
>
<h2>Chart: Managed domains</h2>
<p>{{ data.managed_domains_sliced_at_end_date.0 }} managed domains for {{ data.end_date }}</p>
</canvas>
</div>
<div class="grid-col">
<canvas id="myChart2" width="400" height="200"
aria-label="Chart: {{ data.unmanaged_domains_sliced_at_end_date.0 }} unmanaged domains for {{ data.end_date }}"
role="img"
data-list-one="{{data.unmanaged_domains_sliced_at_start_date}}"
data-list-two="{{data.unmanaged_domains_sliced_at_end_date}}"
>
<h2>Chart: Unmanaged domains</h2>
<p>{{ data.unmanaged_domains_sliced_at_end_date.0 }} unmanaged domains for {{ data.end_date }}</p>
</canvas>
</div>
</div>
<div class="grid-row grid-gap-2 margin-y-2">
<div class="grid-col">
<canvas id="myChart3" width="400" height="200"
aria-label="Chart: {{ data.deleted_domains_sliced_at_end_date.0 }} deleted domains for {{ data.end_date }}"
role="img"
data-list-one="{{data.deleted_domains_sliced_at_start_date}}"
data-list-two="{{data.deleted_domains_sliced_at_end_date}}"
>
<h2>Chart: Deleted domains</h2>
<p>{{ data.deleted_domains_sliced_at_end_date.0 }} deleted domains for {{ data.end_date }}</p>
</canvas>
</div>
<div class="grid-col">
<canvas id="myChart4" width="400" height="200"
aria-label="Chart: {{ data.ready_domains_sliced_at_end_date.0 }} ready domains for {{ data.end_date }}"
role="img"
data-list-one="{{data.ready_domains_sliced_at_start_date}}"
data-list-two="{{data.ready_domains_sliced_at_end_date}}"
>
<h2>Chart: Ready domains</h2>
<p>{{ data.ready_domains_sliced_at_end_date.0 }} ready domains for {{ data.end_date }}</p>
</canvas>
</div>
</div>
<div class="grid-row grid-gap-2 margin-y-2">
<div class="grid-col">
<canvas id="myChart5" width="400" height="200"
aria-label="Chart: {{ data.submitted_requests_sliced_at_end_date.0 }} submitted requests for {{ data.end_date }}"
role="img"
data-list-one="{{data.submitted_requests_sliced_at_start_date}}"
data-list-two="{{data.submitted_requests_sliced_at_end_date}}"
>
<h2>Chart: Submitted requests</h2>
<p>{{ data.submitted_requests_sliced_at_end_date.0 }} submitted requests for {{ data.end_date }}</p>
</canvas>
</div>
<div class="grid-col">
<canvas id="myChart6" width="400" height="200"
aria-label="Chart: {{ data.requests_sliced_at_end_date.0 }} requests for {{ data.end_date }}"
role="img"
data-list-one="{{data.requests_sliced_at_start_date}}"
data-list-two="{{data.requests_sliced_at_end_date}}"
>
<h2>Chart: All requests</h2>
<p>{{ data.requests_sliced_at_end_date.0 }} requests for {{ data.end_date }}</p>
</canvas>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -64,6 +64,11 @@
</table> </table>
</div> </div>
{% endfor %} {% endfor %}
<div class="module module--custom">
<h2>Analytics</h2>
<a class="display-block padding-y-1 padding-x-1" href="{% url 'analytics' %}">Dashboard</a>
</div>
{% else %} {% else %}
<p>{% translate 'You dont have permission to view or edit anything.' %}</p> <p>{% translate 'You dont have permission to view or edit anything.' %}</p>
{% endif %} {% endif %}

View file

@ -20,7 +20,9 @@
> >
<script src="{% static 'js/uswds-init.min.js' %}" defer></script> <script src="{% static 'js/uswds-init.min.js' %}" defer></script>
<script src="{% static 'js/uswds.min.js' %}" defer></script> <script src="{% static 'js/uswds.min.js' %}" defer></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script type="application/javascript" src="{% static 'js/get-gov-admin.js' %}" defer></script> <script type="application/javascript" src="{% static 'js/get-gov-admin.js' %}" defer></script>
<script type="application/javascript" src="{% static 'js/get-gov-reports.js' %}" defer></script>
{% endblock %} {% endblock %}
{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} {% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}

View file

@ -22,7 +22,7 @@ Load our custom filters to extract info from the django generated markup.
<th scope="col" class="action-checkbox-column" title="Toggle all"> <th scope="col" class="action-checkbox-column" title="Toggle all">
<div class="text"> <div class="text">
<span> <span>
<input type="checkbox" name="_selected_action" id="action-toggle"> <input type="checkbox" id="action-toggle">
<label for="action-toggle" class="usa-sr-only">Toggle all</label> <label for="action-toggle" class="usa-sr-only">Toggle all</label>
</span> </span>
</div> </div>

View file

@ -1,33 +0,0 @@
{% extends "admin/index.html" %}
{% block content %}
<div id="content-main">
{% include "admin/app_list.html" with app_list=app_list show_changelinks=True %}
<div class="custom-content module">
<h2>Reports</h2>
<h3>Domain growth report</h3>
{% 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 %}
<div class="display-flex flex-align-baseline flex-justify margin-y-1">
<div>
<label for="start">Start date:</label>
<input type="date" id="start" name="start" value="2018-07-22" min="2018-01-01" />
</div>
<div>
<label for="end">End date:</label>
<input type="date" id="end" name="end" value="2023-12-01" min="2023-12-01" />
</div>
<button id="exportLink" data-export-url="{% url 'admin_export_data' %}" type="button" class="button">Export</button>
</div>
</div>
</div>
{% endblock %}

View file

@ -54,7 +54,7 @@
{# Create a modal for the _extend_expiration_date button #} {# Create a modal for the _extend_expiration_date button #}
<div <div
class="usa-modal django-admin-modal" class="usa-modal usa-modal--django-admin"
id="toggle-extend-expiration-alert" id="toggle-extend-expiration-alert"
aria-labelledby="Are you sure you want to extend the expiration date?" aria-labelledby="Are you sure you want to extend the expiration date?"
aria-describedby="This expiration date will be extended." aria-describedby="This expiration date will be extended."
@ -118,7 +118,7 @@
{# Create a modal for the _on_hold button #} {# Create a modal for the _on_hold button #}
<div <div
class="usa-modal django-admin-modal" class="usa-modal usa-modal--django-admin"
id="toggle-place-on-hold" id="toggle-place-on-hold"
aria-labelledby="Are you sure you want to place this domain on hold?" aria-labelledby="Are you sure you want to place this domain on hold?"
aria-describedby="This domain will be put on hold" aria-describedby="This domain will be put on hold"
@ -185,7 +185,7 @@
</div> </div>
{# Create a modal for the _remove_domain button #} {# Create a modal for the _remove_domain button #}
<div <div
class="usa-modal django-admin-modal" class="usa-modal usa-modal--django-admin"
id="toggle-remove-from-registry" id="toggle-remove-from-registry"
aria-labelledby="Are you sure you want to remove this domain from the registry?" aria-labelledby="Are you sure you want to remove this domain from the registry?"
aria-describedby="This domain will be removed." aria-describedby="This domain will be removed."

View file

@ -1,23 +0,0 @@
{% extends "admin/change_list.html" %}
{% block object-tools %}
<ul class="object-tools">
<li>
<a href="{% url 'admin:registrar_domain_export_data_type' %}" class="button">Export all domain metadata</a>
</li>
<li>
<a href="{% url 'admin:registrar_domain_export_data_full' %}" class="button">Export current-full.csv</a>
</li>
<li>
<a href="{% url 'admin:registrar_domain_export_data_federal' %}" class="button">Export current-federal.csv</a>
</li>
{% if has_add_permission %}
<li>
<a href="{% url 'admin:registrar_domain_add' %}" class="addlink">
Add domain
</a>
</li>
{% endif %}
</ul>
{% endblock %}

View file

@ -1,4 +1,3 @@
import datetime
import os import os
import logging import logging
@ -13,6 +12,8 @@ from django.contrib.sessions.middleware import SessionMiddleware
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model, login from django.contrib.auth import get_user_model, login
from django.utils.timezone import make_aware from django.utils.timezone import make_aware
from datetime import date, datetime, timedelta
from django.utils import timezone
from registrar.models import ( from registrar.models import (
Contact, Contact,
@ -35,6 +36,7 @@ from epplibwrapper import (
ErrorCode, ErrorCode,
responses, responses,
) )
from registrar.models.user_domain_role import UserDomainRole
from registrar.models.utility.contact_error import ContactError, ContactErrorCodes from registrar.models.utility.contact_error import ContactError, ContactErrorCodes
@ -492,6 +494,184 @@ class AuditedAdminMockData:
return domain_request 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(): def mock_user():
"""A simple user.""" """A simple user."""
user_kwargs = dict( user_kwargs = dict(
@ -680,7 +860,7 @@ class MockEppLib(TestCase):
self, self,
id, id,
email, 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", pw="thisisnotapassword",
): ):
fake = info.InfoContactResultData( fake = info.InfoContactResultData(
@ -718,82 +898,82 @@ class MockEppLib(TestCase):
mockDataInfoDomain = fakedEppObject( mockDataInfoDomain = fakedEppObject(
"fakePw", "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)], contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)],
hosts=["fake.host.com"], hosts=["fake.host.com"],
statuses=[ statuses=[
common.Status(state="serverTransferProhibited", description="", lang="en"), common.Status(state="serverTransferProhibited", description="", lang="en"),
common.Status(state="inactive", 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( mockDataInfoDomainSubdomain = fakedEppObject(
"fakePw", "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)], contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)],
hosts=["fake.meoward.gov"], hosts=["fake.meoward.gov"],
statuses=[ statuses=[
common.Status(state="serverTransferProhibited", description="", lang="en"), common.Status(state="serverTransferProhibited", description="", lang="en"),
common.Status(state="inactive", 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( mockDataInfoDomainSubdomainAndIPAddress = fakedEppObject(
"fakePw", "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)], contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)],
hosts=["fake.meow.gov"], hosts=["fake.meow.gov"],
statuses=[ statuses=[
common.Status(state="serverTransferProhibited", description="", lang="en"), common.Status(state="serverTransferProhibited", description="", lang="en"),
common.Status(state="inactive", 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")], addrs=[common.Ip(addr="2.0.0.8")],
) )
mockDataInfoDomainNotSubdomainNoIP = fakedEppObject( mockDataInfoDomainNotSubdomainNoIP = fakedEppObject(
"fakePw", "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)], contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)],
hosts=["fake.meow.com"], hosts=["fake.meow.com"],
statuses=[ statuses=[
common.Status(state="serverTransferProhibited", description="", lang="en"), common.Status(state="serverTransferProhibited", description="", lang="en"),
common.Status(state="inactive", 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( mockDataInfoDomainSubdomainNoIP = fakedEppObject(
"fakePw", "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)], contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)],
hosts=["fake.subdomainwoip.gov"], hosts=["fake.subdomainwoip.gov"],
statuses=[ statuses=[
common.Status(state="serverTransferProhibited", description="", lang="en"), common.Status(state="serverTransferProhibited", description="", lang="en"),
common.Status(state="inactive", 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( mockDataExtensionDomain = fakedEppObject(
"fakePw", "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)], contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)],
hosts=["fake.host.com"], hosts=["fake.host.com"],
statuses=[ statuses=[
common.Status(state="serverTransferProhibited", description="", lang="en"), common.Status(state="serverTransferProhibited", description="", lang="en"),
common.Status(state="inactive", 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( 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( InfoDomainWithContacts = fakedEppObject(
"fakepw", "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=[ contacts=[
common.DomainContact( common.DomainContact(
contact="securityContact", contact="securityContact",
@ -818,7 +998,7 @@ class MockEppLib(TestCase):
InfoDomainWithDefaultSecurityContact = fakedEppObject( InfoDomainWithDefaultSecurityContact = fakedEppObject(
"fakepw", "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=[ contacts=[
common.DomainContact( common.DomainContact(
contact="defaultSec", contact="defaultSec",
@ -833,11 +1013,11 @@ class MockEppLib(TestCase):
) )
mockVerisignDataInfoContact = mockDataInfoDomain.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( InfoDomainWithVerisignSecurityContact = fakedEppObject(
"fakepw", "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=[ contacts=[
common.DomainContact( common.DomainContact(
contact="defaultVeri", contact="defaultVeri",
@ -853,7 +1033,7 @@ class MockEppLib(TestCase):
InfoDomainWithDefaultTechnicalContact = fakedEppObject( InfoDomainWithDefaultTechnicalContact = fakedEppObject(
"fakepw", "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=[ contacts=[
common.DomainContact( common.DomainContact(
contact="defaultTech", contact="defaultTech",
@ -878,14 +1058,14 @@ class MockEppLib(TestCase):
infoDomainNoContact = fakedEppObject( infoDomainNoContact = fakedEppObject(
"security", "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=[], contacts=[],
hosts=["fake.host.com"], hosts=["fake.host.com"],
) )
infoDomainThreeHosts = fakedEppObject( infoDomainThreeHosts = fakedEppObject(
"my-nameserver.gov", "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=[], contacts=[],
hosts=[ hosts=[
"ns1.my-nameserver-1.com", "ns1.my-nameserver-1.com",
@ -896,43 +1076,43 @@ class MockEppLib(TestCase):
infoDomainNoHost = fakedEppObject( infoDomainNoHost = fakedEppObject(
"my-nameserver.gov", "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=[], contacts=[],
hosts=[], hosts=[],
) )
infoDomainTwoHosts = fakedEppObject( infoDomainTwoHosts = fakedEppObject(
"my-nameserver.gov", "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=[], contacts=[],
hosts=["ns1.my-nameserver-1.com", "ns1.my-nameserver-2.com"], hosts=["ns1.my-nameserver-1.com", "ns1.my-nameserver-2.com"],
) )
mockDataInfoHosts = fakedEppObject( mockDataInfoHosts = fakedEppObject(
"lastPw", "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")], addrs=[common.Ip(addr="1.2.3.4"), common.Ip(addr="2.3.4.5")],
) )
mockDataInfoHosts1IP = fakedEppObject( mockDataInfoHosts1IP = fakedEppObject(
"lastPw", "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")], addrs=[common.Ip(addr="2.0.0.8")],
) )
mockDataInfoHostsNotSubdomainNoIP = fakedEppObject( mockDataInfoHostsNotSubdomainNoIP = fakedEppObject(
"lastPw", "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=[], addrs=[],
) )
mockDataInfoHostsSubdomainNoIP = fakedEppObject( mockDataInfoHostsSubdomainNoIP = fakedEppObject(
"lastPw", "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=[], 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 = { addDsData1 = {
"keyTag": 1234, "keyTag": 1234,
"alg": 3, "alg": 3,
@ -964,7 +1144,7 @@ class MockEppLib(TestCase):
infoDomainHasIP = fakedEppObject( infoDomainHasIP = fakedEppObject(
"nameserverwithip.gov", "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=[ contacts=[
common.DomainContact( common.DomainContact(
contact="securityContact", contact="securityContact",
@ -989,7 +1169,7 @@ class MockEppLib(TestCase):
justNameserver = fakedEppObject( justNameserver = fakedEppObject(
"justnameserver.com", "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=[ contacts=[
common.DomainContact( common.DomainContact(
contact="securityContact", contact="securityContact",
@ -1012,7 +1192,7 @@ class MockEppLib(TestCase):
infoDomainCheckHostIPCombo = fakedEppObject( infoDomainCheckHostIPCombo = fakedEppObject(
"nameserversubdomain.gov", "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=[], contacts=[],
hosts=[ hosts=[
"ns1.nameserversubdomain.gov", "ns1.nameserversubdomain.gov",
@ -1022,27 +1202,27 @@ class MockEppLib(TestCase):
mockRenewedDomainExpDate = fakedEppObject( mockRenewedDomainExpDate = fakedEppObject(
"fake.gov", "fake.gov",
ex_date=datetime.date(2023, 5, 25), ex_date=date(2023, 5, 25),
) )
mockButtonRenewedDomainExpDate = fakedEppObject( mockButtonRenewedDomainExpDate = fakedEppObject(
"fake.gov", "fake.gov",
ex_date=datetime.date(2025, 5, 25), ex_date=date(2025, 5, 25),
) )
mockDnsNeededRenewedDomainExpDate = fakedEppObject( mockDnsNeededRenewedDomainExpDate = fakedEppObject(
"fakeneeded.gov", "fakeneeded.gov",
ex_date=datetime.date(2023, 2, 15), ex_date=date(2023, 2, 15),
) )
mockMaximumRenewedDomainExpDate = fakedEppObject( mockMaximumRenewedDomainExpDate = fakedEppObject(
"fakemaximum.gov", "fakemaximum.gov",
ex_date=datetime.date(2024, 12, 31), ex_date=date(2024, 12, 31),
) )
mockRecentRenewedDomainExpDate = fakedEppObject( mockRecentRenewedDomainExpDate = fakedEppObject(
"waterbutpurple.gov", "waterbutpurple.gov",
ex_date=datetime.date(2024, 11, 15), ex_date=date(2024, 11, 15),
) )
def _mockDomainName(self, _name, _avail=False): def _mockDomainName(self, _name, _avail=False):

View file

@ -3,7 +3,7 @@ from django.urls import reverse
from registrar.tests.common import create_superuser from registrar.tests.common import create_superuser
class TestViews(TestCase): class TestAdminViews(TestCase):
def setUp(self): def setUp(self):
self.client = Client(HTTP_HOST="localhost:8080") self.client = Client(HTTP_HOST="localhost:8080")
self.superuser = create_superuser() self.superuser = create_superuser()
@ -26,7 +26,7 @@ class TestViews(TestCase):
# Construct the URL for the export data view with start_date and end_date parameters: # Construct the URL for the export data view with start_date and end_date parameters:
# This stuff is currently done in JS # 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 # Make a GET request to the export data page
response = self.client.get(export_data_url) response = self.client.get(export_data_url)

View file

@ -1,18 +1,18 @@
import csv import csv
import io import io
from django.test import Client, RequestFactory, TestCase from django.test import Client, RequestFactory
from io import StringIO 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.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 ( 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_start_date,
get_default_end_date, get_default_end_date,
write_requests_csv,
) )
from django.core.management import call_command from django.core.management import call_command
@ -22,62 +22,19 @@ from django.conf import settings
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
import boto3_mocking import boto3_mocking
from registrar.utility.s3_bucket import S3ClientError, S3ClientErrorCodes # type: ignore 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 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""" """Tests to determine if we are uploading our reports correctly"""
def setUp(self): def setUp(self):
"""Create fake domain data""" """Create fake domain data"""
super().setUp()
self.client = Client(HTTP_HOST="localhost:8080") self.client = Client(HTTP_HOST="localhost:8080")
self.factory = RequestFactory() 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 @boto3_mocking.patching
def test_generate_federal_report(self): def test_generate_federal_report(self):
@ -88,6 +45,7 @@ class CsvReportsTest(TestCase):
expected_file_content = [ expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), 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("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("ddomain3.gov,Federal,Armed Forces Retirement Home,,,, \r\n"),
] ]
# We don't actually want to write anything for a test case, # We don't actually want to write anything for a test case,
@ -108,6 +66,7 @@ class CsvReportsTest(TestCase):
expected_file_content = [ expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), 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("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("ddomain3.gov,Federal,Armed Forces Retirement Home,,,, \r\n"),
call("adomain2.gov,Interstate,,,,, \r\n"), call("adomain2.gov,Interstate,,,,, \r\n"),
] ]
@ -166,6 +125,7 @@ class CsvReportsTest(TestCase):
@boto3_mocking.patching @boto3_mocking.patching
def test_load_federal_report(self): def test_load_federal_report(self):
"""Tests the get_current_federal api endpoint""" """Tests the get_current_federal api endpoint"""
with less_console_noise(): with less_console_noise():
mock_client = MagicMock() mock_client = MagicMock()
mock_client_instance = mock_client.return_value mock_client_instance = mock_client.return_value
@ -199,6 +159,7 @@ class CsvReportsTest(TestCase):
@boto3_mocking.patching @boto3_mocking.patching
def test_load_full_report(self): def test_load_full_report(self):
"""Tests the current-federal api link""" """Tests the current-federal api link"""
with less_console_noise(): with less_console_noise():
mock_client = MagicMock() mock_client = MagicMock()
mock_client_instance = mock_client.return_value mock_client_instance = mock_client.return_value
@ -231,141 +192,17 @@ class CsvReportsTest(TestCase):
self.assertEqual(expected_file_content, response.content) self.assertEqual(expected_file_content, response.content)
class ExportDataTest(MockEppLib): class ExportDataTest(MockDb, MockEppLib):
def setUp(self): def setUp(self):
super().setUp() 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): 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() super().tearDown()
def test_export_domains_to_writer_security_emails(self): def test_export_domains_to_writer_security_emails(self):
"""Test that export_domains_to_writer returns the """Test that export_domains_to_writer returns the
expected security email""" expected security email"""
with less_console_noise(): with less_console_noise():
# Add security email information # Add security email information
self.domain_1.name = "defaultsecurity.gov" self.domain_1.name = "defaultsecurity.gov"
@ -403,7 +240,7 @@ class ExportDataTest(MockEppLib):
} }
self.maxDiff = None self.maxDiff = None
# Call the export functions # Call the export functions
write_csv( write_domains_csv(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
) )
@ -427,10 +264,11 @@ class ExportDataTest(MockEppLib):
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.assertEqual(csv_content, expected_content) self.assertEqual(csv_content, expected_content)
def test_write_csv(self): def test_write_domains_csv(self):
"""Test that write_body returns the """Test that write_body returns the
existing domain, test that sort by domain name works, existing domain, test that sort by domain name works,
test that filter works""" test that filter works"""
with less_console_noise(): with less_console_noise():
# Create a CSV file in memory # Create a CSV file in memory
csv_file = StringIO() csv_file = StringIO()
@ -462,7 +300,7 @@ class ExportDataTest(MockEppLib):
], ],
} }
# Call the export functions # Call the export functions
write_csv( write_domains_csv(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
) )
# Reset the CSV file's position to the beginning # Reset the CSV file's position to the beginning
@ -486,8 +324,9 @@ class ExportDataTest(MockEppLib):
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.assertEqual(csv_content, expected_content) 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""" """An additional test for filters and multi-column sort"""
with less_console_noise(): with less_console_noise():
# Create a CSV file in memory # Create a CSV file in memory
csv_file = StringIO() csv_file = StringIO()
@ -512,7 +351,7 @@ class ExportDataTest(MockEppLib):
], ],
} }
# Call the export functions # Call the export functions
write_csv( write_domains_csv(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
) )
# Reset the CSV file's position to the beginning # Reset the CSV file's position to the beginning
@ -535,27 +374,23 @@ class ExportDataTest(MockEppLib):
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.assertEqual(csv_content, expected_content) 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 """Test that domains that are
1. READY and their first_ready dates are in range 1. READY and their first_ready dates are in range
2. DELETED and their deleted 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. 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. 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 and would have been easy to set up, but expected_content would contain created_at dates
which are hard to mock. 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(): with less_console_noise():
# Create a CSV file in memory # Create a CSV file in memory
csv_file = StringIO() csv_file = StringIO()
writer = csv.writer(csv_file) 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 # Define columns, sort fields, and filter condition
columns = [ columns = [
"Domain name", "Domain name",
@ -579,19 +414,19 @@ class ExportDataTest(MockEppLib):
"domain__state__in": [ "domain__state__in": [
Domain.State.READY, Domain.State.READY,
], ],
"domain__first_ready__lte": end_date, "domain__first_ready__lte": self.end_date,
"domain__first_ready__gte": start_date, "domain__first_ready__gte": self.start_date,
} }
filter_conditions_for_deleted_domains = { filter_conditions_for_deleted_domains = {
"domain__state__in": [ "domain__state__in": [
Domain.State.DELETED, Domain.State.DELETED,
], ],
"domain__deleted__lte": end_date, "domain__deleted__lte": self.end_date,
"domain__deleted__gte": start_date, "domain__deleted__gte": self.start_date,
} }
# Call the export functions # Call the export functions
write_csv( write_domains_csv(
writer, writer,
columns, columns,
sort_fields, sort_fields,
@ -599,7 +434,7 @@ class ExportDataTest(MockEppLib):
get_domain_managers=False, get_domain_managers=False,
should_write_header=True, should_write_header=True,
) )
write_csv( write_domains_csv(
writer, writer,
columns, columns,
sort_fields_for_deleted_domains, sort_fields_for_deleted_domains,
@ -634,13 +469,13 @@ class ExportDataTest(MockEppLib):
def test_export_domains_to_writer_domain_managers(self): def test_export_domains_to_writer_domain_managers(self):
"""Test that export_domains_to_writer returns the """Test that export_domains_to_writer returns the
expected domain managers""" expected domain managers."""
with less_console_noise(): with less_console_noise():
# Create a CSV file in memory # Create a CSV file in memory
csv_file = StringIO() csv_file = StringIO()
writer = csv.writer(csv_file) writer = csv.writer(csv_file)
# Define columns, sort fields, and filter condition # Define columns, sort fields, and filter condition
columns = [ columns = [
"Domain name", "Domain name",
"Status", "Status",
@ -664,7 +499,7 @@ class ExportDataTest(MockEppLib):
} }
self.maxDiff = None self.maxDiff = None
# Call the export functions # Call the export functions
write_csv( write_domains_csv(
writer, columns, sort_fields, filter_condition, get_domain_managers=True, should_write_header=True writer, columns, sort_fields, filter_condition, get_domain_managers=True, should_write_header=True
) )
@ -677,11 +512,11 @@ class ExportDataTest(MockEppLib):
expected_content = ( expected_content = (
"Domain name,Status,Expiration date,Domain type,Agency," "Domain name,Status,Expiration date,Domain type,Agency,"
"Organization name,City,State,AO,AO email," "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" "adomain10.gov,Ready,,Federal,Armed Forces Retirement Home,,,, , ,\n"
"adomain2.gov,Dns needed,,Interstate,,,,, , , ,meoward@rocks.com\n" "adomain2.gov,Dns needed,,Interstate,,,,, , , ,meoward@rocks.com\n"
"cdomain1.gov,Ready,,Federal - Executive,World War I Centennial Commission,,," "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" "ddomain3.gov,On hold,,Federal,Armed Forces Retirement Home,,,, , , ,,\n"
) )
# Normalize line endings and remove commas, # Normalize line endings and remove commas,
@ -690,8 +525,132 @@ class ExportDataTest(MockEppLib):
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.assertEqual(csv_content, expected_content) 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."""
class HelperFunctions(TestCase): 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.
"""
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.""" """This asserts that 1=1. Its limited usefulness lies in making sure the helper methods stay healthy."""
def test_get_default_start_date(self): def test_get_default_start_date(self):
@ -704,3 +663,33 @@ class HelperFunctions(TestCase):
expected_date = timezone.now() expected_date = timezone.now()
actual_date = get_default_end_date() actual_date = get_default_end_date()
self.assertEqual(actual_date.date(), expected_date.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)

View file

@ -1,7 +1,9 @@
from collections import Counter
import csv import csv
import logging import logging
from datetime import datetime from datetime import datetime
from registrar.models.domain import Domain from registrar.models.domain import Domain
from registrar.models.domain_request import DomainRequest
from registrar.models.domain_information import DomainInformation from registrar.models.domain_information import DomainInformation
from django.utils import timezone from django.utils import timezone
from django.core.paginator import Paginator 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. 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. Works with write_header as long as the same writer object is passed.
""" """
writer.writerow(columns) writer.writerow(columns)
def get_domain_infos(filter_condition, sort_fields): 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 = ( domain_infos = (
DomainInformation.objects.select_related("domain", "authorizing_official") DomainInformation.objects.select_related("domain", "authorizing_official")
.prefetch_related("domain__permissions") .prefetch_related("domain__permissions")
.filter(**filter_condition) .filter(**filter_condition)
.order_by(*sort_fields) .order_by(*sort_fields)
.distinct()
) )
# Do a mass concat of the first and last name fields for authorizing_official. # 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 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""" """Given a set of columns, generate a new row from cleaned column data"""
# Domain should never be none when parsing this information # Domain should never be none when parsing this information
@ -129,7 +137,7 @@ def _get_security_emails(sec_contact_ids):
return security_emails_dict return security_emails_dict
def write_csv( def write_domains_csv(
writer, writer,
columns, columns,
sort_fields, sort_fields,
@ -138,10 +146,10 @@ def write_csv(
should_write_header=True, should_write_header=True,
): ):
""" """
Receives params from the parent methods and outputs a CSV with fltered and sorted domains. Receives params from the parent methods and outputs a CSV with filtered and sorted domains.
Works with write_header as longas the same writer object is passed. 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 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) all_domain_infos = get_domain_infos(filter_condition, sort_fields)
@ -175,7 +183,7 @@ def write_csv(
columns.append(column_name) columns.append(column_name)
try: 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) rows.append(row)
except ValueError: except ValueError:
# This should not happen. If it does, just skip this row. # This should not happen. If it does, just skip this row.
@ -189,6 +197,82 @@ def write_csv(
writer.writerows(total_body_rows) 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): def export_data_type_to_csv(csv_file):
"""All domains report with extra columns""" """All domains report with extra columns"""
@ -223,7 +307,9 @@ def export_data_type_to_csv(csv_file):
Domain.State.ON_HOLD, 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): def export_data_full_to_csv(csv_file):
@ -254,7 +340,9 @@ def export_data_full_to_csv(csv_file):
Domain.State.ON_HOLD, 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): def export_data_federal_to_csv(csv_file):
@ -286,7 +374,9 @@ def export_data_federal_to_csv(csv_file):
Domain.State.ON_HOLD, 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(): def get_default_start_date():
@ -299,7 +389,15 @@ def get_default_end_date():
return timezone.now() 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: Growth report:
Receive start and end dates from the view, parse them. 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. the start and end dates. Specify sort params for both lists.
""" """
start_date_formatted = ( start_date_formatted = format_start_date(start_date)
timezone.make_aware(datetime.strptime(start_date, "%Y-%m-%d")) if start_date else get_default_start_date() end_date_formatted = format_end_date(end_date)
)
end_date_formatted = (
timezone.make_aware(datetime.strptime(end_date, "%Y-%m-%d")) if end_date else get_default_end_date()
)
writer = csv.writer(csv_file) writer = csv.writer(csv_file)
# define columns to include in export # define columns to include in export
columns = [ columns = [
"Domain name", "Domain name",
@ -353,8 +444,10 @@ def export_data_growth_to_csv(csv_file, start_date, end_date):
"domain__deleted__gte": start_date_formatted, "domain__deleted__gte": start_date_formatted,
} }
write_csv(writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True) write_domains_csv(
write_csv( writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
)
write_domains_csv(
writer, writer,
columns, columns,
sort_fields_for_deleted_domains, 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, get_domain_managers=False,
should_write_header=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)

View file

@ -2,6 +2,12 @@
from django.http import HttpResponse from django.http import HttpResponse
from django.views import View 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 from registrar.utility import csv_export
@ -10,7 +16,157 @@ import logging
logger = logging.getLogger(__name__) 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): def get(self, request, *args, **kwargs):
# Get start_date and end_date from the request's GET parameters # Get start_date and end_date from the request's GET parameters
# #999: not needed if we switch to django forms # #999: not needed if we switch to django forms
@ -19,8 +175,50 @@ class ExportData(View):
response = HttpResponse(content_type="text/csv") response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="domain-growth-report-{start_date}-to-{end_date}.csv"' response["Content-Disposition"] = f'attachment; filename="domain-growth-report-{start_date}-to-{end_date}.csv"'
# 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. # 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 return response