Merge branch 'main' into za/1676-require-investigator-da

This commit is contained in:
zandercymatics 2024-02-27 08:43:30 -07:00
commit ef240c59d6
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
15 changed files with 1721 additions and 1002 deletions

View file

@ -3,7 +3,7 @@ import logging
from django import forms from django import forms
from django.db.models.functions import Concat, Coalesce from django.db.models.functions import Concat, Coalesce
from django.db.models import Value, CharField from django.db.models import Value, CharField, Q
from django.http import HttpResponse, HttpResponseRedirect from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions from django_fsm import get_available_FIELD_transitions
@ -25,7 +25,7 @@ from auditlog.admin import LogEntryAdmin # type: ignore
from django_fsm import TransitionNotAllowed # type: ignore from django_fsm import TransitionNotAllowed # type: ignore
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.html import escape from django.utils.html import escape
from django.utils.translation import gettext_lazy as _
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -927,11 +927,35 @@ class DomainApplicationAdmin(ListHeaderAdmin):
else: else:
return queryset.filter(investigator__id__exact=self.value()) return queryset.filter(investigator__id__exact=self.value())
class ElectionOfficeFilter(admin.SimpleListFilter):
"""Define a custom filter for is_election_board"""
title = _("election office")
parameter_name = "is_election_board"
def lookups(self, request, model_admin):
return (
("1", _("Yes")),
("0", _("No")),
)
def queryset(self, request, queryset):
if self.value() == "1":
return queryset.filter(is_election_board=True)
if self.value() == "0":
return queryset.filter(Q(is_election_board=False) | Q(is_election_board=None))
# Columns # Columns
list_display = [ list_display = [
"requested_domain", "requested_domain",
"status", "status",
"organization_type", "organization_type",
"federal_type",
"federal_agency",
"organization_name",
"custom_election_board",
"city",
"state_territory",
"created_at", "created_at",
"submitter", "submitter",
"investigator", "investigator",
@ -943,8 +967,21 @@ class DomainApplicationAdmin(ListHeaderAdmin):
("investigator", ["first_name", "last_name"]), ("investigator", ["first_name", "last_name"]),
] ]
def custom_election_board(self, obj):
return "Yes" if obj.is_election_board else "No"
custom_election_board.admin_order_field = "is_election_board" # type: ignore
custom_election_board.short_description = "Election office" # type: ignore
# Filters # Filters
list_filter = ("status", "organization_type", InvestigatorFilter) list_filter = (
"status",
"organization_type",
"federal_type",
ElectionOfficeFilter,
"rejection_reason",
InvestigatorFilter,
)
# Search # Search
search_fields = [ search_fields = [
@ -958,7 +995,7 @@ class DomainApplicationAdmin(ListHeaderAdmin):
# Detail view # Detail view
form = DomainApplicationAdminForm form = DomainApplicationAdminForm
fieldsets = [ fieldsets = [
(None, {"fields": ["status", "investigator", "creator", "approved_domain", "notes"]}), (None, {"fields": ["status", "rejection_reason", "investigator", "creator", "approved_domain", "notes"]}),
( (
"Type of organization", "Type of organization",
{ {
@ -1073,7 +1110,23 @@ class DomainApplicationAdmin(ListHeaderAdmin):
request, request,
"This action is not permitted. The domain is already active.", "This action is not permitted. The domain is already active.",
) )
elif (
obj
and obj.status == models.DomainApplication.ApplicationStatus.REJECTED
and not obj.rejection_reason
):
# This condition should never be triggered.
# The opposite of this condition is acceptable (rejected -> other status and rejection_reason)
# because we clean up the rejection reason in the transition in the model.
# Clear the success message
messages.set_level(request, messages.ERROR)
messages.error(
request,
"A rejection reason is required.",
)
else: else:
if obj.status != original_obj.status: if obj.status != original_obj.status:
status_method_mapping = { status_method_mapping = {
@ -1217,12 +1270,37 @@ class DomainInformationInline(admin.StackedInline):
class DomainAdmin(ListHeaderAdmin): class DomainAdmin(ListHeaderAdmin):
"""Custom domain admin class to add extra buttons.""" """Custom domain admin class to add extra buttons."""
class ElectionOfficeFilter(admin.SimpleListFilter):
"""Define a custom filter for is_election_board"""
title = _("election office")
parameter_name = "is_election_board"
def lookups(self, request, model_admin):
return (
("1", _("Yes")),
("0", _("No")),
)
def queryset(self, request, queryset):
logger.debug(self.value())
if self.value() == "1":
return queryset.filter(domain_info__is_election_board=True)
if self.value() == "0":
return queryset.filter(Q(domain_info__is_election_board=False) | Q(domain_info__is_election_board=None))
inlines = [DomainInformationInline] inlines = [DomainInformationInline]
# Columns # Columns
list_display = [ list_display = [
"name", "name",
"organization_type", "organization_type",
"federal_type",
"federal_agency",
"organization_name",
"custom_election_board",
"city",
"state_territory",
"state", "state",
"expiration_date", "expiration_date",
"created_at", "created_at",
@ -1246,8 +1324,42 @@ class DomainAdmin(ListHeaderAdmin):
organization_type.admin_order_field = "domain_info__organization_type" # type: ignore organization_type.admin_order_field = "domain_info__organization_type" # type: ignore
def federal_agency(self, obj):
return obj.domain_info.federal_agency if obj.domain_info else None
federal_agency.admin_order_field = "domain_info__federal_agency" # type: ignore
def federal_type(self, obj):
return obj.domain_info.federal_type if obj.domain_info else None
federal_type.admin_order_field = "domain_info__federal_type" # type: ignore
def organization_name(self, obj):
return obj.domain_info.organization_name if obj.domain_info else None
organization_name.admin_order_field = "domain_info__organization_name" # type: ignore
def custom_election_board(self, obj):
domain_info = getattr(obj, "domain_info", None)
if domain_info:
return "Yes" if domain_info.is_election_board else "No"
return "No"
custom_election_board.admin_order_field = "domain_info__is_election_board" # type: ignore
custom_election_board.short_description = "Election office" # type: ignore
def city(self, obj):
return obj.domain_info.city if obj.domain_info else None
city.admin_order_field = "domain_info__city" # type: ignore
def state_territory(self, obj):
return obj.domain_info.state_territory if obj.domain_info else None
state_territory.admin_order_field = "domain_info__state_territory" # type: ignore
# Filters # Filters
list_filter = ["domain_info__organization_type", "state"] list_filter = ["domain_info__organization_type", "domain_info__federal_type", ElectionOfficeFilter, "state"]
search_fields = ["name"] search_fields = ["name"]
search_help_text = "Search by domain name." search_help_text = "Search by domain name."

View file

@ -339,3 +339,46 @@ function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk,
} }
})(); })();
/** An IIFE for admin in DjangoAdmin to listen to changes on the domain request
* status select amd to show/hide the rejection reason
*/
(function (){
let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason')
if (rejectionReasonFormGroup) {
let statusSelect = document.getElementById('id_status')
// Initial handling of rejectionReasonFormGroup display
if (statusSelect.value != 'rejected')
rejectionReasonFormGroup.style.display = 'none';
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
statusSelect.addEventListener('change', function() {
if (statusSelect.value == 'rejected') {
rejectionReasonFormGroup.style.display = 'block';
sessionStorage.removeItem('hideRejectionReason');
} else {
rejectionReasonFormGroup.style.display = 'none';
sessionStorage.setItem('hideRejectionReason', 'true');
}
});
}
// Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage
// When you navigate using forward/back after changing status but not saving, when you land back on the DA page the
// status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide
// accurately for this edge case, we use cache and test for the back/forward navigation.
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.type === "back_forward") {
if (sessionStorage.getItem('hideRejectionReason'))
document.querySelector('.field-rejection_reason').style.display = 'none';
else
document.querySelector('.field-rejection_reason').style.display = 'block';
}
});
});
observer.observe({ type: "navigation" });
})();

