Merge branch 'main' into dk/1359-security-email

This commit is contained in:
David Kennedy 2024-01-02 12:36:47 -05:00
commit ccd29579eb
No known key found for this signature in database
GPG key ID: 6528A5386E66B96B
19 changed files with 723 additions and 64 deletions

View file

@ -983,6 +983,10 @@ class DomainAdmin(ListHeaderAdmin):
"name",
"organization_type",
"state",
"expiration_date",
"created_at",
"first_ready",
"deleted",
]
# this ordering effects the ordering of results
@ -1001,7 +1005,7 @@ class DomainAdmin(ListHeaderAdmin):
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"]
readonly_fields = ["state", "expiration_date", "first_ready", "deleted"]
# Table ordering
ordering = ["name"]

View file

@ -275,3 +275,39 @@ function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk,
viewLink.setAttribute('href', viewLink.getAttribute('data-href-template').replace('__fk__', 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
var 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');
startDateInput.value = currentDate;
// Default the value of the end date input field to the current date
let endDateInput =document.getElementById('end');
endDateInput.value = currentDate;
let exportGrowthReportButton = document.getElementById('exportLink');
if (exportGrowthReportButton) {
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;
});
}
})();

View file

@ -136,10 +136,17 @@ html[data-theme="dark"] {
}
#branding h1,
h1, h2, h3 {
h1, h2, h3,
.module h2 {
font-weight: font-weight('bold');
}
.module h3 {
padding: 0;
color: var(--primary);
margin: units(2) 0 units(1) 0;
}
.change-list {
.usa-table--striped tbody tr:nth-child(odd) td,
.usa-table--striped tbody tr:nth-child(odd) th,

View file

@ -210,7 +210,6 @@ STATICFILES_DIRS = [
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "registrar" / "templates"],
# look for templates inside installed apps
# required by django-debug-toolbar
"APP_DIRS": True,

View file

@ -9,6 +9,10 @@ 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.application import Step
from registrar.views.utility import always_404
from api.views import available, get_current_federal, get_current_full
@ -49,6 +53,7 @@ urlpatterns = [
"admin/logout/",
RedirectView.as_view(pattern_name="logout", permanent=False),
),
path("export_data/", ExportData.as_view(), name="admin_export_data"),
path("admin/", admin.site.urls),
path(
"application/<id>/edit/",

View file

@ -577,12 +577,44 @@ class OtherContactsForm(RegistrarForm):
error_messages={"required": "Enter a phone number for this contact."},
)
def clean(self):
"""
This method overrides the default behavior for forms.
This cleans the form after field validation has already taken place.
In this override, allow for a form which is empty to be considered
valid even though certain required fields have not passed field
validation
"""
# Set form_is_empty to True initially
form_is_empty = True
for name, field in self.fields.items():
# get the value of the field from the widget
value = field.widget.value_from_datadict(self.data, self.files, self.add_prefix(name))
# if any field in the submitted form is not empty, set form_is_empty to False
if value is not None and value != "":
form_is_empty = False
if form_is_empty:
# clear any errors raised by the form fields
# (before this clean() method is run, each field
# performs its own clean, which could result in
# errors that we wish to ignore at this point)
#
# NOTE: we cannot just clear() the errors list.
# That causes problems.
for field in self.fields:
if field in self.errors:
del self.errors[field]
return self.cleaned_data
class BaseOtherContactsFormSet(RegistrarFormSet):
JOIN = "other_contacts"
def should_delete(self, cleaned):
empty = (isinstance(v, str) and not v.strip() for v in cleaned.values())
empty = (isinstance(v, str) and (v.strip() == "" or v is None) for v in cleaned.values())
return all(empty)
def to_database(self, obj: DomainApplication):

View file

@ -0,0 +1,24 @@
# Generated by Django 4.2.7 on 2023-12-29 22:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0059_delete_nameserver"),
]
operations = [
migrations.AddField(
model_name="domain",
name="deleted",
field=models.DateField(editable=False, help_text="Deleted at date", null=True),
),
migrations.AddField(
model_name="domain",
name="first_ready",
field=models.DateField(
editable=False, help_text="The last time this domain moved into the READY state", null=True
),
),
]

View file

@ -980,6 +980,18 @@ class Domain(TimeStampedModel, DomainHelper):
editable=False,
)
deleted = DateField(
null=True,
editable=False,
help_text="Deleted at date",
)
first_ready = DateField(
null=True,
editable=False,
help_text="The last time this domain moved into the READY state",
)
def isActive(self):
return self.state == Domain.State.CREATED
@ -1315,6 +1327,7 @@ class Domain(TimeStampedModel, DomainHelper):
try:
logger.info("deletedInEpp()-> inside _delete_domain")
self._delete_domain()
self.deleted = timezone.now()
except RegistryError as err:
logger.error(f"Could not delete domain. Registry returned error: {err}")
raise err
@ -1358,6 +1371,11 @@ class Domain(TimeStampedModel, DomainHelper):
"""
logger.info("Changing to ready state")
logger.info("able to transition to ready state")
# if self.first_ready is not None, this means that this
# domain was READY, then not READY, then is READY again.
# We do not want to overwrite first_ready.
if self.first_ready is None:
self.first_ready = timezone.now()
@transition(
field="state",

View file

@ -745,6 +745,7 @@ class DomainApplication(TimeStampedModel):
# Only reject if it exists on EPP
if domain_state != Domain.State.UNKNOWN:
self.approved_domain.deletedInEpp()
self.approved_domain.save()
self.approved_domain.delete()
self.approved_domain = None
except Exception as err:
@ -783,6 +784,7 @@ class DomainApplication(TimeStampedModel):
# Only reject if it exists on EPP
if domain_state != Domain.State.UNKNOWN:
self.approved_domain.deletedInEpp()
self.approved_domain.save()
self.approved_domain.delete()
self.approved_domain = None
except Exception as err:

View file

@ -0,0 +1,33 @@
{% 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

@ -1,3 +1,3 @@
Domain name,Domain type,Agency,Organization name,City,State,Security Contact Email
Domain name,Domain type,Agency,Organization name,City,State,Security contact email
cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,
ddomain3.gov,Federal,Armed Forces Retirement Home,,,,
1 Domain name Domain type Agency Organization name City State Security Contact Email Security contact email
2 cdomain1.gov Federal - Executive World War I Centennial Commission
3 ddomain3.gov Federal Armed Forces Retirement Home

View file

@ -1,4 +1,4 @@
Domain name,Domain type,Agency,Organization name,City,State,Security Contact Email
Domain name,Domain type,Agency,Organization name,City,State,Security contact email
cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,
ddomain3.gov,Federal,Armed Forces Retirement Home,,,,
adomain2.gov,Interstate,,,,,
1 Domain name Domain type Agency Organization name City State Security Contact Email Security contact email
2 cdomain1.gov Federal - Executive World War I Centennial Commission
3 ddomain3.gov Federal Armed Forces Retirement Home
4 adomain2.gov Interstate

View file

@ -0,0 +1,42 @@
from django.test import TestCase, Client
from django.urls import reverse
from registrar.tests.common import create_superuser
class TestViews(TestCase):
def setUp(self):
self.client = Client(HTTP_HOST="localhost:8080")
self.superuser = create_superuser()
def test_export_data_view(self):
self.client.force_login(self.superuser)
# Reverse the URL for the admin index page
admin_index_url = reverse("admin:index")
# Make a GET request to the admin index page
response = self.client.get(admin_index_url)
# Assert that the response status code is 200 (OK)
self.assertEqual(response.status_code, 200)
# Ensure that the start_date and end_date are set
start_date = "2023-01-01"
end_date = "2023-12-31"
# 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}"
# Make a GET request to the export data page
response = self.client.get(export_data_url)
# Assert that the response status code is 200 (OK) or the expected status code
self.assertEqual(response.status_code, 200)
# Assert that the content type is CSV
self.assertEqual(response["Content-Type"], "text/csv")
# Check if the filename in the Content-Disposition header matches the expected pattern
expected_filename = f"domain-growth-report-{start_date}-to-{end_date}.csv"
self.assertIn(f'attachment; filename="{expected_filename}"', response["Content-Disposition"])

View file

@ -211,7 +211,7 @@ class TestFormValidation(MockEppLib):
def test_other_contact_email_invalid(self):
"""must be a valid email address."""
form = OtherContactsForm(data={"email": "boss@boss"})
form = OtherContactsForm(data={"email": "splendid@boss"})
self.assertEqual(
form.errors["email"],
["Enter an email address in the required format, like name@example.com."],
@ -219,7 +219,7 @@ class TestFormValidation(MockEppLib):
def test_other_contact_phone_invalid(self):
"""Must be a valid phone number."""
form = OtherContactsForm(data={"phone": "boss@boss"})
form = OtherContactsForm(data={"phone": "super@boss"})
self.assertTrue(form.errors["phone"][0].startswith("Enter a valid phone number "))
def test_requirements_form_blank(self):

View file

@ -387,6 +387,34 @@ class TestDomainStatuses(MockEppLib):
"""Domain 'revert_client_hold' method causes the registry to change statuses"""
raise
def test_first_ready(self):
"""
first_ready is set when a domain is first transitioned to READY. It does not get overwritten
in case the domain gets out of and back into READY.
"""
domain, _ = Domain.objects.get_or_create(name="pig-knuckles.gov", state=Domain.State.DNS_NEEDED)
self.assertEqual(domain.first_ready, None)
domain.ready()
# check that status is READY
self.assertTrue(domain.is_active())
self.assertNotEqual(domain.first_ready, None)
# Capture the value of first_ready
first_ready = domain.first_ready
# change domain status
domain.dns_needed()
self.assertFalse(domain.is_active())
# change back to READY
domain.ready()
self.assertTrue(domain.is_active())
# assert that the value of first_ready has not changed
self.assertEqual(domain.first_ready, first_ready)
def tearDown(self) -> None:
PublicContact.objects.all().delete()
Domain.objects.all().delete()
@ -1157,6 +1185,7 @@ class TestRegistrantNameservers(MockEppLib):
Then `commands.CreateHost` and `commands.UpdateDomain` is sent
to the registry
And `domain.is_active` returns False
And domain.first_ready is null
"""
# set 1 nameserver
@ -1183,6 +1212,8 @@ class TestRegistrantNameservers(MockEppLib):
# as you have less than 2 nameservers
self.assertFalse(self.domain.is_active())
self.assertEqual(self.domain.first_ready, None)
def test_user_adds_two_nameservers(self):
"""
Scenario: Registrant adds 2 or more nameservers, thereby activating the domain
@ -1191,6 +1222,7 @@ class TestRegistrantNameservers(MockEppLib):
Then `commands.CreateHost` and `commands.UpdateDomain` is sent
to the registry
And `domain.is_active` returns True
And domain.first_ready is not null
"""
# set 2 nameservers
@ -1221,6 +1253,7 @@ class TestRegistrantNameservers(MockEppLib):
self.assertEqual(4, self.mockedSendFunction.call_count)
# check that status is READY
self.assertTrue(self.domain.is_active())
self.assertNotEqual(self.domain.first_ready, None)
def test_user_adds_too_many_nameservers(self):
"""
@ -2401,11 +2434,14 @@ class TestAnalystDelete(MockEppLib):
When `domain.deletedInEpp()` is called
Then `commands.DeleteDomain` is sent to the registry
And `state` is set to `DELETED`
The deleted date is set.
"""
# Put the domain in client hold
self.domain.place_client_hold()
# Delete it...
self.domain.deletedInEpp()
self.domain.save()
self.mockedSendFunction.assert_has_calls(
[
call(
@ -2421,6 +2457,9 @@ class TestAnalystDelete(MockEppLib):
# Domain should have the right state
self.assertEqual(self.domain.state, Domain.State.DELETED)
# Domain should have a deleted
self.assertNotEqual(self.domain.deleted, None)
# Cache should be invalidated
self.assertEqual(self.domain._cache, {})
@ -2439,6 +2478,7 @@ class TestAnalystDelete(MockEppLib):
# Delete it
with self.assertRaises(RegistryError) as err:
domain.deletedInEpp()
domain.save()
self.assertTrue(err.is_client_error() and err.code == ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION)
self.mockedSendFunction.assert_has_calls(
[
@ -2462,12 +2502,18 @@ class TestAnalystDelete(MockEppLib):
and domain is of `state` is `READY`
Then an FSM error is returned
And `state` is not set to `DELETED`
The deleted date is still null.
"""
self.assertEqual(self.domain.state, Domain.State.READY)
with self.assertRaises(TransitionNotAllowed) as err:
self.domain.deletedInEpp()
self.domain.save()
self.assertTrue(err.is_client_error() and err.code == ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION)
# Domain should not be deleted
self.assertNotEqual(self.domain, None)
# Domain should have the right state
self.assertEqual(self.domain.state, Domain.State.READY)
# deleted should be null
self.assertEqual(self.domain.deleted, None)

View file

@ -6,7 +6,12 @@ from registrar.models.domain_information import DomainInformation
from registrar.models.domain import Domain
from registrar.models.user import User
from django.contrib.auth import get_user_model
from registrar.utility.csv_export import export_domains_to_writer
from registrar.utility.csv_export import (
write_header,
write_body,
get_default_start_date,
get_default_end_date,
)
from django.core.management import call_command
from unittest.mock import MagicMock, call, mock_open, patch
from api.views import get_current_federal, get_current_full
@ -14,6 +19,8 @@ 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 django.utils import timezone
class CsvReportsTest(TestCase):
@ -35,7 +42,6 @@ class CsvReportsTest(TestCase):
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_information_1, _ = DomainInformation.objects.get_or_create(
creator=self.user,
@ -75,7 +81,7 @@ class CsvReportsTest(TestCase):
mock_client = MagicMock()
fake_open = mock_open()
expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security Contact Email\r\n"),
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"),
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,, \r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,, \r\n"),
]
@ -94,7 +100,7 @@ class CsvReportsTest(TestCase):
mock_client = MagicMock()
fake_open = mock_open()
expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security Contact Email\r\n"),
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"),
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,, \r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,, \r\n"),
call("adomain2.gov,Interstate,,,,, \r\n"),
@ -174,7 +180,7 @@ class CsvReportsTest(TestCase):
# Check that the response contains what we expect
expected_file_content = (
"Domain name,Domain type,Agency,Organization name,City,State,Security Contact Email\n"
"Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n"
"cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,\n"
"ddomain3.gov,Federal,Armed Forces Retirement Home,,,,"
).encode()
@ -206,7 +212,7 @@ class CsvReportsTest(TestCase):
# Check that the response contains what we expect
expected_file_content = (
"Domain name,Domain type,Agency,Organization name,City,State,Security Contact Email\n"
"Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n"
"cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,\n"
"ddomain3.gov,Federal,Armed Forces Retirement Home,,,,\n"
"adomain2.gov,Interstate,,,,,"
@ -225,11 +231,39 @@ class ExportDataTest(TestCase):
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_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,
@ -255,6 +289,42 @@ class ExportDataTest(TestCase):
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",
)
def tearDown(self):
Domain.objects.all().delete()
@ -262,8 +332,8 @@ class ExportDataTest(TestCase):
User.objects.all().delete()
super().tearDown()
def test_export_domains_to_writer(self):
"""Test that export_domains_to_writer returns the
def test_write_body(self):
"""Test that write_body returns the
existing domain, test that sort by domain name works,
test that filter works"""
# Create a CSV file in memory
@ -284,7 +354,7 @@ class ExportDataTest(TestCase):
"Submitter title",
"Submitter email",
"Submitter phone",
"Security Contact Email",
"Security contact email",
"Status",
]
sort_fields = ["domain__name"]
@ -296,8 +366,9 @@ class ExportDataTest(TestCase):
],
}
# Call the export function
export_domains_to_writer(writer, columns, sort_fields, filter_condition)
# Call the export functions
write_header(writer, columns)
write_body(writer, columns, sort_fields, filter_condition)
# Reset the CSV file's position to the beginning
csv_file.seek(0)
@ -310,10 +381,11 @@ class ExportDataTest(TestCase):
expected_content = (
"Domain name,Domain type,Agency,Organization name,City,State,AO,"
"AO email,Submitter,Submitter title,Submitter email,Submitter phone,"
"Security Contact Email,Status\n"
"adomain2.gov,Interstate,dnsneeded\n"
"cdomain1.gov,Federal - Executive,World War I Centennial Commission,ready\n"
"ddomain3.gov,Federal,Armed Forces Retirement Home,onhold\n"
"Security contact email,Status\n"
"adomain10.gov,Federal,Armed Forces Retirement Home,Ready\n"
"adomain2.gov,Interstate,Dns needed\n"
"cdomain1.gov,Federal - Executive,World War I Centennial Commission,Ready\n"
"ddomain3.gov,Federal,Armed Forces Retirement Home,On hold\n"
)
# Normalize line endings and remove commas,
@ -323,7 +395,7 @@ class ExportDataTest(TestCase):
self.assertEqual(csv_content, expected_content)
def test_export_domains_to_writer_additional(self):
def test_write_body_additional(self):
"""An additional test for filters and multi-column sort"""
# Create a CSV file in memory
csv_file = StringIO()
@ -337,7 +409,7 @@ class ExportDataTest(TestCase):
"Organization name",
"City",
"State",
"Security Contact Email",
"Security contact email",
]
sort_fields = ["domain__name", "federal_agency", "organization_type"]
filter_condition = {
@ -349,8 +421,9 @@ class ExportDataTest(TestCase):
],
}
# Call the export function
export_domains_to_writer(writer, columns, sort_fields, filter_condition)
# Call the export functions
write_header(writer, columns)
write_body(writer, columns, sort_fields, filter_condition)
# Reset the CSV file's position to the beginning
csv_file.seek(0)
@ -363,7 +436,8 @@ class ExportDataTest(TestCase):
# sorted alphabetially by domain name
expected_content = (
"Domain name,Domain type,Agency,Organization name,City,"
"State,Security Contact Email\n"
"State,Security contact email\n"
"adomain10.gov,Federal,Armed Forces Retirement Home\n"
"cdomain1.gov,Federal - Executive,World War I Centennial Commission\n"
"ddomain3.gov,Federal,Armed Forces Retirement Home\n"
)
@ -374,3 +448,113 @@ class ExportDataTest(TestCase):
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):
"""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
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."""
# 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",
"Domain type",
"Agency",
"Organization name",
"City",
"State",
"Status",
"Expiration date",
]
sort_fields = [
"created_at",
"domain__name",
]
sort_fields_for_deleted_domains = [
"domain__deleted",
"domain__name",
]
filter_condition = {
"domain__state__in": [
Domain.State.READY,
],
"domain__first_ready__lte": end_date,
"domain__first_ready__gte": start_date,
}
filter_conditions_for_deleted_domains = {
"domain__state__in": [
Domain.State.DELETED,
],
"domain__deleted__lte": end_date,
"domain__deleted__gte": start_date,
}
# Call the export functions
write_header(writer, columns)
write_body(
writer,
columns,
sort_fields,
filter_condition,
)
write_body(
writer,
columns,
sort_fields_for_deleted_domains,
filter_conditions_for_deleted_domains,
)
# 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 = (
"Domain name,Domain type,Agency,Organization name,City,"
"State,Status,Expiration date\n"
"cdomain1.gov,Federal-Executive,World War I Centennial Commission,,,,Ready,\n"
"adomain10.gov,Federal,Armed Forces Retirement Home,,,,Ready,\n"
"zdomain9.gov,Federal,Armed Forces Retirement Home,,,,Deleted,\n"
"sdomain8.gov,Federal,Armed Forces Retirement Home,,,,Deleted,\n"
"xdomain7.gov,Federal,Armed Forces Retirement Home,,,,Deleted,\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(TestCase):
"""This asserts that 1=1. Its limited usefulness lies in making sure the helper methods stay healthy."""
def test_get_default_start_date(self):
expected_date = timezone.make_aware(datetime(2023, 11, 1))
actual_date = get_default_start_date()
self.assertEqual(actual_date, expected_date)
def test_get_default_end_date(self):
# Note: You may need to mock timezone.now() for accurate testing
expected_date = timezone.now()
actual_date = get_default_end_date()
self.assertEqual(actual_date.date(), expected_date.date())

