Merge branch 'main' into za/1852-user-contact-info-inline

This commit is contained in:
zandercymatics 2024-03-20 08:15:08 -06:00
commit ee66b234cf
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:
- python_buildpack
path: ../../src
instances: 2
instances: 1
memory: 512M
stack: cflinuxfs4
timeout: 180

View file

@ -3,9 +3,9 @@ import logging
import copy
from django import forms
from django.db.models.functions import Concat, Coalesce
from django.db.models import Value, CharField, Q
from django.http import HttpResponse, HttpResponseRedirect
from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect
from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions
from django.contrib import admin, messages
@ -16,7 +16,6 @@ from django.urls import reverse
from dateutil.relativedelta import relativedelta # type: ignore
from epplibwrapper.errors import ErrorCode, RegistryError
from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website
from registrar.utility import csv_export
from registrar.utility.errors import FSMApplicationError, FSMErrorCodes
from registrar.views.utility.mixins import OrderableFieldsMixin
from django.contrib.admin.views.main import ORDER_VAR
@ -1469,7 +1468,6 @@ class DomainAdmin(ListHeaderAdmin):
search_fields = ["name"]
search_help_text = "Search by domain name."
change_form_template = "django/admin/domain_change_form.html"
change_list_template = "django/admin/domain_change_list.html"
readonly_fields = ["state", "expiration_date", "first_ready", "deleted"]
# Table ordering
@ -1516,56 +1514,6 @@ class DomainAdmin(ListHeaderAdmin):
return super().changeform_view(request, object_id, form_url, extra_context)
def export_data_type(self, request):
# match the CSV example with all the fields
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="domains-by-type.csv"'
csv_export.export_data_type_to_csv(response)
return response
def export_data_full(self, request):
# Smaller export based on 1
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="current-full.csv"'
csv_export.export_data_full_to_csv(response)
return response
def export_data_federal(self, request):
# Federal only
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="current-federal.csv"'
csv_export.export_data_federal_to_csv(response)
return response
def get_urls(self):
from django.urls import path
urlpatterns = super().get_urls()
# Used to extrapolate a path name, for instance
# name="{app_label}_{model_name}_export_data_type"
info = self.model._meta.app_label, self.model._meta.model_name
my_url = [
path(
"export_data_type/",
self.export_data_type,
name="%s_%s_export_data_type" % info,
),
path(
"export_data_full/",
self.export_data_full,
name="%s_%s_export_data_full" % info,
),
path(
"export_data_federal/",
self.export_data_federal,
name="%s_%s_export_data_federal" % info,
),
]
return my_url + urlpatterns
def response_change(self, request, obj):
# Create dictionary of action functions
ACTION_FUNCTIONS = {
@ -1697,9 +1645,11 @@ class DomainAdmin(ListHeaderAdmin):
else:
self.message_user(
request,
(
"Error deleting this Domain: "
f"Can't switch from state '{obj.state}' to 'deleted'"
", must be either 'dns_needed' or 'on_hold'",
", must be either 'dns_needed' or 'on_hold'"
),
messages.ERROR,
)
except Exception:
@ -1711,7 +1661,7 @@ class DomainAdmin(ListHeaderAdmin):
else:
self.message_user(
request,
("Domain %s has been deleted. Thanks!") % obj.name,
"Domain %s has been deleted. Thanks!" % obj.name,
)
return HttpResponseRedirect(".")
@ -1753,7 +1703,7 @@ class DomainAdmin(ListHeaderAdmin):
else:
self.message_user(
request,
("%s is in client hold. This domain is no longer accessible on the public internet.") % obj.name,
"%s is in client hold. This domain is no longer accessible on the public internet." % obj.name,
)
return HttpResponseRedirect(".")
@ -1782,7 +1732,7 @@ class DomainAdmin(ListHeaderAdmin):
else:
self.message_user(
request,
("%s is ready. This domain is accessible on the public internet.") % obj.name,
"%s is ready. This domain is accessible on the public internet." % obj.name,
)
return HttpResponseRedirect(".")

View file

@ -412,43 +412,6 @@ function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk,
viewLink.setAttribute('title', viewLink.getAttribute('title-template').replace('selected item', elementText));
}
/** An IIFE for admin in DjangoAdmin to listen to clicks on the growth report export button,
* attach the seleted start and end dates to a url that'll trigger the view, and finally
* redirect to that url.
*/
(function (){
// Get the current date in the format YYYY-MM-DD
let currentDate = new Date().toISOString().split('T')[0];
// Default the value of the start date input field to the current date
let startDateInput =document.getElementById('start');
// Default the value of the end date input field to the current date
let endDateInput =document.getElementById('end');
let exportGrowthReportButton = document.getElementById('exportLink');
if (exportGrowthReportButton) {
startDateInput.value = currentDate;
endDateInput.value = currentDate;
exportGrowthReportButton.addEventListener('click', function() {
// Get the selected start and end dates
let startDate = startDateInput.value;
let endDate = endDateInput.value;
let exportUrl = document.getElementById('exportLink').dataset.exportUrl;
// Build the URL with parameters
exportUrl += "?start_date=" + startDate + "&end_date=" + endDate;
// Redirect to the export URL
window.location.href = exportUrl;
});
}
})();
/** An IIFE for admin in DjangoAdmin to listen to changes on the domain request
* status select amd to show/hide the rejection reason
*/

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,
body.dashboard,
body.change-list,
body.change-form {
body.change-form,
.analytics {
color: var(--body-fg);
}
}
@ -304,7 +305,36 @@ input.admin-confirm-button {
}
}
.django-admin-modal .usa-prose ul > li {
.usa-button-group {
margin-left: -0.25rem!important;
padding-left: 0!important;
.usa-button-group__item {
list-style-type: none;
line-height: normal;
}
.button {
display: inline-block;
padding: 10px 8px;
line-height: normal;
}
.usa-icon {
top: 2px;
}
a.button:active, a.button:focus {
text-decoration: none;
}
}
.module--custom {
a {
font-size: 13px;
font-weight: 600;
border: solid 1px var(--darkened-bg);
background: var(--darkened-bg);
}
}
.usa-modal--django-admin .usa-prose ul > li {
list-style-type: inherit;
// Styling based off of the <p> styling in django admin
line-height: 1.5;

View file

@ -330,8 +330,9 @@ CSP_FORM_ACTION = allowed_sources
# Google analytics requires that we relax our otherwise
# strict CSP by allowing scripts to run from their domain
# and inline with a nonce, as well as allowing connections back to their domain
CSP_SCRIPT_SRC_ELEM = ["'self'", "https://www.googletagmanager.com/"]
# and inline with a nonce, as well as allowing connections back to their domain.
# Note: If needed, we can embed chart.js instead of using the CDN
CSP_SCRIPT_SRC_ELEM = ["'self'", "https://www.googletagmanager.com/", "https://cdn.jsdelivr.net/npm/chart.js"]
CSP_CONNECT_SRC = ["'self'", "https://www.google-analytics.com/"]
CSP_INCLUDE_NONCE_IN = ["script-src-elem"]

View file

@ -9,9 +9,16 @@ from django.urls import include, path
from django.views.generic import RedirectView
from registrar import views
from registrar.views.admin_views import ExportData
from registrar.views.admin_views import (
ExportDataDomainsGrowth,
ExportDataFederal,
ExportDataFull,
ExportDataManagedDomains,
ExportDataRequestsGrowth,
ExportDataType,
ExportDataUnmanagedDomains,
AnalyticsView,
)
from registrar.views.domain_request import Step
from registrar.views.utility import always_404
@ -52,7 +59,46 @@ urlpatterns = [
"admin/logout/",
RedirectView.as_view(pattern_name="logout", permanent=False),
),
path("export_data/", ExportData.as_view(), name="admin_export_data"),
path(
"admin/analytics/export_data_type/",
ExportDataType.as_view(),
name="export_data_type",
),
path(
"admin/analytics/export_data_full/",
ExportDataFull.as_view(),
name="export_data_full",
),
path(
"admin/analytics/export_data_federal/",
ExportDataFederal.as_view(),
name="export_data_federal",
),
path(
"admin/analytics/export_domains_growth/",
ExportDataDomainsGrowth.as_view(),
name="export_domains_growth",
),
path(
"admin/analytics/export_requests_growth/",
ExportDataRequestsGrowth.as_view(),
name="export_requests_growth",
),
path(
"admin/analytics/export_managed_domains/",
ExportDataManagedDomains.as_view(),
name="export_managed_domains",
),
path(
"admin/analytics/export_unmanaged_domains/",
ExportDataUnmanagedDomains.as_view(),
name="export_unmanaged_domains",
),
path(
"admin/analytics/",
AnalyticsView.as_view(),
name="analytics",
),
path("admin/", admin.site.urls),
path(
"domain-request/<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>
</div>
{% 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 %}
<p>{% translate 'You dont have permission to view or edit anything.' %}</p>
{% endif %}

View file

@ -20,7 +20,9 @@
>
<script src="{% static 'js/uswds-init.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-reports.js' %}" defer></script>
{% 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">
<div class="text">
<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>
</span>
</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 #}
<div
class="usa-modal django-admin-modal"
class="usa-modal usa-modal--django-admin"
id="toggle-extend-expiration-alert"
aria-labelledby="Are you sure you want to extend the expiration date?"
aria-describedby="This expiration date will be extended."
@ -118,7 +118,7 @@
{# Create a modal for the _on_hold button #}
<div
class="usa-modal django-admin-modal"
class="usa-modal usa-modal--django-admin"
id="toggle-place-on-hold"
aria-labelledby="Are you sure you want to place this domain on hold?"
aria-describedby="This domain will be put on hold"
@ -185,7 +185,7 @@
</div>
{# Create a modal for the _remove_domain button #}
<div
class="usa-modal django-admin-modal"
class="usa-modal usa-modal--django-admin"
id="toggle-remove-from-registry"
aria-labelledby="Are you sure you want to remove this domain from the registry?"
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 logging
@ -13,6 +12,8 @@ from django.contrib.sessions.middleware import SessionMiddleware
from django.conf import settings
from django.contrib.auth import get_user_model, login
from django.utils.timezone import make_aware
from datetime import date, datetime, timedelta
from django.utils import timezone
from registrar.models import (
Contact,
@ -35,6 +36,7 @@ from epplibwrapper import (
ErrorCode,
responses,
)
from registrar.models.user_domain_role import UserDomainRole
from registrar.models.utility.contact_error import ContactError, ContactErrorCodes
@ -492,6 +494,184 @@ class AuditedAdminMockData:
return domain_request
class MockDb(TestCase):
"""Hardcoded mocks make test case assertions straightforward."""
def setUp(self):
super().setUp()
username = "test_user"
first_name = "First"
last_name = "Last"
email = "info@example.com"
self.user = get_user_model().objects.create(
username=username, first_name=first_name, last_name=last_name, email=email
)
# Create a time-aware current date
current_datetime = timezone.now()
# Extract the date part
current_date = current_datetime.date()
# Create start and end dates using timedelta
self.end_date = current_date + timedelta(days=2)
self.start_date = current_date - timedelta(days=2)
self.domain_1, _ = Domain.objects.get_or_create(
name="cdomain1.gov", state=Domain.State.READY, first_ready=timezone.now()
)
self.domain_2, _ = Domain.objects.get_or_create(name="adomain2.gov", state=Domain.State.DNS_NEEDED)
self.domain_3, _ = Domain.objects.get_or_create(name="ddomain3.gov", state=Domain.State.ON_HOLD)
self.domain_4, _ = Domain.objects.get_or_create(name="bdomain4.gov", state=Domain.State.UNKNOWN)
self.domain_4, _ = Domain.objects.get_or_create(name="bdomain4.gov", state=Domain.State.UNKNOWN)
self.domain_5, _ = Domain.objects.get_or_create(
name="bdomain5.gov", state=Domain.State.DELETED, deleted=timezone.make_aware(datetime(2023, 11, 1))
)
self.domain_6, _ = Domain.objects.get_or_create(
name="bdomain6.gov", state=Domain.State.DELETED, deleted=timezone.make_aware(datetime(1980, 10, 16))
)
self.domain_7, _ = Domain.objects.get_or_create(
name="xdomain7.gov", state=Domain.State.DELETED, deleted=timezone.now()
)
self.domain_8, _ = Domain.objects.get_or_create(
name="sdomain8.gov", state=Domain.State.DELETED, deleted=timezone.now()
)
# We use timezone.make_aware to sync to server time a datetime object with the current date (using date.today())
# and a specific time (using datetime.min.time()).
# Deleted yesterday
self.domain_9, _ = Domain.objects.get_or_create(
name="zdomain9.gov",
state=Domain.State.DELETED,
deleted=timezone.make_aware(datetime.combine(date.today() - timedelta(days=1), datetime.min.time())),
)
# ready tomorrow
self.domain_10, _ = Domain.objects.get_or_create(
name="adomain10.gov",
state=Domain.State.READY,
first_ready=timezone.make_aware(datetime.combine(date.today() + timedelta(days=1), datetime.min.time())),
)
self.domain_information_1, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_1,
organization_type="federal",
federal_agency="World War I Centennial Commission",
federal_type="executive",
is_election_board=True,
)
self.domain_information_2, _ = DomainInformation.objects.get_or_create(
creator=self.user, domain=self.domain_2, organization_type="interstate", is_election_board=True
)
self.domain_information_3, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_3,
organization_type="federal",
federal_agency="Armed Forces Retirement Home",
is_election_board=True,
)
self.domain_information_4, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_4,
organization_type="federal",
federal_agency="Armed Forces Retirement Home",
is_election_board=True,
)
self.domain_information_5, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_5,
organization_type="federal",
federal_agency="Armed Forces Retirement Home",
is_election_board=False,
)
self.domain_information_6, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_6,
organization_type="federal",
federal_agency="Armed Forces Retirement Home",
is_election_board=False,
)
self.domain_information_7, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_7,
organization_type="federal",
federal_agency="Armed Forces Retirement Home",
is_election_board=False,
)
self.domain_information_8, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_8,
organization_type="federal",
federal_agency="Armed Forces Retirement Home",
is_election_board=False,
)
self.domain_information_9, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_9,
organization_type="federal",
federal_agency="Armed Forces Retirement Home",
is_election_board=False,
)
self.domain_information_10, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_10,
organization_type="federal",
federal_agency="Armed Forces Retirement Home",
is_election_board=False,
)
meoward_user = get_user_model().objects.create(
username="meoward_username", first_name="first_meoward", last_name="last_meoward", email="meoward@rocks.com"
)
lebowski_user = get_user_model().objects.create(
username="big_lebowski", first_name="big", last_name="lebowski", email="big_lebowski@dude.co"
)
_, created = UserDomainRole.objects.get_or_create(
user=meoward_user, domain=self.domain_1, role=UserDomainRole.Roles.MANAGER
)
_, created = UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain_1, role=UserDomainRole.Roles.MANAGER
)
_, created = UserDomainRole.objects.get_or_create(
user=lebowski_user, domain=self.domain_1, role=UserDomainRole.Roles.MANAGER
)
_, created = UserDomainRole.objects.get_or_create(
user=meoward_user, domain=self.domain_2, role=UserDomainRole.Roles.MANAGER
)
with less_console_noise():
self.domain_request_1 = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED, name="city1.gov"
)
self.domain_request_2 = completed_domain_request(
status=DomainRequest.DomainRequestStatus.IN_REVIEW, name="city2.gov"
)
self.domain_request_3 = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED, name="city3.gov"
)
self.domain_request_4 = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED, name="city4.gov"
)
self.domain_request_5 = completed_domain_request(
status=DomainRequest.DomainRequestStatus.APPROVED, name="city5.gov"
)
self.domain_request_3.submit()
self.domain_request_3.save()
self.domain_request_4.submit()
self.domain_request_4.save()
def tearDown(self):
super().tearDown()
PublicContact.objects.all().delete()
Domain.objects.all().delete()
DomainInformation.objects.all().delete()
DomainRequest.objects.all().delete()
User.objects.all().delete()
UserDomainRole.objects.all().delete()
def mock_user():
"""A simple user."""
user_kwargs = dict(
@ -680,7 +860,7 @@ class MockEppLib(TestCase):
self,
id,
email,
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
pw="thisisnotapassword",
):
fake = info.InfoContactResultData(
@ -718,82 +898,82 @@ class MockEppLib(TestCase):
mockDataInfoDomain = fakedEppObject(
"fakePw",
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)],
hosts=["fake.host.com"],
statuses=[
common.Status(state="serverTransferProhibited", description="", lang="en"),
common.Status(state="inactive", description="", lang="en"),
],
ex_date=datetime.date(2023, 5, 25),
ex_date=date(2023, 5, 25),
)
mockDataInfoDomainSubdomain = fakedEppObject(
"fakePw",
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)],
hosts=["fake.meoward.gov"],
statuses=[
common.Status(state="serverTransferProhibited", description="", lang="en"),
common.Status(state="inactive", description="", lang="en"),
],
ex_date=datetime.date(2023, 5, 25),
ex_date=date(2023, 5, 25),
)
mockDataInfoDomainSubdomainAndIPAddress = fakedEppObject(
"fakePw",
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)],
hosts=["fake.meow.gov"],
statuses=[
common.Status(state="serverTransferProhibited", description="", lang="en"),
common.Status(state="inactive", description="", lang="en"),
],
ex_date=datetime.date(2023, 5, 25),
ex_date=date(2023, 5, 25),
addrs=[common.Ip(addr="2.0.0.8")],
)
mockDataInfoDomainNotSubdomainNoIP = fakedEppObject(
"fakePw",
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)],
hosts=["fake.meow.com"],
statuses=[
common.Status(state="serverTransferProhibited", description="", lang="en"),
common.Status(state="inactive", description="", lang="en"),
],
ex_date=datetime.date(2023, 5, 25),
ex_date=date(2023, 5, 25),
)
mockDataInfoDomainSubdomainNoIP = fakedEppObject(
"fakePw",
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)],
hosts=["fake.subdomainwoip.gov"],
statuses=[
common.Status(state="serverTransferProhibited", description="", lang="en"),
common.Status(state="inactive", description="", lang="en"),
],
ex_date=datetime.date(2023, 5, 25),
ex_date=date(2023, 5, 25),
)
mockDataExtensionDomain = fakedEppObject(
"fakePw",
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)],
hosts=["fake.host.com"],
statuses=[
common.Status(state="serverTransferProhibited", description="", lang="en"),
common.Status(state="inactive", description="", lang="en"),
],
ex_date=datetime.date(2023, 11, 15),
ex_date=date(2023, 11, 15),
)
mockDataInfoContact = mockDataInfoDomain.dummyInfoContactResultData(
"123", "123@mail.gov", datetime.datetime(2023, 5, 25, 19, 45, 35), "lastPw"
"123", "123@mail.gov", datetime(2023, 5, 25, 19, 45, 35), "lastPw"
)
InfoDomainWithContacts = fakedEppObject(
"fakepw",
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[
common.DomainContact(
contact="securityContact",
@ -818,7 +998,7 @@ class MockEppLib(TestCase):
InfoDomainWithDefaultSecurityContact = fakedEppObject(
"fakepw",
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[
common.DomainContact(
contact="defaultSec",
@ -833,11 +1013,11 @@ class MockEppLib(TestCase):
)
mockVerisignDataInfoContact = mockDataInfoDomain.dummyInfoContactResultData(
"defaultVeri", "registrar@dotgov.gov", datetime.datetime(2023, 5, 25, 19, 45, 35), "lastPw"
"defaultVeri", "registrar@dotgov.gov", datetime(2023, 5, 25, 19, 45, 35), "lastPw"
)
InfoDomainWithVerisignSecurityContact = fakedEppObject(
"fakepw",
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[
common.DomainContact(
contact="defaultVeri",
@ -853,7 +1033,7 @@ class MockEppLib(TestCase):
InfoDomainWithDefaultTechnicalContact = fakedEppObject(
"fakepw",
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[
common.DomainContact(
contact="defaultTech",
@ -878,14 +1058,14 @@ class MockEppLib(TestCase):
infoDomainNoContact = fakedEppObject(
"security",
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[],
hosts=["fake.host.com"],
)
infoDomainThreeHosts = fakedEppObject(
"my-nameserver.gov",
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[],
hosts=[
"ns1.my-nameserver-1.com",
@ -896,43 +1076,43 @@ class MockEppLib(TestCase):
infoDomainNoHost = fakedEppObject(
"my-nameserver.gov",
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[],
hosts=[],
)
infoDomainTwoHosts = fakedEppObject(
"my-nameserver.gov",
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[],
hosts=["ns1.my-nameserver-1.com", "ns1.my-nameserver-2.com"],
)
mockDataInfoHosts = fakedEppObject(
"lastPw",
cr_date=make_aware(datetime.datetime(2023, 8, 25, 19, 45, 35)),
cr_date=make_aware(datetime(2023, 8, 25, 19, 45, 35)),
addrs=[common.Ip(addr="1.2.3.4"), common.Ip(addr="2.3.4.5")],
)
mockDataInfoHosts1IP = fakedEppObject(
"lastPw",
cr_date=make_aware(datetime.datetime(2023, 8, 25, 19, 45, 35)),
cr_date=make_aware(datetime(2023, 8, 25, 19, 45, 35)),
addrs=[common.Ip(addr="2.0.0.8")],
)
mockDataInfoHostsNotSubdomainNoIP = fakedEppObject(
"lastPw",
cr_date=make_aware(datetime.datetime(2023, 8, 26, 19, 45, 35)),
cr_date=make_aware(datetime(2023, 8, 26, 19, 45, 35)),
addrs=[],
)
mockDataInfoHostsSubdomainNoIP = fakedEppObject(
"lastPw",
cr_date=make_aware(datetime.datetime(2023, 8, 27, 19, 45, 35)),
cr_date=make_aware(datetime(2023, 8, 27, 19, 45, 35)),
addrs=[],
)
mockDataHostChange = fakedEppObject("lastPw", cr_date=make_aware(datetime.datetime(2023, 8, 25, 19, 45, 35)))
mockDataHostChange = fakedEppObject("lastPw", cr_date=make_aware(datetime(2023, 8, 25, 19, 45, 35)))
addDsData1 = {
"keyTag": 1234,
"alg": 3,
@ -964,7 +1144,7 @@ class MockEppLib(TestCase):
infoDomainHasIP = fakedEppObject(
"nameserverwithip.gov",
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[
common.DomainContact(
contact="securityContact",
@ -989,7 +1169,7 @@ class MockEppLib(TestCase):
justNameserver = fakedEppObject(
"justnameserver.com",
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[
common.DomainContact(
contact="securityContact",
@ -1012,7 +1192,7 @@ class MockEppLib(TestCase):
infoDomainCheckHostIPCombo = fakedEppObject(
"nameserversubdomain.gov",
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[],
hosts=[
"ns1.nameserversubdomain.gov",
@ -1022,27 +1202,27 @@ class MockEppLib(TestCase):
mockRenewedDomainExpDate = fakedEppObject(
"fake.gov",
ex_date=datetime.date(2023, 5, 25),
ex_date=date(2023, 5, 25),
)
mockButtonRenewedDomainExpDate = fakedEppObject(
"fake.gov",
ex_date=datetime.date(2025, 5, 25),
ex_date=date(2025, 5, 25),
)
mockDnsNeededRenewedDomainExpDate = fakedEppObject(
"fakeneeded.gov",
ex_date=datetime.date(2023, 2, 15),
ex_date=date(2023, 2, 15),
)
mockMaximumRenewedDomainExpDate = fakedEppObject(
"fakemaximum.gov",
ex_date=datetime.date(2024, 12, 31),
ex_date=date(2024, 12, 31),
)
mockRecentRenewedDomainExpDate = fakedEppObject(
"waterbutpurple.gov",
ex_date=datetime.date(2024, 11, 15),
ex_date=date(2024, 11, 15),
)
def _mockDomainName(self, _name, _avail=False):

View file

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

View file

@ -1,18 +1,18 @@
import csv
import io
from django.test import Client, RequestFactory, TestCase
from django.test import Client, RequestFactory
from io import StringIO
from registrar.models.domain_information import DomainInformation
from registrar.models.domain_request import DomainRequest
from registrar.models.domain import Domain
from registrar.models.public_contact import PublicContact
from registrar.models.user import User
from django.contrib.auth import get_user_model
from registrar.models.user_domain_role import UserDomainRole
from registrar.tests.common import MockEppLib
from registrar.utility.csv_export import (
write_csv,
export_data_managed_domains_to_csv,
export_data_unmanaged_domains_to_csv,
get_sliced_domains,
get_sliced_requests,
write_domains_csv,
get_default_start_date,
get_default_end_date,
write_requests_csv,
)
from django.core.management import call_command
@ -22,62 +22,19 @@ from django.conf import settings
from botocore.exceptions import ClientError
import boto3_mocking
from registrar.utility.s3_bucket import S3ClientError, S3ClientErrorCodes # type: ignore
from datetime import date, datetime, timedelta
from datetime import datetime
from django.utils import timezone
from .common import less_console_noise
from .common import MockDb, MockEppLib, less_console_noise
class CsvReportsTest(TestCase):
class CsvReportsTest(MockDb):
"""Tests to determine if we are uploading our reports correctly"""
def setUp(self):
"""Create fake domain data"""
super().setUp()
self.client = Client(HTTP_HOST="localhost:8080")
self.factory = RequestFactory()
username = "test_user"
first_name = "First"
last_name = "Last"
email = "info@example.com"
self.user = get_user_model().objects.create(
username=username, first_name=first_name, last_name=last_name, email=email
)
self.domain_1, _ = Domain.objects.get_or_create(name="cdomain1.gov", state=Domain.State.READY)
self.domain_2, _ = Domain.objects.get_or_create(name="adomain2.gov", state=Domain.State.DNS_NEEDED)
self.domain_3, _ = Domain.objects.get_or_create(name="ddomain3.gov", state=Domain.State.ON_HOLD)
self.domain_4, _ = Domain.objects.get_or_create(name="bdomain4.gov", state=Domain.State.UNKNOWN)
self.domain_information_1, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_1,
organization_type="federal",
federal_agency="World War I Centennial Commission",
federal_type="executive",
)
self.domain_information_2, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_2,
organization_type="interstate",
)
self.domain_information_3, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_3,
organization_type="federal",
federal_agency="Armed Forces Retirement Home",
)
self.domain_information_4, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_4,
organization_type="federal",
federal_agency="Armed Forces Retirement Home",
)
def tearDown(self):
"""Delete all faked data"""
Domain.objects.all().delete()
DomainInformation.objects.all().delete()
User.objects.all().delete()
super().tearDown()
@boto3_mocking.patching
def test_generate_federal_report(self):
@ -88,6 +45,7 @@ class CsvReportsTest(TestCase):
expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"),
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,, \r\n"),
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,, \r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,, \r\n"),
]
# We don't actually want to write anything for a test case,
@ -108,6 +66,7 @@ class CsvReportsTest(TestCase):
expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"),
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,, \r\n"),
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,, \r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,, \r\n"),
call("adomain2.gov,Interstate,,,,, \r\n"),
]
@ -166,6 +125,7 @@ class CsvReportsTest(TestCase):
@boto3_mocking.patching
def test_load_federal_report(self):
"""Tests the get_current_federal api endpoint"""
with less_console_noise():
mock_client = MagicMock()
mock_client_instance = mock_client.return_value
@ -199,6 +159,7 @@ class CsvReportsTest(TestCase):
@boto3_mocking.patching
def test_load_full_report(self):
"""Tests the current-federal api link"""
with less_console_noise():
mock_client = MagicMock()
mock_client_instance = mock_client.return_value
@ -231,141 +192,17 @@ class CsvReportsTest(TestCase):
self.assertEqual(expected_file_content, response.content)
class ExportDataTest(MockEppLib):
class ExportDataTest(MockDb, MockEppLib):
def setUp(self):
super().setUp()
username = "test_user"
first_name = "First"
last_name = "Last"
email = "info@example.com"
self.user = get_user_model().objects.create(
username=username, first_name=first_name, last_name=last_name, email=email
)
self.domain_1, _ = Domain.objects.get_or_create(
name="cdomain1.gov", state=Domain.State.READY, first_ready=timezone.now()
)
self.domain_2, _ = Domain.objects.get_or_create(name="adomain2.gov", state=Domain.State.DNS_NEEDED)
self.domain_3, _ = Domain.objects.get_or_create(name="ddomain3.gov", state=Domain.State.ON_HOLD)
self.domain_4, _ = Domain.objects.get_or_create(name="bdomain4.gov", state=Domain.State.UNKNOWN)
self.domain_4, _ = Domain.objects.get_or_create(name="bdomain4.gov", state=Domain.State.UNKNOWN)
self.domain_5, _ = Domain.objects.get_or_create(
name="bdomain5.gov", state=Domain.State.DELETED, deleted=timezone.make_aware(datetime(2023, 11, 1))
)
self.domain_6, _ = Domain.objects.get_or_create(
name="bdomain6.gov", state=Domain.State.DELETED, deleted=timezone.make_aware(datetime(1980, 10, 16))
)
self.domain_7, _ = Domain.objects.get_or_create(
name="xdomain7.gov", state=Domain.State.DELETED, deleted=timezone.now()
)
self.domain_8, _ = Domain.objects.get_or_create(
name="sdomain8.gov", state=Domain.State.DELETED, deleted=timezone.now()
)
# We use timezone.make_aware to sync to server time a datetime object with the current date (using date.today())
# and a specific time (using datetime.min.time()).
# Deleted yesterday
self.domain_9, _ = Domain.objects.get_or_create(
name="zdomain9.gov",
state=Domain.State.DELETED,
deleted=timezone.make_aware(datetime.combine(date.today() - timedelta(days=1), datetime.min.time())),
)
# ready tomorrow
self.domain_10, _ = Domain.objects.get_or_create(
name="adomain10.gov",
state=Domain.State.READY,
first_ready=timezone.make_aware(datetime.combine(date.today() + timedelta(days=1), datetime.min.time())),
)
self.domain_information_1, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_1,
organization_type="federal",
federal_agency="World War I Centennial Commission",
federal_type="executive",
)
self.domain_information_2, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_2,
organization_type="interstate",
)
self.domain_information_3, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_3,
organization_type="federal",
federal_agency="Armed Forces Retirement Home",
)
self.domain_information_4, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_4,
organization_type="federal",
federal_agency="Armed Forces Retirement Home",
)
self.domain_information_5, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_5,
organization_type="federal",
federal_agency="Armed Forces Retirement Home",
)
self.domain_information_6, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_6,
organization_type="federal",
federal_agency="Armed Forces Retirement Home",
)
self.domain_information_7, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_7,
organization_type="federal",
federal_agency="Armed Forces Retirement Home",
)
self.domain_information_8, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_8,
organization_type="federal",
federal_agency="Armed Forces Retirement Home",
)
self.domain_information_9, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_9,
organization_type="federal",
federal_agency="Armed Forces Retirement Home",
)
self.domain_information_10, _ = DomainInformation.objects.get_or_create(
creator=self.user,
domain=self.domain_10,
organization_type="federal",
federal_agency="Armed Forces Retirement Home",
)
meoward_user = get_user_model().objects.create(
username="meoward_username", first_name="first_meoward", last_name="last_meoward", email="meoward@rocks.com"
)
# Test for more than 1 domain manager
_, created = UserDomainRole.objects.get_or_create(
user=meoward_user, domain=self.domain_1, role=UserDomainRole.Roles.MANAGER
)
_, created = UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain_1, role=UserDomainRole.Roles.MANAGER
)
# Test for just 1 domain manager
_, created = UserDomainRole.objects.get_or_create(
user=meoward_user, domain=self.domain_2, role=UserDomainRole.Roles.MANAGER
)
def tearDown(self):
PublicContact.objects.all().delete()
Domain.objects.all().delete()
DomainInformation.objects.all().delete()
User.objects.all().delete()
UserDomainRole.objects.all().delete()
super().tearDown()
def test_export_domains_to_writer_security_emails(self):
"""Test that export_domains_to_writer returns the
expected security email"""
with less_console_noise():
# Add security email information
self.domain_1.name = "defaultsecurity.gov"
@ -403,7 +240,7 @@ class ExportDataTest(MockEppLib):
}
self.maxDiff = None
# Call the export functions
write_csv(
write_domains_csv(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
)
@ -427,10 +264,11 @@ class ExportDataTest(MockEppLib):
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.assertEqual(csv_content, expected_content)
def test_write_csv(self):
def test_write_domains_csv(self):
"""Test that write_body returns the
existing domain, test that sort by domain name works,
test that filter works"""
with less_console_noise():
# Create a CSV file in memory
csv_file = StringIO()
@ -462,7 +300,7 @@ class ExportDataTest(MockEppLib):
],
}
# Call the export functions
write_csv(
write_domains_csv(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
)
# Reset the CSV file's position to the beginning
@ -486,8 +324,9 @@ class ExportDataTest(MockEppLib):
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.assertEqual(csv_content, expected_content)
def test_write_body_additional(self):
def test_write_domains_body_additional(self):
"""An additional test for filters and multi-column sort"""
with less_console_noise():
# Create a CSV file in memory
csv_file = StringIO()
@ -512,7 +351,7 @@ class ExportDataTest(MockEppLib):
],
}
# Call the export functions
write_csv(
write_domains_csv(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
)
# Reset the CSV file's position to the beginning
@ -535,27 +374,23 @@ class ExportDataTest(MockEppLib):
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.assertEqual(csv_content, expected_content)
def test_write_body_with_date_filter_pulls_domains_in_range(self):
def test_write_domains_body_with_date_filter_pulls_domains_in_range(self):
"""Test that domains that are
1. READY and their first_ready dates are in range
2. DELETED and their deleted dates are in range
are pulled when the growth report conditions are applied to export_domains_to_writed.
Test that ready domains are sorted by first_ready/deleted dates first, names second.
We considered testing export_data_growth_to_csv which calls write_body
We considered testing export_data_domain_growth_to_csv which calls write_body
and would have been easy to set up, but expected_content would contain created_at dates
which are hard to mock.
TODO: Simplify is created_at is not needed for the report."""
TODO: Simplify if created_at is not needed for the report."""
with less_console_noise():
# Create a CSV file in memory
csv_file = StringIO()
writer = csv.writer(csv_file)
# We use timezone.make_aware to sync to server time a datetime object with the current date
# (using date.today()) and a specific time (using datetime.min.time()).
end_date = timezone.make_aware(datetime.combine(date.today() + timedelta(days=2), datetime.min.time()))
start_date = timezone.make_aware(datetime.combine(date.today() - timedelta(days=2), datetime.min.time()))
# Define columns, sort fields, and filter condition
columns = [
"Domain name",
@ -579,19 +414,19 @@ class ExportDataTest(MockEppLib):
"domain__state__in": [
Domain.State.READY,
],
"domain__first_ready__lte": end_date,
"domain__first_ready__gte": start_date,
"domain__first_ready__lte": self.end_date,
"domain__first_ready__gte": self.start_date,
}
filter_conditions_for_deleted_domains = {
"domain__state__in": [
Domain.State.DELETED,
],
"domain__deleted__lte": end_date,
"domain__deleted__gte": start_date,
"domain__deleted__lte": self.end_date,
"domain__deleted__gte": self.start_date,
}
# Call the export functions
write_csv(
write_domains_csv(
writer,
columns,
sort_fields,
@ -599,7 +434,7 @@ class ExportDataTest(MockEppLib):
get_domain_managers=False,
should_write_header=True,
)
write_csv(
write_domains_csv(
writer,
columns,
sort_fields_for_deleted_domains,
@ -634,13 +469,13 @@ class ExportDataTest(MockEppLib):
def test_export_domains_to_writer_domain_managers(self):
"""Test that export_domains_to_writer returns the
expected domain managers"""
expected domain managers."""
with less_console_noise():
# Create a CSV file in memory
csv_file = StringIO()
writer = csv.writer(csv_file)
# Define columns, sort fields, and filter condition
columns = [
"Domain name",
"Status",
@ -664,7 +499,7 @@ class ExportDataTest(MockEppLib):
}
self.maxDiff = None
# Call the export functions
write_csv(
write_domains_csv(
writer, columns, sort_fields, filter_condition, get_domain_managers=True, should_write_header=True
)
@ -677,11 +512,11 @@ class ExportDataTest(MockEppLib):
expected_content = (
"Domain name,Status,Expiration date,Domain type,Agency,"
"Organization name,City,State,AO,AO email,"
"Security contact email,Domain manager email 1,Domain manager email 2,\n"
"Security contact email,Domain manager email 1,Domain manager email 2,Domain manager email 3\n"
"adomain10.gov,Ready,,Federal,Armed Forces Retirement Home,,,, , ,\n"
"adomain2.gov,Dns needed,,Interstate,,,,, , , ,meoward@rocks.com\n"
"cdomain1.gov,Ready,,Federal - Executive,World War I Centennial Commission,,,"
", , , ,meoward@rocks.com,info@example.com\n"
", , , ,meoward@rocks.com,info@example.com,big_lebowski@dude.co\n"
"ddomain3.gov,On hold,,Federal,Armed Forces Retirement Home,,,, , , ,,\n"
)
# Normalize line endings and remove commas,
@ -690,8 +525,132 @@ class ExportDataTest(MockEppLib):
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.assertEqual(csv_content, expected_content)
def test_export_data_managed_domains_to_csv(self):
"""Test get counts for domains that have domain managers for two different dates,
get list of managed domains at end_date."""
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."""
def test_get_default_start_date(self):
@ -704,3 +663,33 @@ class HelperFunctions(TestCase):
expected_date = timezone.now()
actual_date = get_default_end_date()
self.assertEqual(actual_date.date(), expected_date.date())
def test_get_sliced_domains(self):
"""Should get fitered domains counts sliced by org type and election office."""
with less_console_noise():
filter_condition = {
"domain__permissions__isnull": False,
"domain__first_ready__lte": self.end_date,
}
# Test with distinct
managed_domains_sliced_at_end_date = get_sliced_domains(filter_condition, True)
expected_content = [1, 1, 0, 0, 0, 0, 0, 0, 0, 1]
self.assertEqual(managed_domains_sliced_at_end_date, expected_content)
# Test without distinct
managed_domains_sliced_at_end_date = get_sliced_domains(filter_condition)
expected_content = [1, 3, 0, 0, 0, 0, 0, 0, 0, 1]
self.assertEqual(managed_domains_sliced_at_end_date, expected_content)
def test_get_sliced_requests(self):
"""Should get fitered requests counts sliced by org type and election office."""
with less_console_noise():
filter_condition = {
"status": DomainRequest.DomainRequestStatus.SUBMITTED,
"submission_date__lte": self.end_date,
}
submitted_requests_sliced_at_end_date = get_sliced_requests(filter_condition)
expected_content = [2, 2, 0, 0, 0, 0, 0, 0, 0, 0]
self.assertEqual(submitted_requests_sliced_at_end_date, expected_content)

View file

@ -1,7 +1,9 @@
from collections import Counter
import csv
import logging
from datetime import datetime
from registrar.models.domain import Domain
from registrar.models.domain_request import DomainRequest
from registrar.models.domain_information import DomainInformation
from django.utils import timezone
from django.core.paginator import Paginator
@ -19,16 +21,22 @@ def write_header(writer, columns):
Receives params from the parent methods and outputs a CSV with a header row.
Works with write_header as long as the same writer object is passed.
"""
writer.writerow(columns)
def get_domain_infos(filter_condition, sort_fields):
"""
Returns DomainInformation objects filtered and sorted based on the provided conditions.
filter_condition -> A dictionary of conditions to filter the objects.
sort_fields -> A list of fields to sort the resulting query set.
returns: A queryset of DomainInformation objects
"""
domain_infos = (
DomainInformation.objects.select_related("domain", "authorizing_official")
.prefetch_related("domain__permissions")
.filter(**filter_condition)
.order_by(*sort_fields)
.distinct()
)
# Do a mass concat of the first and last name fields for authorizing_official.
@ -45,7 +53,7 @@ def get_domain_infos(filter_condition, sort_fields):
return domain_infos_cleaned
def parse_row(columns, domain_info: DomainInformation, security_emails_dict=None, get_domain_managers=False):
def parse_domain_row(columns, domain_info: DomainInformation, security_emails_dict=None, get_domain_managers=False):
"""Given a set of columns, generate a new row from cleaned column data"""
# Domain should never be none when parsing this information
@ -129,7 +137,7 @@ def _get_security_emails(sec_contact_ids):
return security_emails_dict
def write_csv(
def write_domains_csv(
writer,
columns,
sort_fields,
@ -138,10 +146,10 @@ def write_csv(
should_write_header=True,
):
"""
Receives params from the parent methods and outputs a CSV with fltered and sorted domains.
Works with write_header as longas the same writer object is passed.
Receives params from the parent methods and outputs a CSV with filtered and sorted domains.
Works with write_header as long as the same writer object is passed.
get_domain_managers: Conditional bc we only use domain manager info for export_data_full_to_csv
should_write_header: Conditional bc export_data_growth_to_csv calls write_body twice
should_write_header: Conditional bc export_data_domain_growth_to_csv calls write_body twice
"""
all_domain_infos = get_domain_infos(filter_condition, sort_fields)
@ -175,7 +183,7 @@ def write_csv(
columns.append(column_name)
try:
row = parse_row(columns, domain_info, security_emails_dict, get_domain_managers)
row = parse_domain_row(columns, domain_info, security_emails_dict, get_domain_managers)
rows.append(row)
except ValueError:
# This should not happen. If it does, just skip this row.
@ -189,6 +197,82 @@ def write_csv(
writer.writerows(total_body_rows)
def get_requests(filter_condition, sort_fields):
"""
Returns DomainRequest objects filtered and sorted based on the provided conditions.
filter_condition -> A dictionary of conditions to filter the objects.
sort_fields -> A list of fields to sort the resulting query set.
returns: A queryset of DomainRequest objects
"""
requests = DomainRequest.objects.filter(**filter_condition).order_by(*sort_fields).distinct()
return requests
def parse_request_row(columns, request: DomainRequest):
"""Given a set of columns, generate a new row from cleaned column data"""
requested_domain_name = "No requested domain"
if request.requested_domain is not None:
requested_domain_name = request.requested_domain.name
if request.federal_type:
request_type = f"{request.get_organization_type_display()} - {request.get_federal_type_display()}"
else:
request_type = request.get_organization_type_display()
# create a dictionary of fields which can be included in output
FIELDS = {
"Requested domain": requested_domain_name,
"Status": request.get_status_display(),
"Organization type": request_type,
"Agency": request.federal_agency,
"Organization name": request.organization_name,
"City": request.city,
"State": request.state_territory,
"AO email": request.authorizing_official.email if request.authorizing_official else " ",
"Security contact email": request,
"Created at": request.created_at,
"Submission date": request.submission_date,
}
row = [FIELDS.get(column, "") for column in columns]
return row
def write_requests_csv(
writer,
columns,
sort_fields,
filter_condition,
should_write_header=True,
):
"""Receives params from the parent methods and outputs a CSV with filtered and sorted requests.
Works with write_header as long as the same writer object is passed."""
all_requests = get_requests(filter_condition, sort_fields)
# Reduce the memory overhead when performing the write operation
paginator = Paginator(all_requests, 1000)
for page_num in paginator.page_range:
page = paginator.page(page_num)
rows = []
for request in page.object_list:
try:
row = parse_request_row(columns, request)
rows.append(row)
except ValueError:
# This should not happen. If it does, just skip this row.
# It indicates that DomainInformation.domain is None.
logger.error("csv_export -> Error when parsing row, domain was None")
continue
if should_write_header:
write_header(writer, columns)
writer.writerows(rows)
def export_data_type_to_csv(csv_file):
"""All domains report with extra columns"""
@ -223,7 +307,9 @@ def export_data_type_to_csv(csv_file):
Domain.State.ON_HOLD,
],
}
write_csv(writer, columns, sort_fields, filter_condition, get_domain_managers=True, should_write_header=True)
write_domains_csv(
writer, columns, sort_fields, filter_condition, get_domain_managers=True, should_write_header=True
)
def export_data_full_to_csv(csv_file):
@ -254,7 +340,9 @@ def export_data_full_to_csv(csv_file):
Domain.State.ON_HOLD,
],
}
write_csv(writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True)
write_domains_csv(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
)
def export_data_federal_to_csv(csv_file):
@ -286,7 +374,9 @@ def export_data_federal_to_csv(csv_file):
Domain.State.ON_HOLD,
],
}
write_csv(writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True)
write_domains_csv(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
)
def get_default_start_date():
@ -299,7 +389,15 @@ def get_default_end_date():
return timezone.now()
def export_data_growth_to_csv(csv_file, start_date, end_date):
def format_start_date(start_date):
return timezone.make_aware(datetime.strptime(start_date, "%Y-%m-%d")) if start_date else get_default_start_date()
def format_end_date(end_date):
return timezone.make_aware(datetime.strptime(end_date, "%Y-%m-%d")) if end_date else get_default_end_date()
def export_data_domain_growth_to_csv(csv_file, start_date, end_date):
"""
Growth report:
Receive start and end dates from the view, parse them.
@ -308,16 +406,9 @@ def export_data_growth_to_csv(csv_file, start_date, end_date):
the start and end dates. Specify sort params for both lists.
"""
start_date_formatted = (
timezone.make_aware(datetime.strptime(start_date, "%Y-%m-%d")) if start_date else get_default_start_date()
)
end_date_formatted = (
timezone.make_aware(datetime.strptime(end_date, "%Y-%m-%d")) if end_date else get_default_end_date()
)
start_date_formatted = format_start_date(start_date)
end_date_formatted = format_end_date(end_date)
writer = csv.writer(csv_file)
# define columns to include in export
columns = [
"Domain name",
@ -353,8 +444,10 @@ def export_data_growth_to_csv(csv_file, start_date, end_date):
"domain__deleted__gte": start_date_formatted,
}
write_csv(writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True)
write_csv(
write_domains_csv(
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
)
write_domains_csv(
writer,
columns,
sort_fields_for_deleted_domains,
@ -362,3 +455,266 @@ def export_data_growth_to_csv(csv_file, start_date, end_date):
get_domain_managers=False,
should_write_header=False,
)
def get_sliced_domains(filter_condition, distinct=False):
"""Get filtered domains counts sliced by org type and election office.
Pass distinct=True when filtering by permissions so we do not to count multiples
when a domain has more that one manager.
"""
# Round trip 1: Get distinct domain names based on filter condition
domains_count = DomainInformation.objects.filter(**filter_condition).distinct().count()
# Round trip 2: Get counts for other slices
if distinct:
organization_types_query = (
DomainInformation.objects.filter(**filter_condition).values_list("organization_type", flat=True).distinct()
)
else:
organization_types_query = DomainInformation.objects.filter(**filter_condition).values_list(
"organization_type", flat=True
)
organization_type_counts = Counter(organization_types_query)
federal = organization_type_counts.get(DomainRequest.OrganizationChoices.FEDERAL, 0)
interstate = organization_type_counts.get(DomainRequest.OrganizationChoices.INTERSTATE, 0)
state_or_territory = organization_type_counts.get(DomainRequest.OrganizationChoices.STATE_OR_TERRITORY, 0)
tribal = organization_type_counts.get(DomainRequest.OrganizationChoices.TRIBAL, 0)
county = organization_type_counts.get(DomainRequest.OrganizationChoices.COUNTY, 0)
city = organization_type_counts.get(DomainRequest.OrganizationChoices.CITY, 0)
special_district = organization_type_counts.get(DomainRequest.OrganizationChoices.SPECIAL_DISTRICT, 0)
school_district = organization_type_counts.get(DomainRequest.OrganizationChoices.SCHOOL_DISTRICT, 0)
# Round trip 3
election_board = DomainInformation.objects.filter(is_election_board=True, **filter_condition).distinct().count()
return [
domains_count,
federal,
interstate,
state_or_territory,
tribal,
county,
city,
special_district,
school_district,
election_board,
]
def get_sliced_requests(filter_condition, distinct=False):
"""Get filtered requests counts sliced by org type and election office."""
# Round trip 1: Get distinct requests based on filter condition
requests_count = DomainRequest.objects.filter(**filter_condition).distinct().count()
# Round trip 2: Get counts for other slices
if distinct:
organization_types_query = (
DomainRequest.objects.filter(**filter_condition).values_list("organization_type", flat=True).distinct()
)
else:
organization_types_query = DomainRequest.objects.filter(**filter_condition).values_list(
"organization_type", flat=True
)
organization_type_counts = Counter(organization_types_query)
federal = organization_type_counts.get(DomainRequest.OrganizationChoices.FEDERAL, 0)
interstate = organization_type_counts.get(DomainRequest.OrganizationChoices.INTERSTATE, 0)
state_or_territory = organization_type_counts.get(DomainRequest.OrganizationChoices.STATE_OR_TERRITORY, 0)
tribal = organization_type_counts.get(DomainRequest.OrganizationChoices.TRIBAL, 0)
county = organization_type_counts.get(DomainRequest.OrganizationChoices.COUNTY, 0)
city = organization_type_counts.get(DomainRequest.OrganizationChoices.CITY, 0)
special_district = organization_type_counts.get(DomainRequest.OrganizationChoices.SPECIAL_DISTRICT, 0)
school_district = organization_type_counts.get(DomainRequest.OrganizationChoices.SCHOOL_DISTRICT, 0)
# Round trip 3
election_board = DomainRequest.objects.filter(is_election_board=True, **filter_condition).distinct().count()
return [
requests_count,
federal,
interstate,
state_or_territory,
tribal,
county,
city,
special_district,
school_district,
election_board,
]
def export_data_managed_domains_to_csv(csv_file, start_date, end_date):
"""Get counts for domains that have domain managers for two different dates,
get list of managed domains at end_date."""
start_date_formatted = format_start_date(start_date)
end_date_formatted = format_end_date(end_date)
writer = csv.writer(csv_file)
columns = [
"Domain name",
"Domain type",
]
sort_fields = [
"domain__name",
]
filter_managed_domains_start_date = {
"domain__permissions__isnull": False,
"domain__first_ready__lte": start_date_formatted,
}
managed_domains_sliced_at_start_date = get_sliced_domains(filter_managed_domains_start_date, True)
writer.writerow(["MANAGED DOMAINS COUNTS AT START DATE"])
writer.writerow(
[
"Total",
"Federal",
"Interstate",
"State or territory",
"Tribal",
"County",
"City",
"Special district",
"School district",
"Election office",
]
)
writer.writerow(managed_domains_sliced_at_start_date)
writer.writerow([])
filter_managed_domains_end_date = {
"domain__permissions__isnull": False,
"domain__first_ready__lte": end_date_formatted,
}
managed_domains_sliced_at_end_date = get_sliced_domains(filter_managed_domains_end_date, True)
writer.writerow(["MANAGED DOMAINS COUNTS AT END DATE"])
writer.writerow(
[
"Total",
"Federal",
"Interstate",
"State or territory",
"Tribal",
"County",
"City",
"Special district",
"School district",
"Election office",
]
)
writer.writerow(managed_domains_sliced_at_end_date)
writer.writerow([])
write_domains_csv(
writer,
columns,
sort_fields,
filter_managed_domains_end_date,
get_domain_managers=True,
should_write_header=True,
)
def export_data_unmanaged_domains_to_csv(csv_file, start_date, end_date):
"""Get counts for domains that do not have domain managers for two different dates,
get list of unmanaged domains at end_date."""
start_date_formatted = format_start_date(start_date)
end_date_formatted = format_end_date(end_date)
writer = csv.writer(csv_file)
columns = [
"Domain name",
"Domain type",
]
sort_fields = [
"domain__name",
]
filter_unmanaged_domains_start_date = {
"domain__permissions__isnull": True,
"domain__first_ready__lte": start_date_formatted,
}
unmanaged_domains_sliced_at_start_date = get_sliced_domains(filter_unmanaged_domains_start_date, True)
writer.writerow(["UNMANAGED DOMAINS AT START DATE"])
writer.writerow(
[
"Total",
"Federal",
"Interstate",
"State or territory",
"Tribal",
"County",
"City",
"Special district",
"School district",
"Election office",
]
)
writer.writerow(unmanaged_domains_sliced_at_start_date)
writer.writerow([])
filter_unmanaged_domains_end_date = {
"domain__permissions__isnull": True,
"domain__first_ready__lte": end_date_formatted,
}
unmanaged_domains_sliced_at_end_date = get_sliced_domains(filter_unmanaged_domains_end_date, True)
writer.writerow(["UNMANAGED DOMAINS AT END DATE"])
writer.writerow(
[
"Total",
"Federal",
"Interstate",
"State or territory",
"Tribal",
"County",
"City",
"Special district",
"School district",
"Election office",
]
)
writer.writerow(unmanaged_domains_sliced_at_end_date)
writer.writerow([])
write_domains_csv(
writer,
columns,
sort_fields,
filter_unmanaged_domains_end_date,
get_domain_managers=False,
should_write_header=True,
)
def export_data_requests_growth_to_csv(csv_file, start_date, end_date):
"""
Growth report:
Receive start and end dates from the view, parse them.
Request from write_requests_body SUBMITTED requests that are created between
the start and end dates. Specify sort params.
"""
start_date_formatted = format_start_date(start_date)
end_date_formatted = format_end_date(end_date)
writer = csv.writer(csv_file)
# define columns to include in export
columns = [
"Requested domain",
"Organization type",
"Submission date",
]
sort_fields = [
"requested_domain__name",
]
filter_condition = {
"status": DomainRequest.DomainRequestStatus.SUBMITTED,
"submission_date__lte": end_date_formatted,
"submission_date__gte": start_date_formatted,
}
write_requests_csv(writer, columns, sort_fields, filter_condition, should_write_header=True)

View file

@ -2,6 +2,12 @@
from django.http import HttpResponse
from django.views import View
from django.shortcuts import render
from django.contrib import admin
from django.db.models import Avg, F
from .. import models
import datetime
from django.utils import timezone
from registrar.utility import csv_export
@ -10,7 +16,157 @@ import logging
logger = logging.getLogger(__name__)
class ExportData(View):
class AnalyticsView(View):
def get(self, request):
thirty_days_ago = datetime.datetime.today() - datetime.timedelta(days=30)
thirty_days_ago = timezone.make_aware(thirty_days_ago)
last_30_days_applications = models.DomainRequest.objects.filter(created_at__gt=thirty_days_ago)
last_30_days_approved_applications = models.DomainRequest.objects.filter(
created_at__gt=thirty_days_ago, status=models.DomainRequest.DomainRequestStatus.APPROVED
)
avg_approval_time = last_30_days_approved_applications.annotate(
approval_time=F("approved_domain__created_at") - F("submission_date")
).aggregate(Avg("approval_time"))["approval_time__avg"]
# Format the timedelta to display only days
if avg_approval_time is not None:
avg_approval_time_display = f"{avg_approval_time.days} days"
else:
avg_approval_time_display = "No approvals to use"
# The start and end dates are passed as url params
start_date = request.GET.get("start_date", "")
end_date = request.GET.get("end_date", "")
start_date_formatted = csv_export.format_start_date(start_date)
end_date_formatted = csv_export.format_end_date(end_date)
filter_managed_domains_start_date = {
"domain__permissions__isnull": False,
"domain__first_ready__lte": start_date_formatted,
}
filter_managed_domains_end_date = {
"domain__permissions__isnull": False,
"domain__first_ready__lte": end_date_formatted,
}
managed_domains_sliced_at_start_date = csv_export.get_sliced_domains(filter_managed_domains_start_date, True)
managed_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_managed_domains_end_date, True)
filter_unmanaged_domains_start_date = {
"domain__permissions__isnull": True,
"domain__first_ready__lte": start_date_formatted,
}
filter_unmanaged_domains_end_date = {
"domain__permissions__isnull": True,
"domain__first_ready__lte": end_date_formatted,
}
unmanaged_domains_sliced_at_start_date = csv_export.get_sliced_domains(
filter_unmanaged_domains_start_date, True
)
unmanaged_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_unmanaged_domains_end_date, True)
filter_ready_domains_start_date = {
"domain__state__in": [models.Domain.State.READY],
"domain__first_ready__lte": start_date_formatted,
}
filter_ready_domains_end_date = {
"domain__state__in": [models.Domain.State.READY],
"domain__first_ready__lte": end_date_formatted,
}
ready_domains_sliced_at_start_date = csv_export.get_sliced_domains(filter_ready_domains_start_date)
ready_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_ready_domains_end_date)
filter_deleted_domains_start_date = {
"domain__state__in": [models.Domain.State.DELETED],
"domain__deleted__lte": start_date_formatted,
}
filter_deleted_domains_end_date = {
"domain__state__in": [models.Domain.State.DELETED],
"domain__deleted__lte": end_date_formatted,
}
deleted_domains_sliced_at_start_date = csv_export.get_sliced_domains(filter_deleted_domains_start_date)
deleted_domains_sliced_at_end_date = csv_export.get_sliced_domains(filter_deleted_domains_end_date)
filter_requests_start_date = {
"created_at__lte": start_date_formatted,
}
filter_requests_end_date = {
"created_at__lte": end_date_formatted,
}
requests_sliced_at_start_date = csv_export.get_sliced_requests(filter_requests_start_date)
requests_sliced_at_end_date = csv_export.get_sliced_requests(filter_requests_end_date)
filter_submitted_requests_start_date = {
"status": models.DomainRequest.DomainRequestStatus.SUBMITTED,
"submission_date__lte": start_date_formatted,
}
filter_submitted_requests_end_date = {
"status": models.DomainRequest.DomainRequestStatus.SUBMITTED,
"submission_date__lte": end_date_formatted,
}
submitted_requests_sliced_at_start_date = csv_export.get_sliced_requests(filter_submitted_requests_start_date)
submitted_requests_sliced_at_end_date = csv_export.get_sliced_requests(filter_submitted_requests_end_date)
context = dict(
# Generate a dictionary of context variables that are common across all admin templates
# (site_header, site_url, ...),
# include it in the larger context dictionary so it's available in the template rendering context.
# This ensures that the admin interface styling and behavior are consistent with other admin pages.
**admin.site.each_context(request),
data=dict(
user_count=models.User.objects.all().count(),
domain_count=models.Domain.objects.all().count(),
ready_domain_count=models.Domain.objects.filter(state=models.Domain.State.READY).count(),
last_30_days_applications=last_30_days_applications.count(),
last_30_days_approved_applications=last_30_days_approved_applications.count(),
average_application_approval_time_last_30_days=avg_approval_time_display,
managed_domains_sliced_at_start_date=managed_domains_sliced_at_start_date,
unmanaged_domains_sliced_at_start_date=unmanaged_domains_sliced_at_start_date,
managed_domains_sliced_at_end_date=managed_domains_sliced_at_end_date,
unmanaged_domains_sliced_at_end_date=unmanaged_domains_sliced_at_end_date,
ready_domains_sliced_at_start_date=ready_domains_sliced_at_start_date,
deleted_domains_sliced_at_start_date=deleted_domains_sliced_at_start_date,
ready_domains_sliced_at_end_date=ready_domains_sliced_at_end_date,
deleted_domains_sliced_at_end_date=deleted_domains_sliced_at_end_date,
requests_sliced_at_start_date=requests_sliced_at_start_date,
submitted_requests_sliced_at_start_date=submitted_requests_sliced_at_start_date,
requests_sliced_at_end_date=requests_sliced_at_end_date,
submitted_requests_sliced_at_end_date=submitted_requests_sliced_at_end_date,
start_date=start_date,
end_date=end_date,
),
)
return render(request, "admin/analytics.html", context)
class ExportDataType(View):
def get(self, request, *args, **kwargs):
# match the CSV example with all the fields
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="domains-by-type.csv"'
csv_export.export_data_type_to_csv(response)
return response
class ExportDataFull(View):
def get(self, request, *args, **kwargs):
# Smaller export based on 1
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="current-full.csv"'
csv_export.export_data_full_to_csv(response)
return response
class ExportDataFederal(View):
def get(self, request, *args, **kwargs):
# Federal only
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="current-federal.csv"'
csv_export.export_data_federal_to_csv(response)
return response
class ExportDataDomainsGrowth(View):
def get(self, request, *args, **kwargs):
# Get start_date and end_date from the request's GET parameters
# #999: not needed if we switch to django forms
@ -19,8 +175,50 @@ class ExportData(View):
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="domain-growth-report-{start_date}-to-{end_date}.csv"'
# For #999: set export_data_growth_to_csv to return the resulting queryset, which we can then use
# For #999: set export_data_domain_growth_to_csv to return the resulting queryset, which we can then use
# in context to display this data in the template.
csv_export.export_data_growth_to_csv(response, start_date, end_date)
csv_export.export_data_domain_growth_to_csv(response, start_date, end_date)
return response
class ExportDataRequestsGrowth(View):
def get(self, request, *args, **kwargs):
# Get start_date and end_date from the request's GET parameters
# #999: not needed if we switch to django forms
start_date = request.GET.get("start_date", "")
end_date = request.GET.get("end_date", "")
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="requests-{start_date}-to-{end_date}.csv"'
# For #999: set export_data_domain_growth_to_csv to return the resulting queryset, which we can then use
# in context to display this data in the template.
csv_export.export_data_requests_growth_to_csv(response, start_date, end_date)
return response
class ExportDataManagedDomains(View):
def get(self, request, *args, **kwargs):
# Get start_date and end_date from the request's GET parameters
# #999: not needed if we switch to django forms
start_date = request.GET.get("start_date", "")
end_date = request.GET.get("end_date", "")
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="managed-domains-{start_date}-to-{end_date}.csv"'
csv_export.export_data_managed_domains_to_csv(response, start_date, end_date)
return response
class ExportDataUnmanagedDomains(View):
def get(self, request, *args, **kwargs):
# Get start_date and end_date from the request's GET parameters
# #999: not needed if we switch to django forms
start_date = request.GET.get("start_date", "")
end_date = request.GET.get("end_date", "")
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="unamanaged-domains-{start_date}-to-{end_date}.csv"'
csv_export.export_data_unmanaged_domains_to_csv(response, start_date, end_date)
return response