View file

@ -286,6 +286,7 @@ AWS_MAX_ATTEMPTS = 3
BOTO_CONFIG = Config(retries={"mode": AWS_RETRY_MODE, "max_attempts": AWS_MAX_ATTEMPTS}) BOTO_CONFIG = Config(retries={"mode": AWS_RETRY_MODE, "max_attempts": AWS_MAX_ATTEMPTS})
# email address to use for various automated correspondence # email address to use for various automated correspondence
# also used as a default to and bcc email
DEFAULT_FROM_EMAIL = "help@get.gov <help@get.gov>" DEFAULT_FROM_EMAIL = "help@get.gov <help@get.gov>"
# connect to an (external) SMTP server for sending email # connect to an (external) SMTP server for sending email

View file

@ -284,6 +284,7 @@ class OrganizationContactForm(RegistrarForm):
message="Enter a zip code in the form of 12345 or 12345-6789.", message="Enter a zip code in the form of 12345 or 12345-6789.",
) )
], ],
error_messages={"required": ("Enter a zip code in the form of 12345 or 12345-6789.")},
) )
urbanization = forms.CharField( urbanization = forms.CharField(
required=False, required=False,

View file

@ -0,0 +1,29 @@
# Generated by Django 4.2.7 on 2024-02-26 22:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0069_alter_contact_email_alter_contact_first_name_and_more"),
]
operations = [
migrations.AddField(
model_name="domainapplication",
name="rejection_reason",
field=models.TextField(
blank=True,
choices=[
("purpose_not_met", "Purpose requirements not met"),
("requestor_not_eligible", "Requestor not eligible to make request"),
("org_has_domain", "Org already has a .gov domain"),
("contacts_not_verified", "Org contacts couldn't be verified"),
("org_not_eligible", "Org not eligible for a .gov domain"),
("naming_not_met", "Naming requirements not met"),
("other", "Other/Unspecified"),
],
null=True,
),
),
]

View file

@ -4,6 +4,7 @@ from typing import Union
import logging import logging
from django.apps import apps from django.apps import apps
from django.conf import settings
from django.db import models from django.db import models
from django_fsm import FSMField, transition # type: ignore from django_fsm import FSMField, transition # type: ignore
from django.utils import timezone from django.utils import timezone
@ -351,12 +352,34 @@ class DomainApplication(TimeStampedModel):
] ]
AGENCY_CHOICES = [(v, v) for v in AGENCIES] AGENCY_CHOICES = [(v, v) for v in AGENCIES]
class RejectionReasons(models.TextChoices):
DOMAIN_PURPOSE = "purpose_not_met", "Purpose requirements not met"
REQUESTOR = "requestor_not_eligible", "Requestor not eligible to make request"
SECOND_DOMAIN_REASONING = (
"org_has_domain",
"Org already has a .gov domain",
)
CONTACTS_OR_ORGANIZATION_LEGITIMACY = (
"contacts_not_verified",
"Org contacts couldn't be verified",
)
ORGANIZATION_ELIGIBILITY = "org_not_eligible", "Org not eligible for a .gov domain"
NAMING_REQUIREMENTS = "naming_not_met", "Naming requirements not met"
OTHER = "other", "Other/Unspecified"
# #### Internal fields about the application ##### # #### Internal fields about the application #####
status = FSMField( status = FSMField(
choices=ApplicationStatus.choices, # possible states as an array of constants choices=ApplicationStatus.choices, # possible states as an array of constants
default=ApplicationStatus.STARTED, # sensible default default=ApplicationStatus.STARTED, # sensible default
protected=False, # can change state directly, particularly in Django admin protected=False, # can change state directly, particularly in Django admin
) )
rejection_reason = models.TextField(
choices=RejectionReasons.choices,
null=True,
blank=True,
)
# This is the application user who created this application. The contact # This is the application user who created this application. The contact
# information that they gave is in the `submitter` field # information that they gave is in the `submitter` field
creator = models.ForeignKey( creator = models.ForeignKey(
@ -364,6 +387,7 @@ class DomainApplication(TimeStampedModel):
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name="applications_created", related_name="applications_created",
) )
investigator = models.ForeignKey( investigator = models.ForeignKey(
"registrar.User", "registrar.User",
null=True, null=True,
@ -589,7 +613,9 @@ class DomainApplication(TimeStampedModel):
logger.error(err) logger.error(err)
logger.error(f"Can't query an approved domain while attempting {called_from}") logger.error(f"Can't query an approved domain while attempting {called_from}")
def _send_status_update_email(self, new_status, email_template, email_template_subject, send_email=True): def _send_status_update_email(
self, new_status, email_template, email_template_subject, send_email=True, bcc_address=""
):
"""Send a status update email to the submitter. """Send a status update email to the submitter.
The email goes to the email address that the submitter gave as their The email goes to the email address that the submitter gave as their
@ -614,6 +640,7 @@ class DomainApplication(TimeStampedModel):
email_template_subject, email_template_subject,
self.submitter.email, self.submitter.email,
context={"application": self}, context={"application": self},
bcc_address=bcc_address,
) )
logger.info(f"The {new_status} email sent to: {self.submitter.email}") logger.info(f"The {new_status} email sent to: {self.submitter.email}")
except EmailSendingError: except EmailSendingError:
@ -660,11 +687,17 @@ class DomainApplication(TimeStampedModel):
# Limit email notifications to transitions from Started and Withdrawn # Limit email notifications to transitions from Started and Withdrawn
limited_statuses = [self.ApplicationStatus.STARTED, self.ApplicationStatus.WITHDRAWN] limited_statuses = [self.ApplicationStatus.STARTED, self.ApplicationStatus.WITHDRAWN]
bcc_address = ""
if settings.IS_PRODUCTION:
bcc_address = settings.DEFAULT_FROM_EMAIL
if self.status in limited_statuses: if self.status in limited_statuses:
self._send_status_update_email( self._send_status_update_email(
"submission confirmation", "submission confirmation",
"emails/submission_confirmation.txt", "emails/submission_confirmation.txt",
"emails/submission_confirmation_subject.txt", "emails/submission_confirmation_subject.txt",
True,
bcc_address,
) )
@transition( @transition(
@ -684,12 +717,17 @@ class DomainApplication(TimeStampedModel):
This action is logged. This action is logged.
This action cleans up the rejection status if moving away from rejected.
As side effects this will delete the domain and domain_information As side effects this will delete the domain and domain_information
(will cascade) when they exist.""" (will cascade) when they exist."""
if self.status == self.ApplicationStatus.APPROVED: if self.status == self.ApplicationStatus.APPROVED:
self.delete_and_clean_up_domain("in_review") self.delete_and_clean_up_domain("in_review")
if self.status == self.ApplicationStatus.REJECTED:
self.rejection_reason = None
literal = DomainApplication.ApplicationStatus.IN_REVIEW literal = DomainApplication.ApplicationStatus.IN_REVIEW
# Check if the tuple exists, then grab its value # Check if the tuple exists, then grab its value
in_review = literal if literal is not None else "In Review" in_review = literal if literal is not None else "In Review"
@ -711,12 +749,17 @@ class DomainApplication(TimeStampedModel):
This action is logged. This action is logged.
This action cleans up the rejection status if moving away from rejected.
As side effects this will delete the domain and domain_information As side effects this will delete the domain and domain_information
(will cascade) when they exist.""" (will cascade) when they exist."""
if self.status == self.ApplicationStatus.APPROVED: if self.status == self.ApplicationStatus.APPROVED:
self.delete_and_clean_up_domain("reject_with_prejudice") self.delete_and_clean_up_domain("reject_with_prejudice")
if self.status == self.ApplicationStatus.REJECTED:
self.rejection_reason = None
literal = DomainApplication.ApplicationStatus.ACTION_NEEDED literal = DomainApplication.ApplicationStatus.ACTION_NEEDED
# Check if the tuple is setup correctly, then grab its value # Check if the tuple is setup correctly, then grab its value
action_needed = literal if literal is not None else "Action Needed" action_needed = literal if literal is not None else "Action Needed"
@ -736,6 +779,8 @@ class DomainApplication(TimeStampedModel):
def approve(self, send_email=True): def approve(self, send_email=True):
"""Approve an application that has been submitted. """Approve an application that has been submitted.
This action cleans up the rejection status if moving away from rejected.
This has substantial side-effects because it creates another database This has substantial side-effects because it creates another database
object for the approved Domain and makes the user who created the object for the approved Domain and makes the user who created the
application into an admin on that domain. It also triggers an email application into an admin on that domain. It also triggers an email
@ -762,6 +807,9 @@ class DomainApplication(TimeStampedModel):
user=self.creator, domain=created_domain, role=UserDomainRole.Roles.MANAGER user=self.creator, domain=created_domain, role=UserDomainRole.Roles.MANAGER
) )
if self.status == self.ApplicationStatus.REJECTED:
self.rejection_reason = None
# == Send out an email == # # == Send out an email == #
self._send_status_update_email( self._send_status_update_email(
"application approved", "application approved",

View file

@ -15,7 +15,15 @@
{% if filters %} {% if filters %}
filtered by filtered by
{% for filter_param in filters %} {% for filter_param in filters %}
{{ filter_param.parameter_name }} = {{ filter_param.parameter_value }} {% if filter_param.parameter_name == 'is_election_board' %}
{%if filter_param.parameter_value == '0' %}
election office = No
{% else %}
election office = Yes
{% endif %}
{% else %}
{{ filter_param.parameter_name }} = {{ filter_param.parameter_value }}
{% endif %}
{% if not forloop.last %}, {% endif %} {% if not forloop.last %}, {% endif %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}

View file

@ -8,9 +8,58 @@ REQUEST RECEIVED ON: {{ application.submission_date|date }}
STATUS: Rejected STATUS: Rejected
---------------------------------------------------------------- ----------------------------------------------------------------
{% if application.rejection_reason != 'other' %}
REJECTION REASON{% endif %}{% if application.rejection_reason == 'purpose_not_met' %}
Your domain request was rejected because the purpose you provided did not meet our
requirements. You didnt provide enough information about how you intend to use the
domain.
Learn more about:
- Eligibility for a .gov domain <https://get.gov/domains/eligibility>
- What you can and cant do with .gov domains <https://get.gov/domains/requirements/>
If you have questions or comments, reply to this email.{% elif application.rejection_reason == 'requestor_not_eligible' %}
Your domain request was rejected because we dont believe youre eligible to request a
.gov domain on behalf of {{ application.organization_name }}. You must be a government employee, or be
working on behalf of a government organization, to request a .gov domain.
DEMONSTRATE ELIGIBILITY
If you can provide more information that demonstrates your eligibility, or you want to
discuss further, reply to this email.{% elif application.rejection_reason == 'org_has_domain' %}
Your domain request was rejected because {{ application.organization_name }} has a .gov domain. Our
practice is to approve one domain per online service per government organization. We
evaluate additional requests on a case-by-case basis. You did not provide sufficient
justification for an additional domain.
Read more about our practice of approving one domain per online service
<https://get.gov/domains/before/#one-domain-per-service>.
If you have questions or comments, reply to this email.{% elif application.rejection_reason == 'contacts_not_verified' %}
Your domain request was rejected because we could not verify the organizational
contacts you provided. If you have questions or comments, reply to this email.{% elif application.rejection_reason == 'org_not_eligible' %}
Your domain request was rejected because we determined that {{ application.organization_name }} is not
eligible for a .gov domain. .Gov domains are only available to official U.S.-based
government organizations.
DEMONSTRATE ELIGIBILITY
If you can provide documentation that demonstrates your eligibility, reply to this email.
This can include links to (or copies of) your authorizing legislation, your founding
charter or bylaws, or other similar documentation. Without this, we cant approve a
.gov domain for your organization. Learn more about eligibility for .gov domains
<https://get.gov/domains/eligibility/>.{% elif application.rejection_reason == 'naming_not_met' %}
Your domain request was rejected because it does not meet our naming requirements.
Domains should uniquely identify a government organization and be clear to the
general public. Learn more about naming requirements for your type of organization
<https://get.gov/domains/choosing/>.
YOU CAN SUBMIT A NEW REQUEST YOU CAN SUBMIT A NEW REQUEST
If your organization is eligible for a .gov domain and you meet our other requirements, you can submit a new request. We encourage you to request a domain that meets our requirements. If you have
questions or want to discuss potential domain names, reply to this email.{% elif application.rejection_reason == 'other' %}
YOU CAN SUBMIT A NEW REQUEST
If your organization is eligible for a .gov domain and you meet our other requirements, you can submit a new request.
Learn more about: Learn more about:
- Eligibility for a .gov domain <https://get.gov/domains/eligibility> - Eligibility for a .gov domain <https://get.gov/domains/eligibility>
@ -19,7 +68,7 @@ Learn more about:
NEED ASSISTANCE? NEED ASSISTANCE?
If you have questions about this domain request or need help choosing a new domain name, reply to this email. If you have questions about this domain request or need help choosing a new domain name, reply to this email.
{% endif %}
THANK YOU THANK YOU
.Gov helps the public identify official, trusted information. Thank you for requesting a .gov domain. .Gov helps the public identify official, trusted information. Thank you for requesting a .gov domain.

File diff suppressed because it is too large Load diff

View file

@ -1,31 +0,0 @@
from django.test import Client, TestCase, override_settings
from django.contrib.auth import get_user_model
class MyTestCase(TestCase):
def setUp(self):
self.client = Client()
username = "test_user"
first_name = "First"
last_name = "Last"
email = "info@example.com"
self.user = get_user_model().objects.create(
username=username, first_name=first_name, last_name=last_name, email=email
)
self.client.force_login(self.user)
def tearDown(self):
super().tearDown()
self.user.delete()
@override_settings(IS_PRODUCTION=True)
def test_production_environment(self):
"""No banner on prod."""
home_page = self.client.get("/")
self.assertNotContains(home_page, "You are on a test site.")
@override_settings(IS_PRODUCTION=False)
def test_non_production_environment(self):
"""Banner on non-prod."""
home_page = self.client.get("/")
self.assertContains(home_page, "You are on a test site.")

View file

@ -789,6 +789,56 @@ class TestDomainApplication(TestCase):
with self.assertRaises(TransitionNotAllowed): with self.assertRaises(TransitionNotAllowed):
self.approved_application.reject_with_prejudice() self.approved_application.reject_with_prejudice()
def test_approve_from_rejected_clears_rejection_reason(self):
"""When transitioning from rejected to approved on a domain request,
the rejection_reason is cleared."""
with less_console_noise():
# Create a sample application
application = completed_application(status=DomainApplication.ApplicationStatus.REJECTED)
application.rejection_reason = DomainApplication.RejectionReasons.DOMAIN_PURPOSE
# Approve
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
application.approve()
self.assertEqual(application.status, DomainApplication.ApplicationStatus.APPROVED)
self.assertEqual(application.rejection_reason, None)
def test_in_review_from_rejected_clears_rejection_reason(self):
"""When transitioning from rejected to in_review on a domain request,
the rejection_reason is cleared."""
with less_console_noise():
# Create a sample application
application = completed_application(status=DomainApplication.ApplicationStatus.REJECTED)
application.domain_is_not_active = True
application.rejection_reason = DomainApplication.RejectionReasons.DOMAIN_PURPOSE
# Approve
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
application.in_review()
self.assertEqual(application.status, DomainApplication.ApplicationStatus.IN_REVIEW)
self.assertEqual(application.rejection_reason, None)
def test_action_needed_from_rejected_clears_rejection_reason(self):
"""When transitioning from rejected to action_needed on a domain request,
the rejection_reason is cleared."""
with less_console_noise():
# Create a sample application
application = completed_application(status=DomainApplication.ApplicationStatus.REJECTED)
application.domain_is_not_active = True
application.rejection_reason = DomainApplication.RejectionReasons.DOMAIN_PURPOSE
# Approve
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
application.action_needed()
self.assertEqual(application.status, DomainApplication.ApplicationStatus.ACTION_NEEDED)
self.assertEqual(application.rejection_reason, None)
def test_has_rationale_returns_true(self): def test_has_rationale_returns_true(self):
"""has_rationale() returns true when an application has no_other_contacts_rationale""" """has_rationale() returns true when an application has no_other_contacts_rationale"""
with less_console_noise(): with less_console_noise():

View file

@ -321,24 +321,24 @@ class TestDomainCreation(MockEppLib):
Then a Domain exists in the database with the same `name` Then a Domain exists in the database with the same `name`
But a domain object does not exist in the registry But a domain object does not exist in the registry
""" """
draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov") with less_console_noise():
user, _ = User.objects.get_or_create() draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
investigator, _ = User.objects.get_or_create(username="frenchtoast", is_staff=True) user, _ = User.objects.get_or_create()
application = DomainApplication.objects.create( investigator, _ = User.objects.get_or_create(username="frenchtoast", is_staff=True)
creator=user, requested_domain=draft_domain, investigator=investigator application = DomainApplication.objects.create(
) creator=user, requested_domain=draft_domain, investigator=investigator
)
mock_client = MockSESClient() mock_client = MockSESClient()
with boto3_mocking.clients.handler_for("sesv2", mock_client): with boto3_mocking.clients.handler_for("sesv2", mock_client):
with less_console_noise(): # skip using the submit method
# skip using the submit method application.status = DomainApplication.ApplicationStatus.SUBMITTED
application.status = DomainApplication.ApplicationStatus.SUBMITTED # transition to approve state
# transition to approve state application.approve()
application.approve() # should have information present for this domain
# should have information present for this domain domain = Domain.objects.get(name="igorville.gov")
domain = Domain.objects.get(name="igorville.gov") self.assertTrue(domain)
self.assertTrue(domain) self.mockedSendFunction.assert_not_called()
self.mockedSendFunction.assert_not_called()
def test_accessing_domain_properties_creates_domain_in_registry(self): def test_accessing_domain_properties_creates_domain_in_registry(self):
""" """
@ -349,33 +349,34 @@ class TestDomainCreation(MockEppLib):
And `domain.state` is set to `UNKNOWN` And `domain.state` is set to `UNKNOWN`
And `domain.is_active()` returns False And `domain.is_active()` returns False
""" """
domain = Domain.objects.create(name="beef-tongue.gov") with less_console_noise():
# trigger getter domain = Domain.objects.create(name="beef-tongue.gov")
_ = domain.statuses # trigger getter
_ = domain.statuses
# contacts = PublicContact.objects.filter(domain=domain, # contacts = PublicContact.objects.filter(domain=domain,
# type=PublicContact.ContactTypeChoices.REGISTRANT).get() # type=PublicContact.ContactTypeChoices.REGISTRANT).get()
# Called in _fetch_cache # Called in _fetch_cache
self.mockedSendFunction.assert_has_calls( self.mockedSendFunction.assert_has_calls(
[ [
# TODO: due to complexity of the test, will return to it in # TODO: due to complexity of the test, will return to it in
# a future ticket # a future ticket
# call( # call(
# commands.CreateDomain(name="beef-tongue.gov", # commands.CreateDomain(name="beef-tongue.gov",
# id=contact.registry_id, auth_info=None), # id=contact.registry_id, auth_info=None),
# cleaned=True, # cleaned=True,
# ), # ),
call( call(
commands.InfoDomain(name="beef-tongue.gov", auth_info=None), commands.InfoDomain(name="beef-tongue.gov", auth_info=None),
cleaned=True, cleaned=True,
), ),
], ],
any_order=False, # Ensure calls are in the specified order any_order=False, # Ensure calls are in the specified order
) )
self.assertEqual(domain.state, Domain.State.UNKNOWN) self.assertEqual(domain.state, Domain.State.UNKNOWN)
self.assertEqual(domain.is_active(), False) self.assertEqual(domain.is_active(), False)
@skip("assertion broken with mock addition") @skip("assertion broken with mock addition")
def test_empty_domain_creation(self): def test_empty_domain_creation(self):
@ -385,7 +386,8 @@ class TestDomainCreation(MockEppLib):
def test_minimal_creation(self): def test_minimal_creation(self):
"""Can create with just a name.""" """Can create with just a name."""
Domain.objects.create(name="igorville.gov") with less_console_noise():
Domain.objects.create(name="igorville.gov")
@skip("assertion broken with mock addition") @skip("assertion broken with mock addition")
def test_duplicate_creation(self): def test_duplicate_creation(self):
@ -507,23 +509,24 @@ class TestDomainAvailable(MockEppLib):
res_data=[responses.check.CheckDomainResultData(name="available.gov", avail=True, reason=None)], res_data=[responses.check.CheckDomainResultData(name="available.gov", avail=True, reason=None)],
) )
patcher = patch("registrar.models.domain.registry.send") with less_console_noise():
mocked_send = patcher.start() patcher = patch("registrar.models.domain.registry.send")
mocked_send.side_effect = side_effect mocked_send = patcher.start()
mocked_send.side_effect = side_effect
available = Domain.available("available.gov") available = Domain.available("available.gov")
mocked_send.assert_has_calls( mocked_send.assert_has_calls(
[ [
call( call(
commands.CheckDomain( commands.CheckDomain(
["available.gov"], ["available.gov"],
), ),
cleaned=True, cleaned=True,
) )
] ]
) )
self.assertTrue(available) self.assertTrue(available)
patcher.stop() patcher.stop()
def test_domain_unavailable(self): def test_domain_unavailable(self):
""" """
@ -540,23 +543,24 @@ class TestDomainAvailable(MockEppLib):
res_data=[responses.check.CheckDomainResultData(name="unavailable.gov", avail=False, reason="In Use")], res_data=[responses.check.CheckDomainResultData(name="unavailable.gov", avail=False, reason="In Use")],
) )
patcher = patch("registrar.models.domain.registry.send") with less_console_noise():
mocked_send = patcher.start() patcher = patch("registrar.models.domain.registry.send")
mocked_send.side_effect = side_effect mocked_send = patcher.start()
mocked_send.side_effect = side_effect
available = Domain.available("unavailable.gov") available = Domain.available("unavailable.gov")
mocked_send.assert_has_calls( mocked_send.assert_has_calls(
[ [
call( call(
commands.CheckDomain( commands.CheckDomain(
["unavailable.gov"], ["unavailable.gov"],
), ),
cleaned=True, cleaned=True,
) )
] ]
) )
self.assertFalse(available) self.assertFalse(available)
patcher.stop() patcher.stop()
def test_domain_available_with_invalid_error(self): def test_domain_available_with_invalid_error(self):
""" """
@ -565,8 +569,9 @@ class TestDomainAvailable(MockEppLib):
Validate InvalidDomainError is raised Validate InvalidDomainError is raised
""" """
with self.assertRaises(errors.InvalidDomainError): with less_console_noise():
Domain.available("invalid-string") with self.assertRaises(errors.InvalidDomainError):
Domain.available("invalid-string")
def test_domain_available_with_empty_string(self): def test_domain_available_with_empty_string(self):
""" """
@ -575,8 +580,9 @@ class TestDomainAvailable(MockEppLib):
Validate InvalidDomainError is raised Validate InvalidDomainError is raised
""" """
with self.assertRaises(errors.InvalidDomainError): with less_console_noise():
Domain.available("") with self.assertRaises(errors.InvalidDomainError):
Domain.available("")
def test_domain_available_unsuccessful(self): def test_domain_available_unsuccessful(self):
""" """
@ -588,13 +594,14 @@ class TestDomainAvailable(MockEppLib):
def side_effect(_request, cleaned): def side_effect(_request, cleaned):
raise RegistryError(code=ErrorCode.COMMAND_SYNTAX_ERROR) raise RegistryError(code=ErrorCode.COMMAND_SYNTAX_ERROR)
patcher = patch("registrar.models.domain.registry.send") with less_console_noise():
mocked_send = patcher.start() patcher = patch("registrar.models.domain.registry.send")
mocked_send.side_effect = side_effect mocked_send = patcher.start()
mocked_send.side_effect = side_effect
with self.assertRaises(RegistryError): with self.assertRaises(RegistryError):
Domain.available("raises-error.gov") Domain.available("raises-error.gov")
patcher.stop() patcher.stop()
class TestRegistrantContacts(MockEppLib): class TestRegistrantContacts(MockEppLib):