View file

@ -727,6 +727,92 @@ class DomainApplicationTests(TestWithUser, WebTest):
actual_url_slug = no_contacts_page.request.path.split("/")[-2]
self.assertEqual(expected_url_slug, actual_url_slug)
def test_application_delete_other_contact(self):
"""Other contacts can be deleted after being saved to database."""
# Populate the databse with a domain application that
# has 1 "other contact" assigned to it
ao, _ = Contact.objects.get_or_create(
first_name="Testy",
last_name="Tester",
title="Chief Tester",
email="testy@town.com",
phone="(555) 555 5555",
)
you, _ = Contact.objects.get_or_create(
first_name="Testy you",
last_name="Tester you",
title="Admin Tester",
email="testy-admin@town.com",
phone="(555) 555 5556",
)
other, _ = Contact.objects.get_or_create(
first_name="Testy2",
last_name="Tester2",
title="Another Tester",
email="testy2@town.com",
phone="(555) 555 5557",
)
application, _ = DomainApplication.objects.get_or_create(
organization_type="federal",
federal_type="executive",
purpose="Purpose of the site",
anything_else="No",
is_policy_acknowledged=True,
organization_name="Testorg",
address_line1="address 1",
state_territory="NY",
zipcode="10002",
authorizing_official=ao,
submitter=you,
creator=self.user,
status="started",
)
application.other_contacts.add(other)
# prime the form by visiting /edit
self.app.get(reverse("edit-application", kwargs={"id": application.pk}))
# django-webtest does not handle cookie-based sessions well because it keeps
# resetting the session key on each new request, thus destroying the concept
# of a "session". We are going to do it manually, saving the session ID here
# and then setting the cookie on each request.
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
other_contacts_page = self.app.get(reverse("application:other_contacts"))
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
other_contacts_form = other_contacts_page.forms[0]
# Minimal check to ensure the form is loaded with data (if this part of
# the application doesn't work, we should be equipped with other unit
# tests to flag it)
self.assertEqual(other_contacts_form["other_contacts-0-first_name"].value, "Testy2")
# clear the form
other_contacts_form["other_contacts-0-first_name"] = ""
other_contacts_form["other_contacts-0-middle_name"] = ""
other_contacts_form["other_contacts-0-last_name"] = ""
other_contacts_form["other_contacts-0-title"] = ""
other_contacts_form["other_contacts-0-email"] = ""
other_contacts_form["other_contacts-0-phone"] = ""
# Submit the now empty form
result = other_contacts_form.submit()
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Verify that the contact we saved earlier has been removed from the database
application = DomainApplication.objects.get() # There are no contacts anymore
self.assertEqual(
application.other_contacts.count(),
0,
)
# Verify that on submit, user is advanced to "no contacts" page
no_contacts_page = result.follow()
expected_url_slug = str(Step.NO_OTHER_CONTACTS)
actual_url_slug = no_contacts_page.request.path.split("/")[-2]
self.assertEqual(expected_url_slug, actual_url_slug)
def test_application_about_your_organiztion_interstate(self):
"""Special districts have to answer an additional question."""
type_page = self.app.get(reverse("application:")).follow()

