Merge branch 'main' into dk/1485-application-intro

This commit is contained in:
David Kennedy 2024-01-04 17:48:46 -05:00
commit 196e65dd7b
No known key found for this signature in database
GPG key ID: 6528A5386E66B96B
27 changed files with 1297 additions and 367 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,
@ -520,7 +519,7 @@ LOGIN_REQUIRED_IGNORE_PATHS = [
]
# where to go after logging out
LOGOUT_REDIRECT_URL = "home"
LOGOUT_REDIRECT_URL = "https://get.gov/"
# disable dynamic client registration,
# only the OP inside OIDC_PROVIDERS will be available

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

@ -218,5 +218,8 @@ class DomainFixture(DomainApplicationFixture):
creator=user, status=DomainApplication.ApplicationStatus.IN_REVIEW
).last()
logger.debug(f"Approving {application} for {user}")
application.approve()
# We don't want fixtures sending out real emails to
# fake email addresses, so we just skip that and log it instead
application.approve(send_email=False)
application.save()

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

@ -0,0 +1,21 @@
# Generated by Django 4.2.7 on 2023-12-23 01:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0060_domain_deleted_domain_first_ready"),
]
operations = [
migrations.AddField(
model_name="domain",
name="security_contact_registry_id",
field=models.TextField(
editable=False,
help_text="Duplication of registry's security contact id for when registry unavailable",
null=True,
),
),
]

View file