View file

@ -1,4 +1,4 @@
from django.test import Client, TestCase from django.test import Client, TestCase, override_settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from .common import MockEppLib # type: ignore from .common import MockEppLib # type: ignore
@ -50,3 +50,32 @@ class TestWithUser(MockEppLib):
DomainApplication.objects.all().delete() DomainApplication.objects.all().delete()
DomainInformation.objects.all().delete() DomainInformation.objects.all().delete()
self.user.delete() self.user.delete()
class TestEnvironmentVariablesEffects(TestCase):
def setUp(self):
self.client = Client()
username = "test_user"
first_name = "First"
last_name = "Last"
email = "info@example.com"
self.user = get_user_model().objects.create(
username=username, first_name=first_name, last_name=last_name, email=email
)
self.client.force_login(self.user)
def tearDown(self):
super().tearDown()
self.user.delete()
@override_settings(IS_PRODUCTION=True)
def test_production_environment(self):
"""No banner on prod."""
home_page = self.client.get("/")
self.assertNotContains(home_page, "You are on a test site.")
@override_settings(IS_PRODUCTION=False)
def test_non_production_environment(self):
"""Banner on non-prod."""
home_page = self.client.get("/")
self.assertContains(home_page, "You are on a test site.")

View file

@ -15,7 +15,7 @@ class EmailSendingError(RuntimeError):
pass pass
def send_templated_email(template_name: str, subject_template_name: str, to_address: str, context={}): def send_templated_email(template_name: str, subject_template_name: str, to_address: str, bcc_address="", context={}):
"""Send an email built from a template to one email address. """Send an email built from a template to one email address.
template_name and subject_template_name are relative to the same template template_name and subject_template_name are relative to the same template
@ -40,10 +40,14 @@ def send_templated_email(template_name: str, subject_template_name: str, to_addr
except Exception as exc: except Exception as exc:
raise EmailSendingError("Could not access the SES client.") from exc raise EmailSendingError("Could not access the SES client.") from exc
destination = {"ToAddresses": [to_address]}
if bcc_address:
destination["BccAddresses"] = [bcc_address]
try: try:
ses_client.send_email( ses_client.send_email(
FromEmailAddress=settings.DEFAULT_FROM_EMAIL, FromEmailAddress=settings.DEFAULT_FROM_EMAIL,
Destination={"ToAddresses": [to_address]}, Destination=destination,
Content={ Content={
"Simple": { "Simple": {
"Subject": {"Data": subject}, "Subject": {"Data": subject},

View file

@ -14,6 +14,7 @@ from django.http import HttpResponseRedirect
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse from django.urls import reverse
from django.views.generic.edit import FormMixin from django.views.generic.edit import FormMixin
from django.conf import settings
from registrar.models import ( from registrar.models import (
Domain, Domain,
@ -707,7 +708,7 @@ class DomainAddUserView(DomainFormBaseView):
adding a success message to the view if the email sending succeeds""" adding a success message to the view if the email sending succeeds"""
# Set a default email address to send to for staff # Set a default email address to send to for staff
requestor_email = "help@get.gov" requestor_email = settings.DEFAULT_FROM_EMAIL
# Check if the email requestor has a valid email address # Check if the email requestor has a valid email address
if not requestor.is_staff and requestor.email is not None and requestor.email.strip() != "": if not requestor.is_staff and requestor.email is not None and requestor.email.strip() != "":