View file

@ -1,44 +1,83 @@
import csv
import logging
from datetime import datetime
from registrar.models.domain import Domain
from registrar.models.domain_information import DomainInformation
from registrar.models.public_contact import PublicContact
from django.db.models import Value
from django.db.models.functions import Coalesce
from django.utils import timezone
logger = logging.getLogger(__name__)
def export_domains_to_writer(writer, columns, sort_fields, filter_condition):
# write columns headers to writer
def write_header(writer, columns):
"""
Receives params from the parent methods and outputs a CSV with a header row.
Works with write_header as longas the same writer object is passed.
"""
writer.writerow(columns)
domainInfos = DomainInformation.objects.filter(**filter_condition).order_by(*sort_fields)
for domainInfo in domainInfos:
security_contacts = domainInfo.domain.contacts.filter(contact_type=PublicContact.ContactTypeChoices.SECURITY)
# For linter
ao = " "
if domainInfo.authorizing_official:
first_name = domainInfo.authorizing_official.first_name or ""
last_name = domainInfo.authorizing_official.last_name or ""
ao = first_name + " " + last_name
# create a dictionary of fields which can be included in output
FIELDS = {
"Domain name": domainInfo.domain.name,
"Domain type": domainInfo.get_organization_type_display() + " - " + domainInfo.get_federal_type_display()
if domainInfo.federal_type
else domainInfo.get_organization_type_display(),
"Agency": domainInfo.federal_agency,
"Organization name": domainInfo.organization_name,
"City": domainInfo.city,
"State": domainInfo.state_territory,
"AO": ao,
"AO email": domainInfo.authorizing_official.email if domainInfo.authorizing_official else " ",
"Security Contact Email": security_contacts[0].email if security_contacts else " ",
"Status": domainInfo.domain.state,
"Expiration Date": domainInfo.domain.expiration_date,
}
writer.writerow([FIELDS.get(column, "") for column in columns])
def get_domain_infos(filter_condition, sort_fields):
domain_infos = DomainInformation.objects.filter(**filter_condition).order_by(*sort_fields)
return domain_infos
def write_row(writer, columns, domain_info):
security_contacts = domain_info.domain.contacts.filter(contact_type=PublicContact.ContactTypeChoices.SECURITY)
# For linter
ao = " "
if domain_info.authorizing_official:
first_name = domain_info.authorizing_official.first_name or ""
last_name = domain_info.authorizing_official.last_name or ""
ao = first_name + " " + last_name
# create a dictionary of fields which can be included in output
FIELDS = {
"Domain name": domain_info.domain.name,
"Domain type": domain_info.get_organization_type_display() + " - " + domain_info.get_federal_type_display()
if domain_info.federal_type
else domain_info.get_organization_type_display(),
"Agency": domain_info.federal_agency,
"Organization name": domain_info.organization_name,
"City": domain_info.city,
"State": domain_info.state_territory,
"AO": ao,
"AO email": domain_info.authorizing_official.email if domain_info.authorizing_official else " ",
"Security contact email": security_contacts[0].email if security_contacts else " ",
"Status": domain_info.domain.get_state_display(),
"Expiration date": domain_info.domain.expiration_date,
"Created at": domain_info.domain.created_at,
"First ready": domain_info.domain.first_ready,
"Deleted": domain_info.domain.deleted,
}
writer.writerow([FIELDS.get(column, "") for column in columns])
def write_body(
writer,
columns,
sort_fields,
filter_condition,
):
"""
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.
"""
# Get the domainInfos
domain_infos = get_domain_infos(filter_condition, sort_fields)
all_domain_infos = list(domain_infos)
# Write rows to CSV
for domain_info in all_domain_infos:
write_row(writer, columns, domain_info)
def export_data_type_to_csv(csv_file):
"""All domains report with extra columns"""
writer = csv.writer(csv_file)
# define columns to include in export
columns = [
@ -50,9 +89,9 @@ def export_data_type_to_csv(csv_file):
"State",
"AO",
"AO email",
"Security Contact Email",
"Security contact email",
"Status",
"Expiration Date",
"Expiration date",
]
# Coalesce is used to replace federal_type of None with ZZZZZ
sort_fields = [
@ -68,10 +107,13 @@ def export_data_type_to_csv(csv_file):
Domain.State.ON_HOLD,
],
}
export_domains_to_writer(writer, columns, sort_fields, filter_condition)
write_header(writer, columns)
write_body(writer, columns, sort_fields, filter_condition)
def export_data_full_to_csv(csv_file):
"""All domains report"""
writer = csv.writer(csv_file)
# define columns to include in export
columns = [
@ -81,7 +123,7 @@ def export_data_full_to_csv(csv_file):
"Organization name",
"City",
"State",
"Security Contact Email",
"Security contact email",
]
# Coalesce is used to replace federal_type of None with ZZZZZ
sort_fields = [
@ -97,10 +139,13 @@ def export_data_full_to_csv(csv_file):
Domain.State.ON_HOLD,
],
}
export_domains_to_writer(writer, columns, sort_fields, filter_condition)
write_header(writer, columns)
write_body(writer, columns, sort_fields, filter_condition)
def export_data_federal_to_csv(csv_file):
"""Federal domains report"""
writer = csv.writer(csv_file)
# define columns to include in export
columns = [
@ -110,7 +155,7 @@ def export_data_federal_to_csv(csv_file):
"Organization name",
"City",
"State",
"Security Contact Email",
"Security contact email",
]
# Coalesce is used to replace federal_type of None with ZZZZZ
sort_fields = [
@ -127,4 +172,74 @@ def export_data_federal_to_csv(csv_file):
Domain.State.ON_HOLD,
],
}
export_domains_to_writer(writer, columns, sort_fields, filter_condition)
write_header(writer, columns)
write_body(writer, columns, sort_fields, filter_condition)
def get_default_start_date():
# Default to a date that's prior to our first deployment
return timezone.make_aware(datetime(2023, 11, 1))
def get_default_end_date():
# Default to now()
return timezone.now()
def export_data_growth_to_csv(csv_file, start_date, end_date):
"""
Growth report:
Receive start and end dates from the view, parse them.
Request from write_body READY domains that are created between
the start and end dates, as well as DELETED domains that are deleted between
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()
)
writer = csv.writer(csv_file)
# define columns to include in export
columns = [
"Domain name",
"Domain type",
"Agency",
"Organization name",
"City",
"State",
"Status",
"Expiration date",
"Created at",
"First ready",
"Deleted",
]
sort_fields = [
"domain__first_ready",
"domain__name",
]
filter_condition = {
"domain__state__in": [Domain.State.READY],
"domain__first_ready__lte": end_date_formatted,
"domain__first_ready__gte": start_date_formatted,
}
# We also want domains deleted between sar and end dates, sorted
sort_fields_for_deleted_domains = [
"domain__deleted",
"domain__name",
]
filter_condition_for_deleted_domains = {
"domain__state__in": [Domain.State.DELETED],
"domain__deleted__lte": end_date_formatted,
"domain__deleted__gte": start_date_formatted,
}
write_header(writer, columns)
write_body(writer, columns, sort_fields, filter_condition)
write_body(writer, columns, sort_fields_for_deleted_domains, filter_condition_for_deleted_domains)

View file

@ -0,0 +1,26 @@
"""Admin-related views."""
from django.http import HttpResponse
from django.views import View
from registrar.utility import csv_export
import logging
logger = logging.getLogger(__name__)
class ExportData(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="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
# in context to display this data in the template.
csv_export.export_data_growth_to_csv(response, start_date, end_date)
return response