@ -31,7 +31,7 @@ from epplibwrapper import (
from registrar.models.utility.contact_error import ContactError, ContactErrorCodes
from django.db.models import DateField
from django.db.models import DateField, TextField
from .utility.domain_field import DomainField
from .utility.domain_helper import DomainHelper
from .utility.time_stamped_model import TimeStampedModel
@ -974,6 +974,24 @@ class Domain(TimeStampedModel, DomainHelper):
help_text=("Duplication of registry's expiration date saved for ease of reporting"),
)
security_contact_registry_id = TextField(
null=True,
help_text=("Duplication of registry's security contact id for when registry unavailable"),
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
@ -1115,15 +1133,21 @@ class Domain(TimeStampedModel, DomainHelper):
# Grab from cache
contacts = self._get_property(desired_property)
except KeyError as error:
logger.error(f"Could not find {contact_type_choice}: {error}")
return None
else:
cached_contact = self.get_contact_in_keys(contacts, contact_type_choice)
if cached_contact is None:
# TODO - #1103
raise ContactError("No contact was found in cache or the registry")
# if contact type is security, attempt to retrieve registry id
# for the security contact from domain.security_contact_registry_id
if contact_type_choice == PublicContact.ContactTypeChoices.SECURITY and self.security_contact_registry_id:
logger.info(f"Could not access registry, using fallback value of {self.security_contact_registry_id}")
contacts = {PublicContact.ContactTypeChoices.SECURITY: self.security_contact_registry_id}
else:
logger.error(f"Could not find {contact_type_choice}: {error}")
return None
return cached_contact
cached_contact = self.get_contact_in_keys(contacts, contact_type_choice)
if cached_contact is None:
# TODO - #1103
raise ContactError("No contact was found in cache or the registry")
return cached_contact
def get_default_security_contact(self):
"""Gets the default security contact."""
@ -1303,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
@ -1346,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",
@ -1612,6 +1642,8 @@ class Domain(TimeStampedModel, DomainHelper):
self._update_hosts_and_contacts(cleaned, fetch_hosts, fetch_contacts)
if fetch_hosts:
self._update_hosts_and_ips_in_db(cleaned)
if fetch_contacts:
self._update_security_contact_in_db(cleaned)
self._update_dates(cleaned)
self._cache = cleaned
@ -1721,6 +1753,23 @@ class Domain(TimeStampedModel, DomainHelper):
for ip_address in cleaned_ips:
HostIP.objects.get_or_create(address=ip_address, host=host_in_db)
def _update_security_contact_in_db(self, cleaned):
"""Update security contact registry id in database if retrieved from registry.
If no value is retrieved from registry, set to empty string in db.
Parameters:
self: the domain to be updated with security from cleaned
cleaned: dict containing contact registry ids. Security contact is of type
PublicContact.ContactTypeChoices.SECURITY
"""
cleaned_contacts = cleaned["contacts"]
security_contact_registry_id = ""
security_contact = cleaned_contacts[PublicContact.ContactTypeChoices.SECURITY]
if security_contact:
security_contact_registry_id = security_contact
self.security_contact_registry_id = security_contact_registry_id
self.save()
def _update_dates(self, cleaned):
"""Update dates (expiration and creation) from cleaned"""
requires_save = False

View file

@ -570,17 +570,25 @@ class DomainApplication(TimeStampedModel):
return not self.approved_domain.is_active()
return True
def _send_status_update_email(self, new_status, email_template, email_template_subject):
"""Send a atatus update email to the submitter.
def _send_status_update_email(self, new_status, email_template, email_template_subject, send_email=True):
"""Send a status update email to the submitter.
The email goes to the email address that the submitter gave as their
contact information. If there is not submitter information, then do
nothing.
send_email: bool -> Used to bypass the send_templated_email function, in the event
we just want to log that an email would have been sent, rather than actually sending one.
"""
if self.submitter is None or self.submitter.email is None:
logger.warning(f"Cannot send {new_status} email, no submitter email address.")
return
return None
if not send_email:
logger.info(f"Email was not sent. Would send {new_status} email: {self.submitter.email}")
return None
try:
send_templated_email(
email_template,
@ -684,7 +692,7 @@ class DomainApplication(TimeStampedModel):
],
target=ApplicationStatus.APPROVED,
)
def approve(self):
def approve(self, send_email=True):
"""Approve an application that has been submitted.
This has substantial side-effects because it creates another database
@ -713,6 +721,7 @@ class DomainApplication(TimeStampedModel):
"application approved",
"emails/status_change_approved.txt",
"emails/status_change_approved_subject.txt",
send_email,
)
@transition(
@ -745,6 +754,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 +793,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

@ -18,9 +18,11 @@ from registrar.admin import (
from registrar.models import Domain, DomainApplication, DomainInformation, User, DomainInvitation, Contact, Website
from registrar.models.user_domain_role import UserDomainRole
from .common import (
MockSESClient,
AuditedAdminMockData,
completed_application,
generic_domain_object,
less_console_noise,
mock_user,
create_superuser,
create_user,
@ -35,7 +37,6 @@ from unittest.mock import patch
from unittest import skip
from django.conf import settings
from unittest.mock import MagicMock
import boto3_mocking # type: ignore
import logging
@ -58,7 +59,10 @@ class TestDomainAdmin(MockEppLib):
"""
self.client.force_login(self.superuser)
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
application.approve()
mock_client = MockSESClient()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
with less_console_noise():
application.approve()
response = self.client.get("/admin/registrar/domain/")
@ -326,6 +330,7 @@ class TestDomainApplicationAdmin(MockEppLib):
url="/admin/registrar/DomainApplication/",
model=DomainApplication,
)
self.mock_client = MockSESClient()
def test_domain_sortable(self):
"""Tests if the DomainApplication sorts by domain correctly"""
@ -420,25 +425,23 @@ class TestDomainApplicationAdmin(MockEppLib):
EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete()
mock_client = MagicMock()
mock_client_instance = mock_client.return_value
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Create a sample application
application = completed_application()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
# Create a sample application
application = completed_application()
# Create a mock request
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
# Create a mock request
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
# Modify the application's property
application.status = DomainApplication.ApplicationStatus.SUBMITTED
# Modify the application's property
application.status = DomainApplication.ApplicationStatus.SUBMITTED
# Use the model admin's save_model method
self.admin.save_model(request, application, form=None, change=True)
# Use the model admin's save_model method
self.admin.save_model(request, application, form=None, change=True)
# Access the arguments passed to send_email
call_args = mock_client_instance.send_email.call_args
args, kwargs = call_args
call_args = self.mock_client.EMAILS_SENT
kwargs = call_args[0]["kwargs"]
# Retrieve the email details from the arguments
from_email = kwargs.get("FromEmailAddress")
@ -452,8 +455,7 @@ class TestDomainApplicationAdmin(MockEppLib):
self.assertEqual(to_email, EMAIL)
self.assertIn(expected_string, email_body)
# Perform assertions on the mock call itself
mock_client_instance.send_email.assert_called_once()
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
@boto3_mocking.patching
def test_save_model_sends_in_review_email(self):
@ -461,25 +463,23 @@ class TestDomainApplicationAdmin(MockEppLib):
EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete()
mock_client = MagicMock()
mock_client_instance = mock_client.return_value
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Create a sample application
application = completed_application(status=DomainApplication.ApplicationStatus.SUBMITTED)
with boto3_mocking.clients.handler_for("sesv2", mock_client):
# Create a sample application
application = completed_application(status=DomainApplication.ApplicationStatus.SUBMITTED)
# Create a mock request
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
# Create a mock request
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
# Modify the application's property
application.status = DomainApplication.ApplicationStatus.IN_REVIEW
# Modify the application's property
application.status = DomainApplication.ApplicationStatus.IN_REVIEW
# Use the model admin's save_model method
self.admin.save_model(request, application, form=None, change=True)
# Use the model admin's save_model method
self.admin.save_model(request, application, form=None, change=True)
# Access the arguments passed to send_email
call_args = mock_client_instance.send_email.call_args
args, kwargs = call_args
call_args = self.mock_client.EMAILS_SENT
kwargs = call_args[0]["kwargs"]
# Retrieve the email details from the arguments
from_email = kwargs.get("FromEmailAddress")
@ -493,8 +493,7 @@ class TestDomainApplicationAdmin(MockEppLib):
self.assertEqual(to_email, EMAIL)
self.assertIn(expected_string, email_body)
# Perform assertions on the mock call itself
mock_client_instance.send_email.assert_called_once()
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
@boto3_mocking.patching
def test_save_model_sends_approved_email(self):
@ -502,25 +501,23 @@ class TestDomainApplicationAdmin(MockEppLib):
EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete()
mock_client = MagicMock()
mock_client_instance = mock_client.return_value
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Create a sample application
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
with boto3_mocking.clients.handler_for("sesv2", mock_client):
# Create a sample application
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
# Create a mock request
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
# Create a mock request
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
# Modify the application's property
application.status = DomainApplication.ApplicationStatus.APPROVED
# Modify the application's property
application.status = DomainApplication.ApplicationStatus.APPROVED
# Use the model admin's save_model method
self.admin.save_model(request, application, form=None, change=True)
# Use the model admin's save_model method
self.admin.save_model(request, application, form=None, change=True)
# Access the arguments passed to send_email
call_args = mock_client_instance.send_email.call_args
args, kwargs = call_args
call_args = self.mock_client.EMAILS_SENT
kwargs = call_args[0]["kwargs"]
# Retrieve the email details from the arguments
from_email = kwargs.get("FromEmailAddress")
@ -534,9 +531,9 @@ class TestDomainApplicationAdmin(MockEppLib):
self.assertEqual(to_email, EMAIL)
self.assertIn(expected_string, email_body)
# Perform assertions on the mock call itself
mock_client_instance.send_email.assert_called_once()
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
@boto3_mocking.patching
def test_save_model_sets_approved_domain(self):
# make sure there is no user with this email
EMAIL = "mayor@igorville.gov"
@ -548,11 +545,13 @@ class TestDomainApplicationAdmin(MockEppLib):
# Create a mock request
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
# Modify the application's property
application.status = DomainApplication.ApplicationStatus.APPROVED
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Modify the application's property
application.status = DomainApplication.ApplicationStatus.APPROVED
# Use the model admin's save_model method
self.admin.save_model(request, application, form=None, change=True)
# Use the model admin's save_model method
self.admin.save_model(request, application, form=None, change=True)
# Test that approved domain exists and equals requested domain
self.assertEqual(application.requested_domain.name, application.approved_domain.name)
@ -563,25 +562,23 @@ class TestDomainApplicationAdmin(MockEppLib):
EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete()
mock_client = MagicMock()
mock_client_instance = mock_client.return_value
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Create a sample application
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
with boto3_mocking.clients.handler_for("sesv2", mock_client):
# Create a sample application
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
# Create a mock request
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
# Create a mock request
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
# Modify the application's property
application.status = DomainApplication.ApplicationStatus.ACTION_NEEDED
# Modify the application's property
application.status = DomainApplication.ApplicationStatus.ACTION_NEEDED
# Use the model admin's save_model method
self.admin.save_model(request, application, form=None, change=True)
# Use the model admin's save_model method
self.admin.save_model(request, application, form=None, change=True)
# Access the arguments passed to send_email
call_args = mock_client_instance.send_email.call_args
args, kwargs = call_args
call_args = self.mock_client.EMAILS_SENT
kwargs = call_args[0]["kwargs"]
# Retrieve the email details from the arguments
from_email = kwargs.get("FromEmailAddress")
@ -595,8 +592,7 @@ class TestDomainApplicationAdmin(MockEppLib):
self.assertEqual(to_email, EMAIL)
self.assertIn(expected_string, email_body)
# Perform assertions on the mock call itself
mock_client_instance.send_email.assert_called_once()
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
@boto3_mocking.patching
def test_save_model_sends_rejected_email(self):
@ -604,25 +600,23 @@ class TestDomainApplicationAdmin(MockEppLib):
EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete()
mock_client = MagicMock()
mock_client_instance = mock_client.return_value
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Create a sample application
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
with boto3_mocking.clients.handler_for("sesv2", mock_client):
# Create a sample application
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
# Create a mock request
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
# Create a mock request
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
# Modify the application's property
application.status = DomainApplication.ApplicationStatus.REJECTED
# Modify the application's property
application.status = DomainApplication.ApplicationStatus.REJECTED
# Use the model admin's save_model method
self.admin.save_model(request, application, form=None, change=True)
# Use the model admin's save_model method
self.admin.save_model(request, application, form=None, change=True)
# Access the arguments passed to send_email
call_args = mock_client_instance.send_email.call_args
args, kwargs = call_args
call_args = self.mock_client.EMAILS_SENT
kwargs = call_args[0]["kwargs"]
# Retrieve the email details from the arguments
from_email = kwargs.get("FromEmailAddress")
@ -636,9 +630,9 @@ class TestDomainApplicationAdmin(MockEppLib):
self.assertEqual(to_email, EMAIL)
self.assertIn(expected_string, email_body)
# Perform assertions on the mock call itself
mock_client_instance.send_email.assert_called_once()
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
@boto3_mocking.patching
def test_save_model_sets_restricted_status_on_user(self):
# make sure there is no user with this email
EMAIL = "mayor@igorville.gov"
@ -650,19 +644,23 @@ class TestDomainApplicationAdmin(MockEppLib):
# Create a mock request
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
# Modify the application's property
application.status = DomainApplication.ApplicationStatus.INELIGIBLE
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Modify the application's property
application.status = DomainApplication.ApplicationStatus.INELIGIBLE
# Use the model admin's save_model method
self.admin.save_model(request, application, form=None, change=True)
# Use the model admin's save_model method
self.admin.save_model(request, application, form=None, change=True)
# Test that approved domain exists and equals requested domain
self.assertEqual(application.creator.status, "restricted")
def test_readonly_when_restricted_creator(self):
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
application.creator.status = User.RESTRICTED
application.creator.save()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
application.creator.status = User.RESTRICTED
application.creator.save()
request = self.factory.get("/")
request.user = self.superuser
@ -740,8 +738,10 @@ class TestDomainApplicationAdmin(MockEppLib):
def test_saving_when_restricted_creator(self):
# Create an instance of the model
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
application.creator.status = User.RESTRICTED
application.creator.save()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
application.creator.status = User.RESTRICTED
application.creator.save()
# Create a request object with a superuser
request = self.factory.get("/")
@ -763,8 +763,10 @@ class TestDomainApplicationAdmin(MockEppLib):
def test_change_view_with_restricted_creator(self):
# Create an instance of the model
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
application.creator.status = User.RESTRICTED
application.creator.save()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
application.creator.status = User.RESTRICTED
application.creator.save()
with patch("django.contrib.messages.warning") as mock_warning:
# Create a request object with a superuser
@ -779,6 +781,7 @@ class TestDomainApplicationAdmin(MockEppLib):
"Cannot edit an application with a restricted creator.",
)
@boto3_mocking.patching
def test_error_when_saving_approved_to_rejected_and_domain_is_active(self):
# Create an instance of the model
application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
@ -800,9 +803,11 @@ class TestDomainApplicationAdmin(MockEppLib):
stack.enter_context(patch.object(Domain, "is_active", custom_is_active))
stack.enter_context(patch.object(messages, "error"))
# Simulate saving the model
application.status = DomainApplication.ApplicationStatus.REJECTED
self.admin.save_model(request, application, None, True)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Simulate saving the model
application.status = DomainApplication.ApplicationStatus.REJECTED
self.admin.save_model(request, application, None, True)
# Assert that the error message was called with the correct argument
messages.error.assert_called_once_with(
@ -831,10 +836,11 @@ class TestDomainApplicationAdmin(MockEppLib):
# Patch Domain.is_active and django.contrib.messages.error simultaneously
stack.enter_context(patch.object(Domain, "is_active", custom_is_active))
stack.enter_context(patch.object(messages, "error"))
# Simulate saving the model
application.status = DomainApplication.ApplicationStatus.REJECTED
self.admin.save_model(request, application, None, True)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Simulate saving the model
application.status = DomainApplication.ApplicationStatus.REJECTED
self.admin.save_model(request, application, None, True)
# Assert that the error message was never called
messages.error.assert_not_called()
@ -1091,6 +1097,7 @@ class TestDomainApplicationAdmin(MockEppLib):
User.objects.all().delete()
Contact.objects.all().delete()
Website.objects.all().delete()
self.mock_client.EMAILS_SENT.clear()
class DomainInvitationAdminTest(TestCase):

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

@ -3,7 +3,7 @@
from unittest.mock import MagicMock
from django.test import TestCase
from .common import completed_application
from .common import completed_application, less_console_noise
import boto3_mocking # type: ignore
@ -20,7 +20,8 @@ class TestEmails(TestCase):
application = completed_application()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
application.submit()
with less_console_noise():
application.submit()
# check that an email was sent
self.assertTrue(self.mock_client.send_email.called)
@ -56,7 +57,8 @@ class TestEmails(TestCase):
"""Test line spacing without current_website."""
application = completed_application(has_current_website=False)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
application.submit()
with less_console_noise():
application.submit()
_, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertNotIn("Current website for your organization:", body)
@ -68,7 +70,8 @@ class TestEmails(TestCase):
"""Test line spacing with current_website."""
application = completed_application(has_current_website=True)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
application.submit()
with less_console_noise():
application.submit()
_, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertIn("Current website for your organization:", body)
@ -81,7 +84,8 @@ class TestEmails(TestCase):
"""Test line spacing with other contacts."""
application = completed_application(has_other_contacts=True)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
application.submit()
with less_console_noise():
application.submit()
_, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertIn("Other employees from your organization:", body)
@ -94,7 +98,8 @@ class TestEmails(TestCase):
"""Test line spacing without other contacts."""
application = completed_application(has_other_contacts=False)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
application.submit()
with less_console_noise():
application.submit()
_, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertNotIn("Other employees from your organization:", body)
@ -106,7 +111,8 @@ class TestEmails(TestCase):
"""Test line spacing with alternative .gov domain."""
application = completed_application(has_alternative_gov_domain=True)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
application.submit()
with less_console_noise():
application.submit()
_, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertIn("city1.gov", body)
@ -118,7 +124,8 @@ class TestEmails(TestCase):
"""Test line spacing without alternative .gov domain."""
application = completed_application(has_alternative_gov_domain=False)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
application.submit()
with less_console_noise():
application.submit()
_, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertNotIn("city1.gov", body)
@ -130,7 +137,8 @@ class TestEmails(TestCase):
"""Test line spacing with about your organization."""
application = completed_application(has_about_your_organization=True)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
application.submit()
with less_console_noise():
application.submit()
_, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertIn("About your organization:", body)
@ -142,7 +150,8 @@ class TestEmails(TestCase):
"""Test line spacing without about your organization."""
application = completed_application(has_about_your_organization=False)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
application.submit()
with less_console_noise():
application.submit()
_, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertNotIn("About your organization:", body)
@ -154,7 +163,8 @@ class TestEmails(TestCase):
"""Test line spacing with anything else."""
application = completed_application(has_anything_else=True)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
application.submit()
with less_console_noise():
application.submit()
_, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
# spacing should be right between adjacent elements
@ -165,7 +175,8 @@ class TestEmails(TestCase):
"""Test line spacing without anything else."""
application = completed_application(has_anything_else=False)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
application.submit()
with less_console_noise():
application.submit()
_, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertNotIn("Anything else", body)

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

@ -19,8 +19,6 @@ from registrar.models.transition_domain import TransitionDomain # type: ignore
from .common import MockSESClient, less_console_noise, completed_application
from django_fsm import TransitionNotAllowed
boto3_mocking.clients.register_handler("sesv2", MockSESClient)
# Test comment for push -- will remove
# The DomainApplication submit method has a side effect of sending an email
@ -53,6 +51,12 @@ class TestDomainApplication(TestCase):
status=DomainApplication.ApplicationStatus.INELIGIBLE, name="ineligible.gov"
)
self.mock_client = MockSESClient()
def tearDown(self):
super().tearDown()
self.mock_client.EMAILS_SENT.clear()
def assertNotRaises(self, exception_type):
"""Helper method for testing allowed transitions."""
return self.assertRaises(Exception, None, exception_type)
@ -130,17 +134,23 @@ class TestDomainApplication(TestCase):
def test_status_fsm_submit_fail(self):
user, _ = User.objects.get_or_create(username="testy")
application = DomainApplication.objects.create(creator=user)
with self.assertRaises(ValueError):
# can't submit an application with a null domain name
application.submit()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
with self.assertRaises(ValueError):
# can't submit an application with a null domain name
application.submit()
def test_status_fsm_submit_succeed(self):
user, _ = User.objects.get_or_create(username="testy")
site = DraftDomain.objects.create(name="igorville.gov")
application = DomainApplication.objects.create(creator=user, requested_domain=site)
# no submitter email so this emits a log warning
with less_console_noise():
application.submit()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
application.submit()
self.assertEqual(application.status, application.ApplicationStatus.SUBMITTED)
def test_submit_sends_email(self):
@ -154,7 +164,10 @@ class TestDomainApplication(TestCase):
submitter=contact,
)
application.save()
application.submit()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
application.submit()
# check to see if an email was sent
self.assertGreater(
@ -179,12 +192,14 @@ class TestDomainApplication(TestCase):
(self.withdrawn_application, TransitionNotAllowed),
]
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
try:
application.submit()
except TransitionNotAllowed:
self.fail("TransitionNotAllowed was raised, but it was not expected.")
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
try:
application.submit()
except TransitionNotAllowed:
self.fail("TransitionNotAllowed was raised, but it was not expected.")
def test_submit_transition_not_allowed(self):
"""
@ -197,10 +212,12 @@ class TestDomainApplication(TestCase):
(self.ineligible_application, TransitionNotAllowed),
]
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
with self.assertRaises(exception_type):
application.submit()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
with self.assertRaises(exception_type):
application.submit()
def test_in_review_transition_allowed(self):
"""
@ -214,12 +231,14 @@ class TestDomainApplication(TestCase):
(self.ineligible_application, TransitionNotAllowed),
]
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
try:
application.in_review()
except TransitionNotAllowed:
self.fail("TransitionNotAllowed was raised, but it was not expected.")
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
try:
application.in_review()
except TransitionNotAllowed:
self.fail("TransitionNotAllowed was raised, but it was not expected.")
def test_in_review_transition_not_allowed(self):
"""
@ -231,10 +250,12 @@ class TestDomainApplication(TestCase):
(self.withdrawn_application, TransitionNotAllowed),
]
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
with self.assertRaises(exception_type):
application.in_review()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
with self.assertRaises(exception_type):
application.in_review()
def test_action_needed_transition_allowed(self):
"""
@ -247,12 +268,14 @@ class TestDomainApplication(TestCase):
(self.ineligible_application, TransitionNotAllowed),
]
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
try:
application.action_needed()
except TransitionNotAllowed:
self.fail("TransitionNotAllowed was raised, but it was not expected.")
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
try:
application.action_needed()
except TransitionNotAllowed:
self.fail("TransitionNotAllowed was raised, but it was not expected.")
def test_action_needed_transition_not_allowed(self):
"""
@ -265,10 +288,12 @@ class TestDomainApplication(TestCase):
(self.withdrawn_application, TransitionNotAllowed),
]
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
with self.assertRaises(exception_type):
application.action_needed()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
with self.assertRaises(exception_type):
application.action_needed()
def test_approved_transition_allowed(self):
"""
@ -281,12 +306,27 @@ class TestDomainApplication(TestCase):
(self.rejected_application, TransitionNotAllowed),
]
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
try:
application.approve()
except TransitionNotAllowed:
self.fail("TransitionNotAllowed was raised, but it was not expected.")
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
try:
application.approve()
except TransitionNotAllowed:
self.fail("TransitionNotAllowed was raised, but it was not expected.")
def test_approved_skips_sending_email(self):
"""
Test that calling .approve with send_email=False doesn't actually send
an email
"""
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
self.submitted_application.approve(send_email=False)
# Assert that no emails were sent
self.assertEqual(len(self.mock_client.EMAILS_SENT), 0)
def test_approved_transition_not_allowed(self):
"""
@ -299,10 +339,12 @@ class TestDomainApplication(TestCase):
(self.ineligible_application, TransitionNotAllowed),
]
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
with self.assertRaises(exception_type):
application.approve()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
with self.assertRaises(exception_type):
application.approve()
def test_withdraw_transition_allowed(self):
"""
@ -314,12 +356,14 @@ class TestDomainApplication(TestCase):
(self.action_needed_application, TransitionNotAllowed),
]
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
try:
application.withdraw()
except TransitionNotAllowed:
self.fail("TransitionNotAllowed was raised, but it was not expected.")
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
try:
application.withdraw()
except TransitionNotAllowed:
self.fail("TransitionNotAllowed was raised, but it was not expected.")
def test_withdraw_transition_not_allowed(self):
"""
@ -333,10 +377,12 @@ class TestDomainApplication(TestCase):
(self.ineligible_application, TransitionNotAllowed),
]
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
with self.assertRaises(exception_type):
application.withdraw()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
with self.assertRaises(exception_type):
application.withdraw()
def test_reject_transition_allowed(self):
"""
@ -348,12 +394,14 @@ class TestDomainApplication(TestCase):
(self.approved_application, TransitionNotAllowed),
]
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
try:
application.reject()
except TransitionNotAllowed:
self.fail("TransitionNotAllowed was raised, but it was not expected.")
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
try:
application.reject()
except TransitionNotAllowed:
self.fail("TransitionNotAllowed was raised, but it was not expected.")
def test_reject_transition_not_allowed(self):
"""
@ -367,10 +415,12 @@ class TestDomainApplication(TestCase):
(self.ineligible_application, TransitionNotAllowed),
]
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
with self.assertRaises(exception_type):
application.reject()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
with self.assertRaises(exception_type):
application.reject()
def test_reject_with_prejudice_transition_allowed(self):
"""
@ -383,12 +433,14 @@ class TestDomainApplication(TestCase):
(self.rejected_application, TransitionNotAllowed),
]
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
try:
application.reject_with_prejudice()
except TransitionNotAllowed:
self.fail("TransitionNotAllowed was raised, but it was not expected.")
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
try:
application.reject_with_prejudice()
except TransitionNotAllowed:
self.fail("TransitionNotAllowed was raised, but it was not expected.")
def test_reject_with_prejudice_transition_not_allowed(self):
"""
@ -401,10 +453,12 @@ class TestDomainApplication(TestCase):
(self.ineligible_application, TransitionNotAllowed),
]
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
with self.assertRaises(exception_type):
application.reject_with_prejudice()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
for application, exception_type in test_cases:
with self.subTest(application=application, exception_type=exception_type):
with self.assertRaises(exception_type):
application.reject_with_prejudice()
def test_transition_not_allowed_approved_rejected_when_domain_is_active(self):
"""Create an application with status approved, create a matching domain that
@ -418,11 +472,13 @@ class TestDomainApplication(TestCase):
def custom_is_active(self):
return True # Override to return True
# Use patch to temporarily replace is_active with the custom implementation
with patch.object(Domain, "is_active", custom_is_active):
# Now, when you call is_active on Domain, it will return True
with self.assertRaises(TransitionNotAllowed):
self.approved_application.reject()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Use patch to temporarily replace is_active with the custom implementation
with patch.object(Domain, "is_active", custom_is_active):
# Now, when you call is_active on Domain, it will return True
with self.assertRaises(TransitionNotAllowed):
self.approved_application.reject()
def test_transition_not_allowed_approved_ineligible_when_domain_is_active(self):
"""Create an application with status approved, create a matching domain that
@ -436,24 +492,37 @@ class TestDomainApplication(TestCase):
def custom_is_active(self):
return True # Override to return True
# Use patch to temporarily replace is_active with the custom implementation
with patch.object(Domain, "is_active", custom_is_active):
# Now, when you call is_active on Domain, it will return True
with self.assertRaises(TransitionNotAllowed):
self.approved_application.reject_with_prejudice()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Use patch to temporarily replace is_active with the custom implementation
with patch.object(Domain, "is_active", custom_is_active):
# Now, when you call is_active on Domain, it will return True
with self.assertRaises(TransitionNotAllowed):
self.approved_application.reject_with_prejudice()
class TestPermissions(TestCase):
"""Test the User-Domain-Role connection."""
def setUp(self):
super().setUp()
self.mock_client = MockSESClient()
def tearDown(self):
super().tearDown()
self.mock_client.EMAILS_SENT.clear()
@boto3_mocking.patching
def test_approval_creates_role(self):
draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
user, _ = User.objects.get_or_create()
application = DomainApplication.objects.create(creator=user, requested_domain=draft_domain)
# skip using the submit method
application.status = DomainApplication.ApplicationStatus.SUBMITTED
application.approve()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# skip using the submit method
application.status = DomainApplication.ApplicationStatus.SUBMITTED
application.approve()
# should be a role for this user
domain = Domain.objects.get(name="igorville.gov")
@ -464,13 +533,25 @@ class TestDomainInfo(TestCase):
"""Test creation of Domain Information when approved."""
def setUp(self):
super().setUp()
self.mock_client = MockSESClient()
def tearDown(self):
super().tearDown()
self.mock_client.EMAILS_SENT.clear()
@boto3_mocking.patching
def test_approval_creates_info(self):
draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
user, _ = User.objects.get_or_create()
application = DomainApplication.objects.create(creator=user, requested_domain=draft_domain)
# skip using the submit method
application.status = DomainApplication.ApplicationStatus.SUBMITTED
application.approve()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# skip using the submit method
application.status = DomainApplication.ApplicationStatus.SUBMITTED
application.approve()
# should be an information present for this domain
domain = Domain.objects.get(name="igorville.gov")
@ -572,11 +653,12 @@ class TestUser(TestCase):
caps_email = "MAYOR@igorville.gov"
# mock the domain invitation save routine
with patch("registrar.models.DomainInvitation.save") as save_mock:
DomainInvitation.objects.get_or_create(email=caps_email, domain=self.domain)
self.user.check_domain_invitations_on_login()
# if check_domain_invitations_on_login properly matches exactly one
# Domain Invitation, then save routine should be called exactly once
save_mock.assert_called_once()
with less_console_noise():
DomainInvitation.objects.get_or_create(email=caps_email, domain=self.domain)
self.user.check_domain_invitations_on_login()
# if check_domain_invitations_on_login properly matches exactly one
# Domain Invitation, then save routine should be called exactly once
save_mock.assert_called_once()
class TestContact(TestCase):

View file

@ -29,8 +29,9 @@ from epplibwrapper import (
RegistryError,
ErrorCode,
)
from .common import MockEppLib
from .common import MockEppLib, MockSESClient, less_console_noise
import logging
import boto3_mocking # type: ignore
logger = logging.getLogger(__name__)
@ -252,6 +253,7 @@ class TestDomainCache(MockEppLib):
class TestDomainCreation(MockEppLib):
"""Rule: An approved domain application must result in a domain"""
@boto3_mocking.patching
def test_approved_application_creates_domain_locally(self):
"""
Scenario: Analyst approves a domain application
@ -262,10 +264,14 @@ class TestDomainCreation(MockEppLib):
draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
user, _ = User.objects.get_or_create()
application = DomainApplication.objects.create(creator=user, requested_domain=draft_domain)
# skip using the submit method
application.status = DomainApplication.ApplicationStatus.SUBMITTED
# transition to approve state
application.approve()
mock_client = MockSESClient()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
with less_console_noise():
# skip using the submit method
application.status = DomainApplication.ApplicationStatus.SUBMITTED
# transition to approve state
application.approve()
# should have information present for this domain
domain = Domain.objects.get(name="igorville.gov")
self.assertTrue(domain)
@ -387,6 +393,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()
@ -711,6 +745,50 @@ class TestRegistrantContacts(MockEppLib):
self.mockedSendFunction.assert_has_calls(expected_calls, any_order=True)
self.assertEqual(PublicContact.objects.filter(domain=self.domain).count(), 1)
def test_security_email_returns_on_registry_error(self):
"""
Scenario: Security email previously set through EPP and stored in registrar's database.
Registry is unavailable and throws exception when attempting to build cache from
registry. Security email retrieved from database.
"""
# Use self.domain_contact which has been initialized with existing contacts, including securityContact
# call get_security_email to initially set the security_contact_registry_id in the domain model
self.domain_contact.get_security_email()
# invalidate the cache so the next time get_security_email is called, it has to attempt to populate cache
self.domain_contact._invalidate_cache()
# mock that registry throws an error on the EPP send
def side_effect(_request, cleaned):
raise RegistryError(code=ErrorCode.COMMAND_FAILED)
patcher = patch("registrar.models.domain.registry.send")
mocked_send = patcher.start()
mocked_send.side_effect = side_effect
# when get_security_email is called, the registry error will force the security contact
# to be retrieved using the security_contact_registry_id in the domain model
security_email = self.domain_contact.get_security_email()
# assert that the proper security contact was retrieved by testing the email matches expected value
self.assertEqual(security_email, "security@mail.gov")
patcher.stop()
def test_security_email_stored_on_fetch_cache(self):
"""
Scenario: Security email is stored in db when security contact is retrieved from fetch_cache.
Verify the success of this by asserting get_or_create calls to db.
The mocked data for the EPP calls for the freeman.gov domain returns a security
contact with registry id of securityContact when InfoContact is called
"""
# Use self.domain_contact which has been initialized with existing contacts, including securityContact
# force fetch_cache to be called, which will return above documented mocked hosts
self.domain_contact.get_security_email()
# assert that the security_contact_registry_id in the db matches "securityContact"
self.assertEqual(self.domain_contact.security_contact_registry_id, "securityContact")
def test_not_disclosed_on_other_contacts(self):
"""
Scenario: Registrant creates a new domain with multiple contacts
@ -1113,6 +1191,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
@ -1139,6 +1218,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
@ -1147,6 +1228,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
@ -1177,6 +1259,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):
"""
@ -2357,11 +2440,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(
@ -2377,6 +2463,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, {})
@ -2395,6 +2484,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(
[
@ -2418,12 +2508,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

@ -18,7 +18,8 @@ from unittest.mock import patch
from registrar.models.contact import Contact
from .common import MockEppLib, less_console_noise
from .common import MockEppLib, MockSESClient, less_console_noise
import boto3_mocking # type: ignore
class TestExtendExpirationDates(MockEppLib):
@ -706,17 +707,21 @@ class TestMigrations(TestCase):
def run_master_script(self):
# noqa here (E501) because splitting this up makes it
# confusing to read.
with patch(
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", # noqa
return_value=True,
):
call_command(
"master_domain_migrations",
runMigrations=True,
migrationDirectory=self.test_data_file_location,
migrationJSON=self.migration_json_filename,
disablePrompts=True,
)
mock_client = MockSESClient()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
with patch(
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", # noqa
return_value=True,
):
with patch("registrar.utility.email.send_templated_email", return_value=None):
call_command(
"master_domain_migrations",
runMigrations=True,
migrationDirectory=self.test_data_file_location,
migrationJSON=self.migration_json_filename,
disablePrompts=True,
)
print(f"here: {mock_client.EMAILS_SENT}")
def compare_tables(
self,
@ -1019,6 +1024,7 @@ class TestMigrations(TestCase):
expected_missing_domain_invitations,
)
@boto3_mocking.patching
def test_send_domain_invitations_email(self):
"""Can send only a single domain invitation email."""
with less_console_noise():
@ -1027,9 +1033,12 @@ class TestMigrations(TestCase):
# this is one of the email addresses in data/test_contacts.txt
output_stream = StringIO()
# also have to re-point the logging handlers to output_stream
with less_console_noise(output_stream):
call_command("send_domain_invitations", "testuser@gmail.com", stdout=output_stream)
mock_client = MockSESClient()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
# also have to re-point the logging handlers to output_stream
with less_console_noise(output_stream):
call_command("send_domain_invitations", "testuser@gmail.com", stdout=output_stream)
# Check that we had the right numbers in our output
output = output_stream.getvalue()
@ -1037,6 +1046,7 @@ class TestMigrations(TestCase):
self.assertIn("Found 1 transition domains", output)
self.assertTrue("would send email to testuser@gmail.com", output)
@boto3_mocking.patching
def test_send_domain_invitations_two_emails(self):
"""Can send only a single domain invitation email."""
with less_console_noise():
@ -1045,11 +1055,14 @@ class TestMigrations(TestCase):
# these are two email addresses in data/test_contacts.txt
output_stream = StringIO()
# also have to re-point the logging handlers to output_stream
with less_console_noise(output_stream):
call_command(
"send_domain_invitations", "testuser@gmail.com", "agustina.wyman7@test.com", stdout=output_stream
)
mock_client = MockSESClient()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
# also have to re-point the logging handlers to output_stream
with less_console_noise(output_stream):
call_command(
"send_domain_invitations", "testuser@gmail.com", "agustina.wyman7@test.com", stdout=output_stream
)
# Check that we had the right numbers in our output
output = output_stream.getvalue()

View file

@ -5,7 +5,7 @@ from django.conf import settings
from django.test import Client, TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
from .common import MockEppLib, completed_application, create_user # type: ignore
from .common import MockEppLib, MockSESClient, completed_application, create_user # type: ignore
from django_webtest import WebTest # type: ignore
import boto3_mocking # type: ignore
@ -182,8 +182,11 @@ class DomainApplicationTests(TestWithUser, WebTest):
"""Test that an info message appears when user has multiple applications already"""
# create and submit an application
application = completed_application(user=self.user)
application.submit()
application.save()
mock_client = MockSESClient()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
with less_console_noise():
application.submit()
application.save()
# now, attempt to create another one
with less_console_noise():
@ -824,6 +827,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."""
intro_page = self.app.get(reverse("application:"))
@ -1503,6 +1592,7 @@ class TestDomainManagers(TestDomainOverview):
response = self.client.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
self.assertContains(response, "Add a domain manager")
@boto3_mocking.patching
def test_domain_user_add_form(self):
"""Adding an existing user works."""
other_user, _ = get_user_model().objects.get_or_create(email="mayor@igorville.gov")
@ -1512,7 +1602,11 @@ class TestDomainManagers(TestDomainOverview):
add_page.form["email"] = "mayor@igorville.gov"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
success_result = add_page.form.submit()
mock_client = MockSESClient()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
with less_console_noise():
success_result = add_page.form.submit()
self.assertEqual(success_result.status_code, 302)
self.assertEqual(
@ -1541,7 +1635,12 @@ class TestDomainManagers(TestDomainOverview):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
success_result = add_page.form.submit()
mock_client = MockSESClient()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
with less_console_noise():
success_result = add_page.form.submit()
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
success_page = success_result.follow()
@ -1567,7 +1666,12 @@ class TestDomainManagers(TestDomainOverview):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = caps_email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
success_result = add_page.form.submit()
mock_client = MockSESClient()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
with less_console_noise():
success_result = add_page.form.submit()
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
success_page = success_result.follow()
@ -1587,11 +1691,12 @@ class TestDomainManagers(TestDomainOverview):
mock_client = MagicMock()
mock_client_instance = mock_client.return_value
with boto3_mocking.clients.handler_for("sesv2", mock_client):
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
add_page.form.submit()
with less_console_noise():
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
add_page.form.submit()
# check the mock instance to see if `send_email` was called right
mock_client_instance.send_email.assert_called_once_with(
@ -1613,11 +1718,12 @@ class TestDomainManagers(TestDomainOverview):
mock_client_instance = mock_client.return_value
with boto3_mocking.clients.handler_for("sesv2", mock_client):
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
add_page.form.submit()
with less_console_noise():
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
add_page.form.submit()
# check the mock instance to see if `send_email` was called right
mock_client_instance.send_email.assert_called_once_with(
@ -1651,11 +1757,12 @@ class TestDomainManagers(TestDomainOverview):
mock_client_instance = mock_client.return_value
with boto3_mocking.clients.handler_for("sesv2", mock_client):
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
add_page.form.submit()
with less_console_noise():
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
add_page.form.submit()
# check the mock instance to see if `send_email` was called right
mock_client_instance.send_email.assert_called_once_with(
@ -1693,11 +1800,12 @@ class TestDomainManagers(TestDomainOverview):
mock_client_instance = mock_client.return_value
with boto3_mocking.clients.handler_for("sesv2", mock_client):
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
add_page.form.submit()
with less_console_noise():
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
add_page.form.submit()
# check the mock instance to see if `send_email` was called right
mock_client_instance.send_email.assert_called_once_with(
@ -1732,15 +1840,15 @@ class TestDomainManagers(TestDomainOverview):
self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
mock_client = MagicMock()
mock_error_message = MagicMock()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
with patch("django.contrib.messages.error") as mock_error_message:
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
add_page.form.submit().follow()
with less_console_noise():
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
add_page.form.submit().follow()
expected_message_content = "Can't send invitation email. No email is associated with your account."
@ -1769,11 +1877,12 @@ class TestDomainManagers(TestDomainOverview):
mock_error_message = MagicMock()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
with patch("django.contrib.messages.error") as mock_error_message:
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
add_page.form.submit().follow()
with less_console_noise():
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
add_page.form.submit().follow()
expected_message_content = "Can't send invitation email. No email is associated with your account."
@ -1787,7 +1896,11 @@ class TestDomainManagers(TestDomainOverview):
"""Posting to the delete view deletes an invitation."""
email_address = "mayor@igorville.gov"
invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address)
self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id}))
mock_client = MockSESClient()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
with less_console_noise():
self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id}))
mock_client.EMAILS_SENT.clear()
with self.assertRaises(DomainInvitation.DoesNotExist):
DomainInvitation.objects.get(id=invitation.id)
@ -1799,8 +1912,11 @@ class TestDomainManagers(TestDomainOverview):
other_user = User()
other_user.save()
self.client.force_login(other_user)
with less_console_noise(): # permission denied makes console errors
result = self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id}))
mock_client = MagicMock()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
with less_console_noise(): # permission denied makes console errors
result = self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id}))
self.assertEqual(result.status_code, 403)
@boto3_mocking.patching
@ -1816,7 +1932,11 @@ class TestDomainManagers(TestDomainOverview):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
add_page.form.submit()
mock_client = MagicMock()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
with less_console_noise():
add_page.form.submit()
# user was invited, create them
new_user = User.objects.create(username=email_address, email=email_address)
@ -1871,6 +1991,7 @@ class TestDomainNameservers(TestDomainOverview):
# attempt to submit the form without two hosts, both subdomains,
# only one has ips
nameservers_page.form["form-1-server"] = "ns2.igorville.gov"
with less_console_noise(): # swallow log warning message
result = nameservers_page.form.submit()
# form submission was a post with an error, response should be a 200
@ -2206,8 +2327,10 @@ class TestDomainSecurityEmail(TestDomainOverview):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
security_email_page.form["security_email"] = "mayor@igorville.gov"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
with less_console_noise(): # swallow log warning message
result = security_email_page.form.submit()
mock_client = MagicMock()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
with less_console_noise(): # swallow log warning message
result = security_email_page.form.submit()
self.assertEqual(result.status_code, 302)
self.assertEqual(
result["Location"],
@ -2553,9 +2676,12 @@ class TestApplicationStatus(TestWithUser, WebTest):
self.assertContains(detail_page, "Admin Tester")
self.assertContains(detail_page, "Status:")
# click the "Withdraw request" button
withdraw_page = detail_page.click("Withdraw request")
self.assertContains(withdraw_page, "Withdraw request for")
home_page = withdraw_page.click("Withdraw request")
mock_client = MockSESClient()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
with less_console_noise():
withdraw_page = detail_page.click("Withdraw request")
self.assertContains(withdraw_page, "Withdraw request for")
home_page = withdraw_page.click("Withdraw request")
# confirm that it has redirected, and the status has been updated to withdrawn
self.assertRedirects(
home_page,

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

@ -1,11 +1,14 @@
"""Utilities for sending emails."""
import boto3
import logging
from django.conf import settings
from django.template.loader import get_template
logger = logging.getLogger(__name__)
class EmailSendingError(RuntimeError):
"""Local error for handling all failures when sending email."""
@ -20,7 +23,7 @@ def send_templated_email(template_name: str, subject_template_name: str, to_addr
context as Django's HTML templates. context gives additional information
that the template may use.
"""
logger.info(f"An email was sent! Template name: {template_name} to {to_address}")
template = get_template(template_name)
email_body = template.render(context=context)

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

View file

@ -67,6 +67,7 @@
10038 OUTOFSCOPE http://app:8080/dns/nameservers
10038 OUTOFSCOPE http://app:8080/dns/dnssec
10038 OUTOFSCOPE http://app:8080/dns/dnssec/dsdata
10038 OUTOFSCOPE http://app:8080/org-name-address
# This URL always returns 404, so include it as well.
10038 OUTOFSCOPE http://app:8080/todo
# OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers