Merge branch 'main' into dk/1623-notify-analysts-with-contact-related-objects

This commit is contained in:
David Kennedy 2024-01-25 15:17:22 -05:00
commit d8c8e1b688
No known key found for this signature in database
GPG key ID: 6528A5386E66B96B
66 changed files with 950 additions and 184 deletions

View file

@ -26,7 +26,6 @@ on:
- rb - rb
- ko - ko
- ab - ab
- bl
- rjm - rjm
- dk - dk

View file

@ -26,7 +26,6 @@ on:
- rb - rb
- ko - ko
- ab - ab
- bl
- rjm - rjm
- dk - dk

View file

@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log # Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO DJANGO_LOG_LEVEL: INFO
# default public site location # default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments # Flag to disable/enable features in prod environments
IS_PRODUCTION: False IS_PRODUCTION: False
routes: routes:

View file

@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log # Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO DJANGO_LOG_LEVEL: INFO
# default public site location # default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments # Flag to disable/enable features in prod environments
IS_PRODUCTION: False IS_PRODUCTION: False
routes: routes:

View file

@ -1,32 +0,0 @@
---
applications:
- name: getgov-bl
buildpacks:
- python_buildpack
path: ../../src
instances: 1
memory: 512M
stack: cflinuxfs4
timeout: 180
command: ./run.sh
health-check-type: http
health-check-http-endpoint: /health
health-check-invocation-timeout: 40
env:
# Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup
# Tell Django where to find its configuration
DJANGO_SETTINGS_MODULE: registrar.config.settings
# Tell Django where it is being hosted
DJANGO_BASE_URL: https://getgov-bl.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
# Flag to disable/enable features in prod environments
IS_PRODUCTION: False
routes:
- route: getgov-bl.app.cloud.gov
services:
- getgov-credentials
- getgov-bl-database

View file

@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log # Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO DJANGO_LOG_LEVEL: INFO
# default public site location # default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments # Flag to disable/enable features in prod environments
IS_PRODUCTION: False IS_PRODUCTION: False
routes: routes:

View file

@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log # Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO DJANGO_LOG_LEVEL: INFO
# default public site location # default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments # Flag to disable/enable features in prod environments
IS_PRODUCTION: False IS_PRODUCTION: False
routes: routes:

View file

@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log # Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO DJANGO_LOG_LEVEL: INFO
# default public site location # default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments # Flag to disable/enable features in prod environments
IS_PRODUCTION: False IS_PRODUCTION: False
routes: routes:

View file

@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log # Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO DJANGO_LOG_LEVEL: INFO
# default public site location # default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments # Flag to disable/enable features in prod environments
IS_PRODUCTION: False IS_PRODUCTION: False
routes: routes:

View file

@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log # Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO DJANGO_LOG_LEVEL: INFO
# default public site location # default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments # Flag to disable/enable features in prod environments
IS_PRODUCTION: False IS_PRODUCTION: False
routes: routes:

View file

@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log # Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO DJANGO_LOG_LEVEL: INFO
# default public site location # default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments # Flag to disable/enable features in prod environments
IS_PRODUCTION: False IS_PRODUCTION: False
routes: routes:

View file

@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log # Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO DJANGO_LOG_LEVEL: INFO
# default public site location # default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments # Flag to disable/enable features in prod environments
IS_PRODUCTION: False IS_PRODUCTION: False
routes: routes:

View file

@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log # Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO DJANGO_LOG_LEVEL: INFO
# default public site location # default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments # Flag to disable/enable features in prod environments
IS_PRODUCTION: False IS_PRODUCTION: False
routes: routes:

View file

@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log # Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO DJANGO_LOG_LEVEL: INFO
# default public site location # default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments # Flag to disable/enable features in prod environments
IS_PRODUCTION: False IS_PRODUCTION: False
routes: routes:

View file

@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log # Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO DJANGO_LOG_LEVEL: INFO
# default public site location # default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments # Flag to disable/enable features in prod environments
IS_PRODUCTION: False IS_PRODUCTION: False
routes: routes:

View file

@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log # Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO DJANGO_LOG_LEVEL: INFO
# default public site location # default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov GETGOV_PUBLIC_SITE_URL: https://get.gov
# Which OIDC provider to use # Which OIDC provider to use
OIDC_ACTIVE_PROVIDER: login.gov production OIDC_ACTIVE_PROVIDER: login.gov production
# Flag to disable/enable features in prod environments # Flag to disable/enable features in prod environments

View file

@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log # Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO DJANGO_LOG_LEVEL: INFO
# default public site location # default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments # Flag to disable/enable features in prod environments
IS_PRODUCTION: False IS_PRODUCTION: False
routes: routes:

View file

@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log # Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO DJANGO_LOG_LEVEL: INFO
# default public site location # default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments # Flag to disable/enable features in prod environments
IS_PRODUCTION: False IS_PRODUCTION: False
routes: routes:

View file

@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log # Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO DJANGO_LOG_LEVEL: INFO
# default public site location # default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov GETGOV_PUBLIC_SITE_URL: https://get.gov
# use a non-default route to avoid conflicts # use a non-default route to avoid conflicts
routes: routes:
- route: getgov-ENVIRONMENT-migrate.app.cloud.gov - route: getgov-ENVIRONMENT-migrate.app.cloud.gov

View file

@ -22,7 +22,7 @@ applications:
# Tell Django how much stuff to log # Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO DJANGO_LOG_LEVEL: INFO
# default public site location # default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments # Flag to disable/enable features in prod environments
IS_PRODUCTION: False IS_PRODUCTION: False
routes: routes:

View file

@ -33,6 +33,10 @@ class AuthenticationFailed(OIDCException):
friendly_message = "This login attempt didn't work." friendly_message = "This login attempt didn't work."
class NoStateDefined(OIDCException):
friendly_message = "The session state is None."
class InternalError(OIDCException): class InternalError(OIDCException):
status = status.INTERNAL_SERVER_ERROR status = status.INTERNAL_SERVER_ERROR
friendly_message = "The system broke while trying to log you in." friendly_message = "The system broke while trying to log you in."

View file

@ -183,6 +183,8 @@ class Client(oic.Client):
if authn_response["state"] != session.get("state", None): if authn_response["state"] != session.get("state", None):
# this most likely means the user's Django session vanished # this most likely means the user's Django session vanished
logger.error("Received state not the same as expected for %s" % state) logger.error("Received state not the same as expected for %s" % state)
if session.get("state", None) is None:
raise o_e.NoStateDefined()
raise o_e.AuthenticationFailed(locator=state) raise o_e.AuthenticationFailed(locator=state)
if self.behaviour.get("response_type") == "code": if self.behaviour.get("response_type") == "code":
@ -272,6 +274,11 @@ class Client(oic.Client):
super(Client, self).store_response(resp, info) super(Client, self).store_response(resp, info)
def get_default_acr_value(self):
"""returns the acr_value from settings
this helper function is called from djangooidc views"""
return self.behaviour.get("acr_value")
def get_step_up_acr_value(self): def get_step_up_acr_value(self):
"""returns the step_up_acr_value from settings """returns the step_up_acr_value from settings
this helper function is called from djangooidc views""" this helper function is called from djangooidc views"""

View file

@ -3,6 +3,8 @@ from unittest.mock import MagicMock, patch
from django.http import HttpResponse from django.http import HttpResponse
from django.test import Client, TestCase, RequestFactory from django.test import Client, TestCase, RequestFactory
from django.urls import reverse from django.urls import reverse
from djangooidc.exceptions import NoStateDefined
from ..views import login_callback from ..views import login_callback
from .common import less_console_noise from .common import less_console_noise
@ -17,6 +19,9 @@ class ViewsTest(TestCase):
def say_hi(*args): def say_hi(*args):
return HttpResponse("Hi") return HttpResponse("Hi")
def create_acr(*args):
return "any string"
def user_info(*args): def user_info(*args):
return { return {
"sub": "TEST", "sub": "TEST",
@ -34,6 +39,7 @@ class ViewsTest(TestCase):
callback_url = reverse("openid_login_callback") callback_url = reverse("openid_login_callback")
# mock # mock
mock_client.create_authn_request.side_effect = self.say_hi mock_client.create_authn_request.side_effect = self.say_hi
mock_client.get_default_acr_value.side_effect = self.create_acr
# test # test
response = self.client.get(reverse("login"), {"next": callback_url}) response = self.client.get(reverse("login"), {"next": callback_url})
# assert # assert
@ -53,6 +59,19 @@ class ViewsTest(TestCase):
self.assertTemplateUsed(response, "500.html") self.assertTemplateUsed(response, "500.html")
self.assertIn("Server error", response.content.decode("utf-8")) self.assertIn("Server error", response.content.decode("utf-8"))
def test_callback_with_no_session_state(self, mock_client):
"""If the local session is None (ie the server restarted while user was logged out),
we do not throw an exception. Rather, we attempt to login again."""
# mock
mock_client.get_default_acr_value.side_effect = self.create_acr
mock_client.callback.side_effect = NoStateDefined()
# test
with less_console_noise():
response = self.client.get(reverse("openid_login_callback"))
# assert
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "/")
def test_login_callback_reads_next(self, mock_client): def test_login_callback_reads_next(self, mock_client):
# setup # setup
session = self.client.session session = self.client.session

View file

@ -55,6 +55,10 @@ def error_page(request, error):
def openid(request): def openid(request):
"""Redirect the user to an authentication provider (OP).""" """Redirect the user to an authentication provider (OP)."""
# If the session reset because of a server restart, attempt to login again
request.session["acr_value"] = CLIENT.get_default_acr_value()
request.session["next"] = request.GET.get("next", "/") request.session["next"] = request.GET.get("next", "/")
try: try:
@ -78,9 +82,13 @@ def login_callback(request):
if user: if user:
login(request, user) login(request, user)
logger.info("Successfully logged in user %s" % user) logger.info("Successfully logged in user %s" % user)
# Double login bug (1507)?
return redirect(request.session.get("next", "/")) return redirect(request.session.get("next", "/"))
else: else:
raise o_e.BannedUser() raise o_e.BannedUser()
except o_e.NoStateDefined as nsd_err:
logger.warning(f"No State Defined: {nsd_err}")
return redirect(request.session.get("next", "/"))
except Exception as err: except Exception as err:
return error_page(request, err) return error_page(request, err)

View file

@ -32,7 +32,7 @@ services:
# Is this a production environment # Is this a production environment
- IS_PRODUCTION - IS_PRODUCTION
# Public site URL link # Public site URL link
- GETGOV_PUBLIC_SITE_URL=https://beta.get.gov - GETGOV_PUBLIC_SITE_URL=https://get.gov
# Set a username for accessing the registry # Set a username for accessing the registry
- REGISTRY_CL_ID=nothing - REGISTRY_CL_ID=nothing
# Set a password for accessing the registry # Set a password for accessing the registry

View file

@ -665,7 +665,7 @@ class DomainInformationAdmin(ListHeaderAdmin):
), ),
("Anything else?", {"fields": ["anything_else"]}), ("Anything else?", {"fields": ["anything_else"]}),
( (
"Requirements for operating .gov domains", "Requirements for operating a .gov domain",
{"fields": ["is_policy_acknowledged"]}, {"fields": ["is_policy_acknowledged"]},
), ),
] ]
@ -834,7 +834,7 @@ class DomainApplicationAdmin(ListHeaderAdmin):
), ),
("Anything else?", {"fields": ["anything_else"]}), ("Anything else?", {"fields": ["anything_else"]}),
( (
"Requirements for operating .gov domains", "Requirements for operating a .gov domain",
{"fields": ["is_policy_acknowledged"]}, {"fields": ["is_policy_acknowledged"]},
), ),
] ]
@ -1294,6 +1294,29 @@ class DraftDomainAdmin(ListHeaderAdmin):
search_help_text = "Search by draft domain name." search_help_text = "Search by draft domain name."
class VeryImportantPersonAdmin(ListHeaderAdmin):
list_display = ("email", "requestor", "truncated_notes", "created_at")
search_fields = ["email"]
search_help_text = "Search by email."
list_filter = [
"requestor",
]
readonly_fields = [
"requestor",
]
def truncated_notes(self, obj):
# Truncate the 'notes' field to 50 characters
return str(obj.notes)[:50]
truncated_notes.short_description = "Notes (Truncated)" # type: ignore
def save_model(self, request, obj, form, change):
# Set the user field to the current admin user
obj.requestor = request.user if request.user.is_authenticated else None
super().save_model(request, obj, form, change)
admin.site.unregister(LogEntry) # Unregister the default registration admin.site.unregister(LogEntry) # Unregister the default registration
admin.site.register(LogEntry, CustomLogEntryAdmin) admin.site.register(LogEntry, CustomLogEntryAdmin)
admin.site.register(models.User, MyUserAdmin) admin.site.register(models.User, MyUserAdmin)
@ -1314,3 +1337,4 @@ admin.site.register(models.Website, WebsiteAdmin)
admin.site.register(models.PublicContact, AuditedAdmin) admin.site.register(models.PublicContact, AuditedAdmin)
admin.site.register(models.DomainApplication, DomainApplicationAdmin) admin.site.register(models.DomainApplication, DomainApplicationAdmin)
admin.site.register(models.TransitionDomain, TransitionDomainAdmin) admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
admin.site.register(models.VeryImportantPerson, VeryImportantPersonAdmin)

View file

@ -269,10 +269,22 @@ function removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier){
// h2 and legend for DS form, label for nameservers // h2 and legend for DS form, label for nameservers
Array.from(form.querySelectorAll('h2, legend, label, p')).forEach((node) => { Array.from(form.querySelectorAll('h2, legend, label, p')).forEach((node) => {
let innerSpan = node.querySelector('span')
if (innerSpan) {
innerSpan.textContent = innerSpan.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`);
} else {
node.textContent = node.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`);
node.textContent = node.textContent.replace(formExampleRegex, `ns${index + 1}`);
}
// If the node is a nameserver label, one of the first 2 which was previously 3 and up (not required) // If the node is a nameserver label, one of the first 2 which was previously 3 and up (not required)
// inject the USWDS required markup and make sure the INPUT is required // inject the USWDS required markup and make sure the INPUT is required
if (isNameserversForm && index <= 1 && node.innerHTML.includes('server') && !node.innerHTML.includes('*')) { if (isNameserversForm && index <= 1 && node.innerHTML.includes('server') && !node.innerHTML.includes('*')) {
// Remove the word optional
innerSpan.textContent = innerSpan.textContent.replace(/\s*\(\s*optional\s*\)\s*/, '');
// Create a new element // Create a new element
const newElement = document.createElement('abbr'); const newElement = document.createElement('abbr');
newElement.textContent = '*'; newElement.textContent = '*';
@ -295,13 +307,8 @@ function removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier){
nextInputElement.required = true; nextInputElement.required = true;
} }
let innerSpan = node.querySelector('span')
if (innerSpan) {
innerSpan.textContent = innerSpan.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`);
} else {
node.textContent = node.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`);
node.textContent = node.textContent.replace(formExampleRegex, `ns${index + 1}`);
}
}); });
// Display the add more button if we have less than 13 forms // Display the add more button if we have less than 13 forms
@ -521,16 +528,24 @@ function hideDeletedForms() {
formNum++; formNum++;
newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `${formIdentifier}-${formNum-1}-`); newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `${formIdentifier}-${formNum-1}-`);
// For the other contacts form, we need to update the fieldset headers based on what's visible vs hidden,
// since the form on the backend employs Django's DELETE widget. For the other formsets, we delete the form
// in JS (completely remove from teh DOM) so we update the headers/labels based on total number of forms.
if (isOtherContactsForm) { if (isOtherContactsForm) {
// For the other contacts form, we need to update the fieldset headers based on what's visible vs hidden,
// since the form on the backend employs Django's DELETE widget.
let totalShownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`).length; let totalShownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`).length;
newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${totalShownForms + 1}`); newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${totalShownForms + 1}`);
} else { } else {
newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum}`); // Nameservers form is cloned from index 2 which has the word optional on init, does not have the word optional
// if indices 0 or 1 have been deleted
let containsOptional = newForm.innerHTML.includes('(optional)');
if (isNameserversForm && !containsOptional) {
newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum} (optional)`);
} else {
newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum}`);
}
} }
newForm.innerHTML = newForm.innerHTML.replace(formExampleRegex, `ns${formNum}`); newForm.innerHTML = newForm.innerHTML.replace(formExampleRegex, `ns${formNum}`);
newForm.innerHTML = newForm.innerHTML.replace(/\n/g, ''); // Remove newline characters
newForm.innerHTML = newForm.innerHTML.replace(/>\s*</g, '><'); // Remove spaces between tags
container.insertBefore(newForm, addButton); container.insertBefore(newForm, addButton);
newForm.style.display = 'block'; newForm.style.display = 'block';

View file

@ -25,6 +25,22 @@
color: color('primary-darker'); color: color('primary-darker');
padding-bottom: units(2px); padding-bottom: units(2px);
} }
// Ticket #1510
// @include at-media('desktop') {
// th:first-child {
// width: 220px;
// }
// th:nth-child(2) {
// width: 175px;
// }
// th:nth-child(3) {
// width: 130px;
// }
// th:nth-child(5) {
// width: 130px;
// }
// }
} }
.dotgov-table { .dotgov-table {

View file

@ -335,7 +335,7 @@ CSP_INCLUDE_NONCE_IN = ["script-src-elem"]
# Cross-Origin Resource Sharing (CORS) configuration # Cross-Origin Resource Sharing (CORS) configuration
# Sets clients that allow access control to manage.get.gov # Sets clients that allow access control to manage.get.gov
# TODO: remove :8080 to see if we can have all localhost access # TODO: remove :8080 to see if we can have all localhost access
CORS_ALLOWED_ORIGINS = ["http://localhost:8080", "https://beta.get.gov"] CORS_ALLOWED_ORIGINS = ["http://localhost:8080", "https://beta.get.gov", "https://get.gov"]
CORS_ALLOWED_ORIGIN_REGEXES = [r"https://[\w-]+\.sites\.pages\.cloud\.gov"] CORS_ALLOWED_ORIGIN_REGEXES = [r"https://[\w-]+\.sites\.pages\.cloud\.gov"]
# Content-Length header is set by django.middleware.common.CommonMiddleware # Content-Length header is set by django.middleware.common.CommonMiddleware
@ -660,7 +660,6 @@ ALLOWED_HOSTS = [
"getgov-rb.app.cloud.gov", "getgov-rb.app.cloud.gov",
"getgov-ko.app.cloud.gov", "getgov-ko.app.cloud.gov",
"getgov-ab.app.cloud.gov", "getgov-ab.app.cloud.gov",
"getgov-bl.app.cloud.gov",
"getgov-rjm.app.cloud.gov", "getgov-rjm.app.cloud.gov",
"getgov-dk.app.cloud.gov", "getgov-dk.app.cloud.gov",
"manage.get.gov", "manage.get.gov",

View file

@ -137,6 +137,11 @@ urlpatterns = [
views.DomainInvitationDeleteView.as_view(http_method_names=["post"]), views.DomainInvitationDeleteView.as_view(http_method_names=["post"]),
name="invitation-delete", name="invitation-delete",
), ),
path(
"application/<int:pk>/delete",
views.DomainApplicationDeleteView.as_view(http_method_names=["post"]),
name="application-delete",
),
] ]
# we normally would guard these with `if settings.DEBUG` but tests run with # we normally would guard these with `if settings.DEBUG` but tests run with

View file

@ -487,7 +487,8 @@ class DotGovDomainForm(RegistrarForm):
values = {} values = {}
requested_domain = getattr(obj, "requested_domain", None) requested_domain = getattr(obj, "requested_domain", None)
if requested_domain is not None: if requested_domain is not None:
values["requested_domain"] = Domain.sld(requested_domain.name) domain_name = requested_domain.name
values["requested_domain"] = Domain.sld(domain_name)
return values return values
def clean_requested_domain(self): def clean_requested_domain(self):
@ -837,8 +838,8 @@ class AnythingElseForm(RegistrarForm):
class RequirementsForm(RegistrarForm): class RequirementsForm(RegistrarForm):
is_policy_acknowledged = forms.BooleanField( is_policy_acknowledged = forms.BooleanField(
label="I read and agree to the requirements for operating .gov domains.", label="I read and agree to the requirements for operating a .gov domain.",
error_messages={ error_messages={
"required": ("Check the box if you read and agree to the requirements for operating .gov domains.") "required": ("Check the box if you read and agree to the requirements for operating a .gov domain.")
}, },
) )

View file

@ -0,0 +1,69 @@
""""
Converts all ready and DNS needed domains with a non-default public contact
to disclose their public contact. Created for Issue#1535 to resolve
disclose issue of domains with missing security emails.
"""
import logging
import copy
from django.core.management import BaseCommand
from registrar.models import Domain
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Disclose all nondefault domain security emails."
def __init__(self):
"""Sets global variables for code tidiness"""
super().__init__()
# domains with errors, which are not successfully updated to disclose
self.domains_with_errors: list[str] = []
# domains that are successfully disclosed
self.disclosed_domain_contacts_count = 0
# domains that skip disclose due to having contact registrar@dotgov.gov
self.skipped_domain_contacts_count = 0
def handle(self, **options):
"""
Converts all ready and DNS needed domains with a non-default public contact
to disclose their public contact.
"""
logger.info("Updating security emails to public")
# Initializes domains that need to be disclosed
statuses = [Domain.State.READY, Domain.State.DNS_NEEDED]
domains = Domain.objects.filter(state__in=statuses)
logger.info(f"Found {len(domains)} domains with status Ready or DNS Needed.")
# Update EPP contact for domains with a security contact
for domain in domains:
try:
contact = domain.security_contact # noqa on these items as we only want to call security_contact
logger.info(f"Domain {domain.name} security contact: {domain.security_contact.email}")
if domain.security_contact.email != "registrar@dotgov.gov":
domain._update_epp_contact(contact=domain.security_contact)
self.disclosed_domain_contacts_count += 1
else:
logger.info(
f"Skipping disclose for {domain.name} security contact {domain.security_contact.email}."
)
self.skipped_domain_contacts_count += 1
except Exception as err:
# error condition if domain not in database
self.domains_with_errors.append(copy.deepcopy(domain.name))
logger.error(f"error retrieving domain {domain.name} contact {domain.security_contact}: {err}")
# Inform user how many contacts were disclosed, skipped, and errored
logger.info(f"Updated {self.disclosed_domain_contacts_count} contacts to disclosed.")
logger.info(
f"Skipped disclosing {self.skipped_domain_contacts_count} contacts with security email "
f"registrar@dotgov.gov."
)
logger.info(
f"Error disclosing the following {len(self.domains_with_errors)} contacts: {self.domains_with_errors}"
)

View file

@ -0,0 +1,37 @@
# Generated by Django 4.2.7 on 2024-01-23 23:51
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("registrar", "0062_alter_host_name"),
]
operations = [
migrations.CreateModel(
name="VeryImportantPerson",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("email", models.EmailField(db_index=True, help_text="Email", max_length=254)),
("notes", models.TextField(help_text="Notes")),
(
"requestor",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="verifiedby_user",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
]

View file

@ -0,0 +1,24 @@
# Generated by Django 4.2.7 on 2024-01-23 22:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0063_veryimportantperson"),
]
operations = [
migrations.AlterField(
model_name="domainapplication",
name="address_line1",
field=models.TextField(blank=True, help_text="Street address", null=True, verbose_name="Address line 1"),
),
migrations.AlterField(
model_name="domainapplication",
name="address_line2",
field=models.TextField(
blank=True, help_text="Street address line 2 (optional)", null=True, verbose_name="Address line 2"
),
),
]

View file

@ -13,6 +13,7 @@ from .user import User
from .user_group import UserGroup from .user_group import UserGroup
from .website import Website from .website import Website
from .transition_domain import TransitionDomain from .transition_domain import TransitionDomain
from .very_important_person import VeryImportantPerson
__all__ = [ __all__ = [
"Contact", "Contact",
@ -29,6 +30,7 @@ __all__ = [
"UserGroup", "UserGroup",
"Website", "Website",
"TransitionDomain", "TransitionDomain",
"VeryImportantPerson",
] ]
auditlog.register(Contact) auditlog.register(Contact)
@ -45,3 +47,4 @@ auditlog.register(User, m2m_fields=["user_permissions", "groups"])
auditlog.register(UserGroup, m2m_fields=["permissions"]) auditlog.register(UserGroup, m2m_fields=["permissions"])
auditlog.register(Website) auditlog.register(Website)
auditlog.register(TransitionDomain) auditlog.register(TransitionDomain)
auditlog.register(VeryImportantPerson)

View file

@ -1396,11 +1396,13 @@ class Domain(TimeStampedModel, DomainHelper):
def _disclose_fields(self, contact: PublicContact): def _disclose_fields(self, contact: PublicContact):
"""creates a disclose object that can be added to a contact Create using """creates a disclose object that can be added to a contact Create using
.disclose= <this function> on the command before sending. .disclose= <this function> on the command before sending.
if item is security email then make sure email is visable""" if item is security email then make sure email is visible"""
is_security = contact.contact_type == contact.ContactTypeChoices.SECURITY is_security = contact.contact_type == contact.ContactTypeChoices.SECURITY
DF = epp.DiscloseField DF = epp.DiscloseField
fields = {DF.EMAIL} fields = {DF.EMAIL}
disclose = is_security and contact.email != PublicContact.get_default_security().email disclose = is_security and contact.email != PublicContact.get_default_security().email
# Delete after testing on other devices
logger.info("Updated domain contact %s to disclose: %s", contact.email, disclose)
# Will only disclose DF.EMAIL if its not the default # Will only disclose DF.EMAIL if its not the default
return epp.Disclose( return epp.Disclose(
flag=disclose, flag=disclose,

View file

@ -431,11 +431,13 @@ class DomainApplication(TimeStampedModel):
null=True, null=True,
blank=True, blank=True,
help_text="Street address", help_text="Street address",
verbose_name="Address line 1",
) )
address_line2 = models.TextField( address_line2 = models.TextField(
null=True, null=True,
blank=True, blank=True,
help_text="Street address line 2 (optional)", help_text="Street address line 2 (optional)",
verbose_name="Address line 2",
) )
city = models.TextField( city = models.TextField(
null=True, null=True,

View file

@ -7,6 +7,7 @@ from registrar.models.user_domain_role import UserDomainRole
from .domain_invitation import DomainInvitation from .domain_invitation import DomainInvitation
from .transition_domain import TransitionDomain from .transition_domain import TransitionDomain
from .very_important_person import VeryImportantPerson
from .domain import Domain from .domain import Domain
from phonenumber_field.modelfields import PhoneNumberField # type: ignore from phonenumber_field.modelfields import PhoneNumberField # type: ignore
@ -89,6 +90,10 @@ class User(AbstractUser):
if TransitionDomain.objects.filter(username=email).exists(): if TransitionDomain.objects.filter(username=email).exists():
return False return False
# New users flagged by Staff to bypass ial2
if VeryImportantPerson.objects.filter(email=email).exists():
return False
# A new incoming user who is being invited to be a domain manager (that is, # A new incoming user who is being invited to be a domain manager (that is,
# their email address is in DomainInvitation for an invitation that is not yet "retrieved"). # their email address is in DomainInvitation for an invitation that is not yet "retrieved").
invited = DomainInvitation.DomainInvitationStatus.INVITED invited = DomainInvitation.DomainInvitationStatus.INVITED

View file

@ -0,0 +1,32 @@
from django.db import models
from .utility.time_stamped_model import TimeStampedModel
class VeryImportantPerson(TimeStampedModel):
"""emails that get added to this table will bypass ial2 on login."""
email = models.EmailField(
null=False,
blank=False,
help_text="Email",
db_index=True,
)
requestor = models.ForeignKey(
"registrar.User",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="verifiedby_user",
)
notes = models.TextField(
null=False,
blank=False,
help_text="Notes",
)
def __str__(self):
return self.email

View file

@ -1,7 +1,7 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load static form_helpers url_helpers %} {% load static form_helpers url_helpers %}
{% block title %}Apply for a .gov domain | {{form_titles|get_item:steps.current}} | {% endblock %} {% block title %}Request a .gov domain | {{form_titles|get_item:steps.current}} | {% endblock %}
{% block content %} {% block content %}
<div class="grid-container"> <div class="grid-container">
<div class="grid-row grid-gap"> <div class="grid-row grid-gap">

View file

@ -14,7 +14,7 @@
<h2>Time to complete the form</h2> <h2>Time to complete the form</h2>
<p>If you have <a href="{% public_site_url 'domains/before/#information-you%E2%80%99ll-need-to-complete-the-domain-request-form' %}" target="_blank" class="usa-link">all the information you need</a>, <p>If you have <a href="{% public_site_url 'domains/before/#information-you%E2%80%99ll-need-to-complete-the-domain-request-form' %}" target="_blank" class="usa-link">all the information you need</a>,
completing your domain request might take around 15 minutes.</p> completing your domain request might take around 15 minutes.</p>
<p><a href="{% public_site_url 'contact/' %}" target="_blank" rel="noopener noreferrer" class="usa-link">Contact us if you need help with your request</a>.</p>
{% block form_buttons %} {% block form_buttons %}
<div class="stepnav"> <div class="stepnav">
@ -29,6 +29,7 @@
</form> </form>
<div class="caption margin-top-5"><a href="{% public_site_url 'privacy-policy#pra' %}" target="_blank" rel="noopener noreferrer" class="usa-link">Paperwork Reduction Act statement</a> (OMB control number: 1670-0049; expiration date: 10/31/2026)</div>
</div> </div>
</main> </main>
{% endblock %} {% endblock %}

View file

@ -1,8 +1,8 @@
{% extends 'application_form.html' %} {% extends 'application_form.html' %}
{% load field_helpers %} {% load field_helpers url_helpers %}
{% block form_instructions %} {% block form_instructions %}
<p>If your domain request is approved, the name of your organization and your city/state will be listed in <a href="https://beta.get.gov/about/data/" target="_blank">.govs public data.</a></p> <p>If your domain request is approved, the name of your organization and your city/state will be listed in <a href="{% public_site_url 'about/data/' %}" target="_blank">.govs public data.</a></p>
<h2>What is the name and mailing address of the organization you represent?</h2> <h2>What is the name and mailing address of the organization you represent?</h2>

View file

@ -2,11 +2,11 @@
{% load static field_helpers %} {% load static field_helpers %}
{% block form_instructions %} {% block form_instructions %}
<p>To help us assess your eligibility for a .gov domain, please provide contact information for other employees from your organization. <p>To help us determine your organizations eligibility for a .gov domain, its helpful to have contact information for other employees from your organization.
<ul class="usa-list"> <ul class="usa-list">
<li>They should be clearly and publicly affiliated with your organization and familiar with your domain request. </li> <li>They should be clearly and publicly affiliated with your organization and familiar with your domain request.</li>
<li>They don't need to be involved with the technical management of your domain (although they can be). </li> <li>They dont need to be involved with the technical management of your domain (although they can be).</li>
<li>We typically dont reach out to these employees, but if contact is necessary, our practice is to coordinate first with you, the requestor. </li> <li><strong>We typically dont reach out to these employees</strong>, but if contact is necessary, our practice is to coordinate with you first.</li>
</ul> </ul>
</p> </p>
@ -88,9 +88,7 @@
<legend> <legend>
<h2 class="margin-bottom-0">No other employees from your organization?</h2> <h2 class="margin-bottom-0">No other employees from your organization?</h2>
</legend> </legend>
<p>You don't need to provide names of other employees now, but it may <p>You dont need to provide names of other employees now, but it may slow down our assessment of your eligibility. Describe why there are no other employees who can help verify your request.</p>
slow down our assessment of your eligibility. Describe why there are
no other employees who can help verify your request.</p>
{% with attr_maxlength=1000 add_label_class="usa-sr-only" %} {% with attr_maxlength=1000 add_label_class="usa-sr-only" %}
{% input_with_errors forms.2.no_other_contacts_rationale %} {% input_with_errors forms.2.no_other_contacts_rationale %}
{% endwith %} {% endwith %}

View file

@ -2,7 +2,7 @@
{% load field_helpers %} {% load field_helpers %}
{% block form_instructions %} {% block form_instructions %}
<p>Please read this page. Check the box at the bottom to show that you agree to the requirements for operating .gov domains.</p> <p>Please read this page. Check the box at the bottom to show that you agree to the requirements for operating a .gov domain.</p>
<p>The .gov domain space exists to support a broad diversity of government missions. Generally, we dont review or audit how government organizations use their registered domains. However, misuse of a .gov domain can reflect upon the integrity of the entire .gov space. There are categories of misuse that are statutorily prohibited or abusive in nature.</p> <p>The .gov domain space exists to support a broad diversity of government missions. Generally, we dont review or audit how government organizations use their registered domains. However, misuse of a .gov domain can reflect upon the integrity of the entire .gov space. There are categories of misuse that are statutorily prohibited or abusive in nature.</p>

View file

@ -90,7 +90,7 @@
{% endif %} {% endif %}
{% if domainapplication.current_websites.all %} {% if domainapplication.current_websites.all %}
{% include "includes/summary_item.html" with title='Current website for your organization' value=domainapplication.current_websites.all list='true' heading_level=heading_level %} {% include "includes/summary_item.html" with title='Current websites' value=domainapplication.current_websites.all list='true' heading_level=heading_level %}
{% endif %} {% endif %}
{% if domainapplication.requested_domain %} {% if domainapplication.requested_domain %}

View file

@ -70,19 +70,6 @@
<script src="{% static 'js/uswds.min.js' %}" defer></script> <script src="{% static 'js/uswds.min.js' %}" defer></script>
<a class="usa-skipnav" href="#main-content">Skip to main content</a> <a class="usa-skipnav" href="#main-content">Skip to main content</a>
{% if IS_DEMO_SITE %}
<section aria-label="Alert" >
<div class="usa-alert usa-alert--info">
<div class="usa-alert__body">
<h4 class="usa-alert__heading">New domain requests are paused</h4>
<p class="usa-alert__text measure-none">
This is the new registrar for managing .gov domains. Note that were not accepting requests for new .gov domains until January 2024. Follow .gov updates at <a href="https://get.gov/updates/" class="usa-link">get.gov/updates/</a>.
</p>
</div>
</div>
</section>
{% endif %}
<section class="usa-banner" aria-label="Official website of the United States government"> <section class="usa-banner" aria-label="Official website of the United States government">
<div class="usa-accordion"> <div class="usa-accordion">
<header class="usa-banner__header"> <header class="usa-banner__header">

View file

@ -10,7 +10,7 @@
{% if widget.attrs.required %} {% if widget.attrs.required %}
<!--Don't add asterisk to one-field forms --> <!--Don't add asterisk to one-field forms -->
{% if field.label == "Is your organization an election office?" or field.label == "What .gov domain do you want?" or field.label == "I read and agree to the requirements for operating .gov domains." or field.label == "Please explain why there are no other employees from your organization we can contact to help us assess your eligibility for a .gov domain." %} {% if field.label == "Is your organization an election office?" or field.label == "What .gov domain do you want?" or field.label == "I read and agree to the requirements for operating a .gov domain." or field.label == "Please explain why there are no other employees from your organization we can contact to help us assess your eligibility for a .gov domain." %}
{% else %} {% else %}
<abbr class="usa-hint usa-hint--required" title="required">*</abbr> <abbr class="usa-hint usa-hint--required" title="required">*</abbr>
{% endif %} {% endif %}

View file

@ -1,7 +1,7 @@
{% extends "domain_base.html" %} {% extends "domain_base.html" %}
{% load static field_helpers %} {% load static field_helpers %}
{% block title %}Add another user | {% endblock %} {% block title %}Add a domain manager | {% endblock %}
{% block domain_content %} {% block domain_content %}
<h1>Add a domain manager</h1> <h1>Add a domain manager</h1>

View file

@ -20,7 +20,7 @@
<button <button
type="submit" type="submit"
class="usa-button" class="usa-button"
>{% if form.security_email.value is None or form.security_email.value == "dotgov@cisa.dhs.gov"%}Add security email{% else %}Save{% endif %}</button> >{% if form.security_email.value is None or form.security_email.value == "dotgov@cisa.dhs.gov" or form.security_email.value == "registrar@dotgov.gov"%}Add security email{% else %}Save{% endif %}</button>
</form> </form>
{% endblock %} {# domain_content #} {% endblock %} {# domain_content #}

View file

@ -1,7 +1,7 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #} {% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi. Hi.
{{ requester_email }} has added you as a manager on {{ domain.name }}. {{ requestor_email }} has added you as a manager on {{ domain.name }}.
You can manage this domain on the .gov registrar <https://manage.get.gov>. You can manage this domain on the .gov registrar <https://manage.get.gov>.

View file

@ -17,7 +17,7 @@ About your organization:
Authorizing official: Authorizing official:
{% spaceless %}{% include "emails/includes/contact.txt" with contact=application.authorizing_official %}{% endspaceless %} {% spaceless %}{% include "emails/includes/contact.txt" with contact=application.authorizing_official %}{% endspaceless %}
{% if application.current_websites.exists %}{# if block makes a newline #} {% if application.current_websites.exists %}{# if block makes a newline #}
Current website for your organization: {% for site in application.current_websites.all %} Current websites: {% for site in application.current_websites.all %}
{% spaceless %}{{ site.website }}{% endspaceless %} {% spaceless %}{{ site.website }}{% endspaceless %}
{% endfor %}{% endif %} {% endfor %}{% endif %}
.gov domain: .gov domain:

View file

@ -32,6 +32,14 @@ Learn more about:
- Domain security best practices <https://get.gov/domains/security/> - Domain security best practices <https://get.gov/domains/security/>
WELL PRELOAD THIS DOMAIN
We add new .gov domains to the HSTS preload list each month. This requires browsers to use a secure HTTPS connection to any website at this domain and ensures the content you publish is exactly what your visitors get. It also means youll need to support HTTPS anywhere the domain is used for websites on the internet or internally. Well add your domain to the preload list soon.
Learn more about:
- What preloading is <https://get.gov/domains/security/#preload-your-domain>
- Why we preload new domains <https://get.gov/posts/2021-06-21-an-intent-to-preload/>
THANK YOU THANK YOU
.Gov helps the public identify official, trusted information. Thank you for using a .gov domain. .Gov helps the public identify official, trusted information. Thank you for using a .gov domain.

View file

@ -9,29 +9,17 @@
{% if user.is_authenticated %} {% if user.is_authenticated %}
{# the entire logged in page goes here #} {# the entire logged in page goes here #}
<div class="tablet:grid-offset-1 desktop:grid-offset-2"> <div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1">
<h1>Manage your domains</h2> <h1>Manage your domains</h2>
<p class="margin-top-4"> <p class="margin-top-4">
{% if IS_PRODUCTION %}
<a href="javascript:void(0)"
class="usa-button usa-tooltip disabled-link"
data-position="right"
title="Coming in 2024"
aria-disabled="true"
data-tooltip="true"
>
Start a new domain request
</a>
{% else %}
<a href="{% url 'application:' %}" class="usa-button" <a href="{% url 'application:' %}" class="usa-button"
> >
Start a new domain request Start a new domain request
</a> </a>
{% endif %}
</p> </p>
<section class="section--outlined tablet:grid-col-11 desktop:grid-col-10"> <section class="section--outlined">
<h2>Domains</h2> <h2>Domains</h2>
{% if domains %} {% if domains %}
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked"> <table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
@ -41,7 +29,12 @@
<th data-sortable scope="col" role="columnheader">Domain name</th> <th data-sortable scope="col" role="columnheader">Domain name</th>
<th data-sortable scope="col" role="columnheader">Expires</th> <th data-sortable scope="col" role="columnheader">Expires</th>
<th data-sortable scope="col" role="columnheader">Status</th> <th data-sortable scope="col" role="columnheader">Status</th>
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th> <th
scope="col"
role="columnheader"
>
<span class="usa-sr-only">Action</span>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -58,7 +51,7 @@
{% elif domain.state == domain.State.UNKNOWN or domain.state == domain.State.DNS_NEEDED %} {% elif domain.state == domain.State.UNKNOWN or domain.state == domain.State.DNS_NEEDED %}
DNS needed DNS needed
{% else %} {% else %}
{{ domain.state|title }} {{ domain.state|capfirst }}
{% endif %} {% endif %}
</td> </td>
<td> <td>
@ -94,7 +87,7 @@
{% endif %} {% endif %}
</section> </section>
<section class="section--outlined tablet:grid-col-11 desktop:grid-col-10"> <section class="section--outlined">
<h2>Domain requests</h2> <h2>Domain requests</h2>
{% if domain_applications %} {% if domain_applications %}
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked"> <table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
@ -105,13 +98,23 @@
<th data-sortable scope="col" role="columnheader">Date submitted</th> <th data-sortable scope="col" role="columnheader">Date submitted</th>
<th data-sortable scope="col" role="columnheader">Status</th> <th data-sortable scope="col" role="columnheader">Status</th>
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th> <th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th>
{% if has_deletable_applications %}
<th scope="col" role="columnheader"><span class="usa-sr-only">Delete Action</span></th>
{% endif %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for application in domain_applications %} {% for application in domain_applications %}
<tr> <tr>
<th th scope="row" role="rowheader" data-label="Domain name"> <th th scope="row" role="rowheader" data-label="Domain name">
{{ application.requested_domain.name|default:"New domain request" }} {% if application.requested_domain is None %}
New domain request
{# Add a breakpoint #}
<div aria-hidden="true"></div>
<span class="text-base font-body-xs">({{ application.created_at }} UTC)</span>
{% else %}
{{ application.requested_domain.name }}
{% endif %}
</th> </th>
<td data-sort-value="{{ application.submission_date|date:"U" }}" data-label="Date submitted"> <td data-sort-value="{{ application.submission_date|date:"U" }}" data-label="Date submitted">
{% if application.submission_date %} {% if application.submission_date %}
@ -122,22 +125,88 @@
</td> </td>
<td data-label="Status">{{ application.get_status_display }}</td> <td data-label="Status">{{ application.get_status_display }}</td>
<td> <td>
{% if application.status == "started" or application.status == "action needed" or application.status == "withdrawn" %} {% with prefix="New domain request ("%}
<a href="{% url 'edit-application' application.pk %}"> {% with date=application.created_at|date:"DATETIME_FORMAT"%}
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24"> {% with name_default=prefix|add:date|add:" UTC)"%}
<use xlink:href="{%static 'img/sprite.svg'%}#edit"></use> {% if application.status == application.ApplicationStatus.STARTED or application.status == application.ApplicationStatus.ACTION_NEEDED or application.status == application.ApplicationStatus.WITHDRAWN %}
</svg> <a href="{% url 'edit-application' application.pk %}">
Edit <span class="usa-sr-only">{{ application.requested_domain.name|default:"New domain request" }} </span> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#edit"></use>
{% else %} </svg>
<a href="{% url 'application-status' application.pk %}"> {% if application.requested_domain is not None%}
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24"> Edit <span class="usa-sr-only">{{ application.requested_domain.name }}</span>
<use xlink:href="{%static 'img/sprite.svg'%}#settings"></use> {% else %}
</svg> Edit <span class="usa-sr-only">{{ name_default }}</span>
Manage <span class="usa-sr-only">{{application.requested_domain.name}} </span> {% endif %}
{% endif %} {% else %}
<a href="{% url 'application-status' application.pk %}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#settings"></use>
</svg>
Manage <span class="usa-sr-only">{{ application.requested_domain.name|default:name_default }}</span>
{% endif %}
{% endwith %}
{% endwith %}
{% endwith %}
</a> </a>
</td> </td>
{% if has_deletable_applications %}
<td>
{% if application.status == "started" or application.status == "withdrawn" %}
<a
role="button"
id="button-toggle-delete-domain-alert-{{ forloop.counter }}"
href="#toggle-delete-domain-alert-{{ forloop.counter }}"
class="usa-button--unstyled text-no-underline"
aria-controls="toggle-delete-domain-alert-{{ forloop.counter }}"
data-open-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
</svg>
{% with prefix="New domain request ("%}
{% with date=application.created_at|date:"DATETIME_FORMAT"%}
{% with name_default=prefix|add:date|add:" UTC)"%}
{% if application.requested_domain is not None %}
Delete <span class="usa-sr-only">{{ application.requested_domain.name }}</span>
{% else %}
Delete <span class="usa-sr-only">{{ name_default }}</span>
{% endif %}
{% endwith %}
{% endwith %}
{% endwith %}
</a>
<div
class="usa-modal"
id="toggle-delete-domain-alert-{{ forloop.counter }}"
aria-labelledby="Are you sure you want to continue?"
aria-describedby="Domain will be removed"
data-force-action
>
<form method="POST" action="{% url "application-delete" pk=application.id %}">
{% if application.requested_domain is None %}
{% if application.created_at %}
{% with prefix="(created " %}
{% with formatted_date=application.created_at|date:"DATETIME_FORMAT" %}
{% with modal_content=prefix|add:formatted_date|add:" UTC)" %}
{% include 'includes/modal.html' with modal_heading="Are you sure you want to delete this domain request?" modal_description="This will remove the domain request "|add:modal_content|add:" from the .gov registrar. This action cannot be undone." modal_button=modal_button|safe %}
{% endwith %}
{% endwith %}
{% endwith %}
{% else %}
{% include 'includes/modal.html' with modal_heading="Are you sure you want to delete New domain request?" modal_description="This will remove the domain request from the .gov registrar. This action cannot be undone." modal_button=modal_button|safe %}
{% endif %}
{% else %}
{% with modal_heading_value=application.requested_domain.name|add:"?" %}
{% include 'includes/modal.html' with modal_heading="Are you sure you want to delete" heading_value=modal_heading_value modal_description="This will remove the domain request from the .gov registrar. This action cannot be undone." modal_button=modal_button|safe %}
{% endwith %}
{% endif %}
</form>
</div>
{% endif %}
</td>
{% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View file

@ -4,6 +4,11 @@
<div class="usa-modal__main"> <div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading"> <h2 class="usa-modal__heading" id="modal-1-heading">
{{ modal_heading }} {{ modal_heading }}
{% if heading_value is not None %}
{# Add a breakpoint #}
<div aria-hidden="true"></div>
{{ heading_value }}
{% endif %}
</h2> </h2>
<div class="usa-prose"> <div class="usa-prose">
<p id="modal-1-description"> <p id="modal-1-description">

View file

@ -14,9 +14,11 @@ from registrar.admin import (
ContactAdmin, ContactAdmin,
DomainInformationAdmin, DomainInformationAdmin,
UserDomainRoleAdmin, UserDomainRoleAdmin,
VeryImportantPersonAdmin,
) )
from registrar.models import Domain, DomainApplication, DomainInformation, User, DomainInvitation, Contact, Website from registrar.models import Domain, DomainApplication, DomainInformation, User, DomainInvitation, Contact, Website
from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_domain_role import UserDomainRole
from registrar.models.very_important_person import VeryImportantPerson
from .common import ( from .common import (
MockSESClient, MockSESClient,
AuditedAdminMockData, AuditedAdminMockData,
@ -1804,3 +1806,28 @@ class ContactAdminTest(TestCase):
DomainApplication.objects.all().delete() DomainApplication.objects.all().delete()
Contact.objects.all().delete() Contact.objects.all().delete()
User.objects.all().delete() User.objects.all().delete()
class VeryImportantPersonAdminTestCase(TestCase):
def setUp(self):
self.superuser = create_superuser()
self.factory = RequestFactory()
def test_save_model_sets_user_field(self):
self.client.force_login(self.superuser)
# Create an instance of the admin class
admin_instance = VeryImportantPersonAdmin(model=VeryImportantPerson, admin_site=None)
# Create a VeryImportantPerson instance
vip_instance = VeryImportantPerson(email="test@example.com", notes="Test Notes")
# Create a request object
request = self.factory.post("/admin/yourapp/veryimportantperson/add/")
request.user = self.superuser
# Call the save_model method
admin_instance.save_model(request, vip_instance, None, None)
# Check that the user field is set to the request.user
self.assertEqual(vip_instance.requestor, self.superuser)

View file

@ -47,7 +47,7 @@ class TestEmails(TestCase):
# check for optional things # check for optional things
self.assertIn("Other employees from your organization:", body) self.assertIn("Other employees from your organization:", body)
self.assertIn("Testy2 Tester2", body) self.assertIn("Testy2 Tester2", body)
self.assertIn("Current website for your organization:", body) self.assertIn("Current websites:", body)
self.assertIn("city.com", body) self.assertIn("city.com", body)
self.assertIn("About your organization:", body) self.assertIn("About your organization:", body)
self.assertIn("Anything else", body) self.assertIn("Anything else", body)
@ -61,7 +61,7 @@ class TestEmails(TestCase):
application.submit() application.submit()
_, kwargs = self.mock_client.send_email.call_args _, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertNotIn("Current website for your organization:", body) self.assertNotIn("Current websites:", body)
# spacing should be right between adjacent elements # spacing should be right between adjacent elements
self.assertRegex(body, r"5555\n\n.gov domain:") self.assertRegex(body, r"5555\n\n.gov domain:")
@ -74,9 +74,9 @@ class TestEmails(TestCase):
application.submit() application.submit()
_, kwargs = self.mock_client.send_email.call_args _, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertIn("Current website for your organization:", body) self.assertIn("Current websites:", body)
# spacing should be right between adjacent elements # spacing should be right between adjacent elements
self.assertRegex(body, r"5555\n\nCurrent website for") self.assertRegex(body, r"5555\n\nCurrent websites:")
self.assertRegex(body, r"city.com\n\n.gov domain:") self.assertRegex(body, r"city.com\n\n.gov domain:")
@boto3_mocking.patching @boto3_mocking.patching

View file

@ -101,7 +101,7 @@ class TestFormValidation(MockEppLib):
( (
"whitehouse.gov", "whitehouse.gov",
"That domain isnt available. <a class='usa-link' " "That domain isnt available. <a class='usa-link' "
"href='https://beta.get.gov/domains/choosing' target='_blank'>Read more about " "href='https://get.gov/domains/choosing' target='_blank'>Read more about "
"choosing your .gov domain</a>.", "choosing your .gov domain</a>.",
), ),
] ]
@ -151,7 +151,7 @@ class TestFormValidation(MockEppLib):
( (
"whitehouse.gov", "whitehouse.gov",
"That domain isnt available. <a class='usa-link' " "That domain isnt available. <a class='usa-link' "
"href='https://beta.get.gov/domains/choosing' target='_blank'>Read more about " "href='https://get.gov/domains/choosing' target='_blank'>Read more about "
"choosing your .gov domain</a>.", "choosing your .gov domain</a>.",
), ),
] ]
@ -338,7 +338,7 @@ class TestFormValidation(MockEppLib):
form = RequirementsForm(data={}) form = RequirementsForm(data={})
self.assertEqual( self.assertEqual(
form.errors["is_policy_acknowledged"], form.errors["is_policy_acknowledged"],
["Check the box if you read and agree to the requirements for operating .gov domains."], ["Check the box if you read and agree to the requirements for operating a .gov domain."],
) )
def test_requirements_form_unchecked(self): def test_requirements_form_unchecked(self):
@ -346,7 +346,7 @@ class TestFormValidation(MockEppLib):
form = RequirementsForm(data={"is_policy_acknowledged": False}) form = RequirementsForm(data={"is_policy_acknowledged": False})
self.assertEqual( self.assertEqual(
form.errors["is_policy_acknowledged"], form.errors["is_policy_acknowledged"],
["Check the box if you read and agree to the requirements for operating .gov domains."], ["Check the box if you read and agree to the requirements for operating a .gov domain."],
) )
def test_tribal_government_unrecognized(self): def test_tribal_government_unrecognized(self):

View file

@ -11,9 +11,11 @@ from registrar.models import (
DomainInformation, DomainInformation,
UserDomainRole, UserDomainRole,
) )
from registrar.models.public_contact import PublicContact
from django.core.management import call_command from django.core.management import call_command
from unittest.mock import patch from unittest.mock import patch, call
from epplibwrapper import commands, common
from .common import MockEppLib from .common import MockEppLib
@ -441,3 +443,57 @@ class TestExtendExpirationDates(MockEppLib):
# Explicitly test the expiration date - should be the same # Explicitly test the expiration date - should be the same
self.assertEqual(desired_domain.expiration_date, datetime.date(2024, 11, 15)) self.assertEqual(desired_domain.expiration_date, datetime.date(2024, 11, 15))
class TestDiscloseEmails(MockEppLib):
def setUp(self):
super().setUp()
def tearDown(self):
super().tearDown()
PublicContact.objects.all().delete()
Domain.objects.all().delete()
def run_disclose_security_emails(self):
"""
This method executes the disclose_security_emails command.
The 'call_command' function from Django's management framework is then used to
execute the disclose_security_emails command.
"""
with patch(
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", # noqa
return_value=True,
):
call_command("disclose_security_emails")
def test_disclose_security_emails(self):
"""
Tests that command disclose_security_emails runs successfully with
appropriate EPP calll to UpdateContact.
"""
domain, _ = Domain.objects.get_or_create(name="testdisclose.gov", state=Domain.State.READY)
expectedSecContact = PublicContact.get_default_security()
expectedSecContact.domain = domain
expectedSecContact.email = "123@mail.gov"
# set domain security email to 123@mail.gov instead of default email
domain.security_contact = expectedSecContact
self.run_disclose_security_emails()
# running disclose_security_emails sends EPP call UpdateContact with disclose
self.mockedSendFunction.assert_has_calls(
[
call(
commands.UpdateContact(
id=domain.security_contact.registry_id,
postal_info=domain._make_epp_contact_postal_info(contact=domain.security_contact),
email=domain.security_contact.email,
voice=domain.security_contact.voice,
fax=domain.security_contact.fax,
auth_info=common.ContactAuthInfo(pw="2fooBAR123fooBaz"),
disclose=domain._disclose_fields(contact=domain.security_contact),
),
cleaned=True,
)
]
)

View file

@ -15,7 +15,8 @@ from registrar.models import (
) )
import boto3_mocking import boto3_mocking
from registrar.models.transition_domain import TransitionDomain # type: ignore from registrar.models.transition_domain import TransitionDomain
from registrar.models.very_important_person import VeryImportantPerson # type: ignore
from .common import MockSESClient, less_console_noise, completed_application from .common import MockSESClient, less_console_noise, completed_application
from django_fsm import TransitionNotAllowed from django_fsm import TransitionNotAllowed
@ -652,6 +653,12 @@ class TestUser(TestCase):
TransitionDomain.objects.get_or_create(username=self.user.email, domain_name=self.domain_name) TransitionDomain.objects.get_or_create(username=self.user.email, domain_name=self.domain_name)
self.assertFalse(User.needs_identity_verification(self.user.email, self.user.username)) self.assertFalse(User.needs_identity_verification(self.user.email, self.user.username))
def test_identity_verification_with_very_important_person(self):
"""A Very Important Person should return False
when tested with class method needs_identity_verification"""
VeryImportantPerson.objects.get_or_create(email=self.user.email)
self.assertFalse(User.needs_identity_verification(self.user.email, self.user.username))
def test_identity_verification_with_invited_user(self): def test_identity_verification_with_invited_user(self):
"""An invited user should return False when tested with class """An invited user should return False when tested with class
method needs_identity_verification""" method needs_identity_verification"""

View file

@ -399,7 +399,7 @@ class ExportDataTest(MockEppLib):
"adomain10.gov,Federal,Armed Forces Retirement Home,Ready\n" "adomain10.gov,Federal,Armed Forces Retirement Home,Ready\n"
"adomain2.gov,Interstate,(blank),Dns needed\n" "adomain2.gov,Interstate,(blank),Dns needed\n"
"ddomain3.gov,Federal,Armed Forces Retirement Home,123@mail.gov,On hold,2023-05-25\n" "ddomain3.gov,Federal,Armed Forces Retirement Home,123@mail.gov,On hold,2023-05-25\n"
"defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,dotgov@cisa.dhs.gov,Ready" "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,(blank),Ready"
) )
# Normalize line endings and remove commas, # Normalize line endings and remove commas,

View file

@ -89,17 +89,230 @@ class LoggedInTests(TestWithUser):
super().setUp() super().setUp()
self.client.force_login(self.user) self.client.force_login(self.user)
def tearDown(self):
super().tearDown()
Contact.objects.all().delete()
def test_home_lists_domain_applications(self): def test_home_lists_domain_applications(self):
response = self.client.get("/") response = self.client.get("/")
self.assertNotContains(response, "igorville.gov") self.assertNotContains(response, "igorville.gov")
site = DraftDomain.objects.create(name="igorville.gov") site = DraftDomain.objects.create(name="igorville.gov")
application = DomainApplication.objects.create(creator=self.user, requested_domain=site) application = DomainApplication.objects.create(creator=self.user, requested_domain=site)
response = self.client.get("/") response = self.client.get("/")
# count = 2 because it is also in screenreader content
self.assertContains(response, "igorville.gov", count=2) # count = 7 because of screenreader content
self.assertContains(response, "igorville.gov", count=7)
# clean up # clean up
application.delete() application.delete()
def test_home_deletes_withdrawn_domain_application(self):
"""Tests if the user can delete a DomainApplication in the 'withdrawn' status"""
site = DraftDomain.objects.create(name="igorville.gov")
application = DomainApplication.objects.create(
creator=self.user, requested_domain=site, status=DomainApplication.ApplicationStatus.WITHDRAWN
)
# Ensure that igorville.gov exists on the page
home_page = self.client.get("/")
self.assertContains(home_page, "igorville.gov")
# Check if the delete button exists. We can do this by checking for its id and text content.
self.assertContains(home_page, "Delete")
self.assertContains(home_page, "button-toggle-delete-domain-alert-1")
# Trigger the delete logic
response = self.client.post(reverse("application-delete", kwargs={"pk": application.pk}), follow=True)
self.assertNotContains(response, "igorville.gov")
# clean up
application.delete()
def test_home_deletes_started_domain_application(self):
"""Tests if the user can delete a DomainApplication in the 'started' status"""
site = DraftDomain.objects.create(name="igorville.gov")
application = DomainApplication.objects.create(
creator=self.user, requested_domain=site, status=DomainApplication.ApplicationStatus.STARTED
)
# Ensure that igorville.gov exists on the page
home_page = self.client.get("/")
self.assertContains(home_page, "igorville.gov")
# Check if the delete button exists. We can do this by checking for its id and text content.
self.assertContains(home_page, "Delete")
self.assertContains(home_page, "button-toggle-delete-domain-alert-1")
# Trigger the delete logic
response = self.client.post(reverse("application-delete", kwargs={"pk": application.pk}), follow=True)
self.assertNotContains(response, "igorville.gov")
# clean up
application.delete()
def test_home_doesnt_delete_other_domain_applications(self):
"""Tests to ensure the user can't delete Applications not in the status of STARTED or WITHDRAWN"""
# Given that we are including a subset of items that can be deleted while excluding the rest,
# subTest is appropriate here as otherwise we would need many duplicate tests for the same reason.
draft_domain = DraftDomain.objects.create(name="igorville.gov")
for status in DomainApplication.ApplicationStatus:
if status not in [
DomainApplication.ApplicationStatus.STARTED,
DomainApplication.ApplicationStatus.WITHDRAWN,
]:
with self.subTest(status=status):
application = DomainApplication.objects.create(
creator=self.user, requested_domain=draft_domain, status=status
)
# Trigger the delete logic
response = self.client.post(
reverse("application-delete", kwargs={"pk": application.pk}), follow=True
)
# Check for a 403 error - the end user should not be allowed to do this
self.assertEqual(response.status_code, 403)
desired_application = DomainApplication.objects.filter(requested_domain=draft_domain)
# Make sure the DomainApplication wasn't deleted
self.assertEqual(desired_application.count(), 1)
# clean up
application.delete()
def test_home_deletes_domain_application_and_orphans(self):
"""Tests if delete for DomainApplication deletes orphaned Contact objects"""
# Create the site and contacts to delete (orphaned)
contact = Contact.objects.create(
first_name="Henry",
last_name="Mcfakerson",
)
contact_shared = Contact.objects.create(
first_name="Relative",
last_name="Aether",
)
# Create two non-orphaned contacts
contact_2 = Contact.objects.create(
first_name="Saturn",
last_name="Mars",
)
# Attach a user object to a contact (should not be deleted)
contact_user, _ = Contact.objects.get_or_create(user=self.user)
site = DraftDomain.objects.create(name="igorville.gov")
application = DomainApplication.objects.create(
creator=self.user,
requested_domain=site,
status=DomainApplication.ApplicationStatus.WITHDRAWN,
authorizing_official=contact,
submitter=contact_user,
)
application.other_contacts.set([contact_2])
# Create a second application to attach contacts to
site_2 = DraftDomain.objects.create(name="teaville.gov")
application_2 = DomainApplication.objects.create(
creator=self.user,
requested_domain=site_2,
status=DomainApplication.ApplicationStatus.STARTED,
authorizing_official=contact_2,
submitter=contact_shared,
)
application_2.other_contacts.set([contact_shared])
# Ensure that igorville.gov exists on the page
home_page = self.client.get("/")
self.assertContains(home_page, "igorville.gov")
# Trigger the delete logic
response = self.client.post(reverse("application-delete", kwargs={"pk": application.pk}), follow=True)
# igorville is now deleted
self.assertNotContains(response, "igorville.gov")
# Check if the orphaned contact was deleted
orphan = Contact.objects.filter(id=contact.id)
self.assertFalse(orphan.exists())
# All non-orphan contacts should still exist and are unaltered
try:
current_user = Contact.objects.filter(id=contact_user.id).get()
except Contact.DoesNotExist:
self.fail("contact_user (a non-orphaned contact) was deleted")
self.assertEqual(current_user, contact_user)
try:
edge_case = Contact.objects.filter(id=contact_2.id).get()
except Contact.DoesNotExist:
self.fail("contact_2 (a non-orphaned contact) was deleted")
self.assertEqual(edge_case, contact_2)
def test_home_deletes_domain_application_and_shared_orphans(self):
"""Test the edge case for an object that will become orphaned after a delete
(but is not an orphan at the time of deletion)"""
# Create the site and contacts to delete (orphaned)
contact = Contact.objects.create(
first_name="Henry",
last_name="Mcfakerson",
)
contact_shared = Contact.objects.create(
first_name="Relative",
last_name="Aether",
)
# Create two non-orphaned contacts
contact_2 = Contact.objects.create(
first_name="Saturn",
last_name="Mars",
)
# Attach a user object to a contact (should not be deleted)
contact_user, _ = Contact.objects.get_or_create(user=self.user)
site = DraftDomain.objects.create(name="igorville.gov")
application = DomainApplication.objects.create(
creator=self.user,
requested_domain=site,
status=DomainApplication.ApplicationStatus.WITHDRAWN,
authorizing_official=contact,
submitter=contact_user,
)
application.other_contacts.set([contact_2])
# Create a second application to attach contacts to
site_2 = DraftDomain.objects.create(name="teaville.gov")
application_2 = DomainApplication.objects.create(
creator=self.user,
requested_domain=site_2,
status=DomainApplication.ApplicationStatus.STARTED,
authorizing_official=contact_2,
submitter=contact_shared,
)
application_2.other_contacts.set([contact_shared])
home_page = self.client.get("/")
self.assertContains(home_page, "teaville.gov")
# Trigger the delete logic
response = self.client.post(reverse("application-delete", kwargs={"pk": application_2.pk}), follow=True)
self.assertNotContains(response, "teaville.gov")
# Check if the orphaned contact was deleted
orphan = Contact.objects.filter(id=contact_shared.id)
self.assertFalse(orphan.exists())
def test_application_form_view(self): def test_application_form_view(self):
response = self.client.get("/request/", follow=True) response = self.client.get("/request/", follow=True)
self.assertContains( self.assertContains(
@ -2590,7 +2803,7 @@ class TestDomainManagers(TestDomainOverview):
) )
@boto3_mocking.patching @boto3_mocking.patching
def test_domain_invitation_email_has_email_as_requester_non_existent(self): def test_domain_invitation_email_has_email_as_requestor_non_existent(self):
"""Inviting a non existent user sends them an email, with email as the name.""" """Inviting a non existent user sends them an email, with email as the name."""
# make sure there is no user with this email # make sure there is no user with this email
email_address = "mayor@igorville.gov" email_address = "mayor@igorville.gov"
@ -2623,13 +2836,13 @@ class TestDomainManagers(TestDomainOverview):
email_content = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] email_content = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertIn("info@example.com", email_content) self.assertIn("info@example.com", email_content)
# Check that the requesters first/last name do not exist # Check that the requestors first/last name do not exist
self.assertNotIn("First", email_content) self.assertNotIn("First", email_content)
self.assertNotIn("Last", email_content) self.assertNotIn("Last", email_content)
self.assertNotIn("First Last", email_content) self.assertNotIn("First Last", email_content)
@boto3_mocking.patching @boto3_mocking.patching
def test_domain_invitation_email_has_email_as_requester(self): def test_domain_invitation_email_has_email_as_requestor(self):
"""Inviting a user sends them an email, with email as the name.""" """Inviting a user sends them an email, with email as the name."""
# Create a fake user object # Create a fake user object
email_address = "mayor@igorville.gov" email_address = "mayor@igorville.gov"
@ -2662,13 +2875,13 @@ class TestDomainManagers(TestDomainOverview):
email_content = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] email_content = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertIn("info@example.com", email_content) self.assertIn("info@example.com", email_content)
# Check that the requesters first/last name do not exist # Check that the requestors first/last name do not exist
self.assertNotIn("First", email_content) self.assertNotIn("First", email_content)
self.assertNotIn("Last", email_content) self.assertNotIn("Last", email_content)
self.assertNotIn("First Last", email_content) self.assertNotIn("First Last", email_content)
@boto3_mocking.patching @boto3_mocking.patching
def test_domain_invitation_email_has_email_as_requester_staff(self): def test_domain_invitation_email_has_email_as_requestor_staff(self):
"""Inviting a user sends them an email, with email as the name.""" """Inviting a user sends them an email, with email as the name."""
# Create a fake user object # Create a fake user object
email_address = "mayor@igorville.gov" email_address = "mayor@igorville.gov"
@ -2705,7 +2918,7 @@ class TestDomainManagers(TestDomainOverview):
email_content = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] email_content = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertIn("help@get.gov", email_content) self.assertIn("help@get.gov", email_content)
# Check that the requesters first/last name do not exist # Check that the requestors first/last name do not exist
self.assertNotIn("First", email_content) self.assertNotIn("First", email_content)
self.assertNotIn("Last", email_content) self.assertNotIn("Last", email_content)
self.assertNotIn("First Last", email_content) self.assertNotIn("First Last", email_content)

View file

@ -38,7 +38,7 @@ def write_row(writer, columns, domain_info):
if security_contacts: if security_contacts:
security_email = security_contacts[0].email security_email = security_contacts[0].email
invalid_emails = {"registrar@dotgov.gov"} invalid_emails = {"registrar@dotgov.gov", "dotgov@cisa.dhs.gov"}
# These are default emails that should not be displayed in the csv report # These are default emails that should not be displayed in the csv report
if security_email is not None and security_email.lower() in invalid_emails: if security_email is not None and security_email.lower() in invalid_emails:
security_email = "(blank)" security_email = "(blank)"

View file

@ -1,5 +1,5 @@
import logging import logging
from collections import defaultdict
from django.http import Http404, HttpResponse, HttpResponseRedirect from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import resolve, reverse from django.urls import resolve, reverse
@ -10,8 +10,11 @@ from django.contrib import messages
from registrar.forms import application_wizard as forms from registrar.forms import application_wizard as forms
from registrar.models import DomainApplication from registrar.models import DomainApplication
from registrar.models.contact import Contact
from registrar.models.user import User
from registrar.utility import StrEnum from registrar.utility import StrEnum
from registrar.views.utility import StepsHelper from registrar.views.utility import StepsHelper
from registrar.views.utility.permission_views import DomainApplicationPermissionDeleteView
from .utility import ( from .utility import (
DomainApplicationPermissionView, DomainApplicationPermissionView,
@ -83,13 +86,13 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
Step.ORGANIZATION_CONTACT: _("Organization name and mailing address"), Step.ORGANIZATION_CONTACT: _("Organization name and mailing address"),
Step.ABOUT_YOUR_ORGANIZATION: _("About your organization"), Step.ABOUT_YOUR_ORGANIZATION: _("About your organization"),
Step.AUTHORIZING_OFFICIAL: _("Authorizing official"), Step.AUTHORIZING_OFFICIAL: _("Authorizing official"),
Step.CURRENT_SITES: _("Current website for your organization"), Step.CURRENT_SITES: _("Current websites"),
Step.DOTGOV_DOMAIN: _(".gov domain"), Step.DOTGOV_DOMAIN: _(".gov domain"),
Step.PURPOSE: _("Purpose of your domain"), Step.PURPOSE: _("Purpose of your domain"),
Step.YOUR_CONTACT: _("Your contact information"), Step.YOUR_CONTACT: _("Your contact information"),
Step.OTHER_CONTACTS: _("Other employees from your organization"), Step.OTHER_CONTACTS: _("Other employees from your organization"),
Step.ANYTHING_ELSE: _("Anything else?"), Step.ANYTHING_ELSE: _("Anything else?"),
Step.REQUIREMENTS: _("Requirements for operating .gov domains"), Step.REQUIREMENTS: _("Requirements for operating a .gov domain"),
Step.REVIEW: _("Review and submit your domain request"), Step.REVIEW: _("Review and submit your domain request"),
} }
@ -128,20 +131,26 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
if self._application: if self._application:
return self._application return self._application
# For linter. The else block should never be hit, but if it does,
# there may be a UI consideration. That will need to be handled in another ticket.
creator = None
if self.request.user is not None and isinstance(self.request.user, User):
creator = self.request.user
else:
raise ValueError("Invalid value for User")
if self.has_pk(): if self.has_pk():
id = self.storage["application_id"] id = self.storage["application_id"]
try: try:
self._application = DomainApplication.objects.get( self._application = DomainApplication.objects.get(
creator=self.request.user, # type: ignore creator=creator,
pk=id, pk=id,
) )
return self._application return self._application
except DomainApplication.DoesNotExist: except DomainApplication.DoesNotExist:
logger.debug("Application id %s did not have a DomainApplication" % id) logger.debug("Application id %s did not have a DomainApplication" % id)
self._application = DomainApplication.objects.create( self._application = DomainApplication.objects.create(creator=self.request.user)
creator=self.request.user, # type: ignore
)
self.storage["application_id"] = self._application.id self.storage["application_id"] = self._application.id
return self._application return self._application
@ -150,7 +159,6 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
def storage(self): def storage(self):
# marking session as modified on every access # marking session as modified on every access
# so that updates to nested keys are always saved # so that updates to nested keys are always saved
# push to sandbox will remove
self.request.session.modified = True self.request.session.modified = True
return self.request.session.setdefault(self.prefix, {}) return self.request.session.setdefault(self.prefix, {})
@ -611,3 +619,102 @@ class ApplicationWithdrawn(DomainApplicationPermissionWithdrawView):
application.withdraw() application.withdraw()
application.save() application.save()
return HttpResponseRedirect(reverse("home")) return HttpResponseRedirect(reverse("home"))
class DomainApplicationDeleteView(DomainApplicationPermissionDeleteView):
"""Delete view for home that allows the end user to delete DomainApplications"""
object: DomainApplication # workaround for type mismatch in DeleteView
def has_permission(self):
"""Custom override for has_permission to exclude all statuses, except WITHDRAWN and STARTED"""
has_perm = super().has_permission()
if not has_perm:
return False
status = self.get_object().status
valid_statuses = [DomainApplication.ApplicationStatus.WITHDRAWN, DomainApplication.ApplicationStatus.STARTED]
if status not in valid_statuses:
return False
return True
def get_success_url(self):
"""After a delete is successful, redirect to home"""
return reverse("home")
def post(self, request, *args, **kwargs):
# Grab all orphaned contacts
application: DomainApplication = self.get_object()
contacts_to_delete, duplicates = self._get_orphaned_contacts(application)
# Delete the DomainApplication
response = super().post(request, *args, **kwargs)
# Delete orphaned contacts - but only for if they are not associated with a user
Contact.objects.filter(id__in=contacts_to_delete, user=None).delete()
# After a delete occurs, do a second sweep on any returned duplicates.
# This determines if any of these three fields share a contact, which is used for
# the edge case where the same user may be an AO, and a submitter, for example.
if len(duplicates) > 0:
duplicates_to_delete, _ = self._get_orphaned_contacts(application, check_db=True)
Contact.objects.filter(id__in=duplicates_to_delete, user=None).delete()
return response
def _get_orphaned_contacts(self, application: DomainApplication, check_db=False):
"""
Collects all orphaned contacts associated with a given DomainApplication object.
An orphaned contact is defined as a contact that is associated with the application,
but not with any other application. This includes the authorizing official, the submitter,
and any other contacts linked to the application.
Parameters:
application (DomainApplication): The DomainApplication object for which to find orphaned contacts.
check_db (bool, optional): A flag indicating whether to check the database for the existence of the contacts.
Defaults to False.
Returns:
tuple: A tuple containing two lists. The first list contains the IDs of the orphaned contacts.
The second list contains any duplicate contacts found. ([Contacts], [Contacts])
"""
contacts_to_delete = []
# Get each contact object on the DomainApplication object
ao = application.authorizing_official
submitter = application.submitter
other_contacts = list(application.other_contacts.all())
other_contact_ids = application.other_contacts.all().values_list("id", flat=True)
# Check if the desired item still exists in the DB
if check_db:
ao = self._get_contacts_by_id([ao.id]).first() if ao is not None else None
submitter = self._get_contacts_by_id([submitter.id]).first() if submitter is not None else None
other_contacts = self._get_contacts_by_id(other_contact_ids)
# Pair each contact with its db related name for use in checking if it has joins
checked_contacts = [(ao, "authorizing_official"), (submitter, "submitted_applications")]
checked_contacts.extend((contact, "contact_applications") for contact in other_contacts)
for contact, related_name in checked_contacts:
if contact is not None and not contact.has_more_than_one_join(related_name):
contacts_to_delete.append(contact.id)
return (contacts_to_delete, self._get_duplicates(checked_contacts))
def _get_contacts_by_id(self, contact_ids):
"""Given a list of ids, grab contacts if it exists"""
contacts = Contact.objects.filter(id__in=contact_ids)
return contacts
def _get_duplicates(self, objects):
"""Given a list of objects, return a list of which items were duplicates"""
# Gets the occurence count
object_dict = defaultdict(int)
for contact, _related in objects:
object_dict[contact] += 1
duplicates = [item for item, count in object_dict.items() if count > 1]
return duplicates

View file

@ -568,7 +568,9 @@ class DomainSecurityEmailView(DomainFormBaseView):
"""The initial value for the form.""" """The initial value for the form."""
initial = super().get_initial() initial = super().get_initial()
security_contact = self.object.security_contact security_contact = self.object.security_contact
if security_contact is None or security_contact.email == "dotgov@cisa.dhs.gov":
invalid_emails = ["dotgov@cisa.dhs.gov", "registrar@dotgov.gov"]
if security_contact is None or security_contact.email in invalid_emails:
initial["security_email"] = None initial["security_email"] = None
return initial return initial
initial["security_email"] = security_contact.email initial["security_email"] = security_contact.email
@ -646,7 +648,7 @@ class DomainAddUserView(DomainFormBaseView):
"""Get an absolute URL for this domain.""" """Get an absolute URL for this domain."""
return self.request.build_absolute_uri(reverse("domain", kwargs={"pk": self.object.id})) return self.request.build_absolute_uri(reverse("domain", kwargs={"pk": self.object.id}))
def _send_domain_invitation_email(self, email: str, requester: User, add_success=True): def _send_domain_invitation_email(self, email: str, requestor: User, add_success=True):
"""Performs the sending of the domain invitation email, """Performs the sending of the domain invitation email,
does not make a domain information object does not make a domain information object
email: string- email to send to email: string- email to send to
@ -654,16 +656,16 @@ 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
requester_email = "help@get.gov" requestor_email = "help@get.gov"
# Check if the email requester has a valid email address # Check if the email requestor has a valid email address
if not requester.is_staff and requester.email is not None and requester.email.strip() != "": if not requestor.is_staff and requestor.email is not None and requestor.email.strip() != "":
requester_email = requester.email requestor_email = requestor.email
elif not requester.is_staff: elif not requestor.is_staff:
messages.error(self.request, "Can't send invitation email. No email is associated with your account.") messages.error(self.request, "Can't send invitation email. No email is associated with your account.")
logger.error( logger.error(
f"Can't send email to '{email}' on domain '{self.object}'." f"Can't send email to '{email}' on domain '{self.object}'."
f"No email exists for the requester '{requester.username}'.", f"No email exists for the requestor '{requestor.username}'.",
exc_info=True, exc_info=True,
) )
return None return None
@ -676,7 +678,7 @@ class DomainAddUserView(DomainFormBaseView):
context={ context={
"domain_url": self._domain_abs_url(), "domain_url": self._domain_abs_url(),
"domain": self.object, "domain": self.object,
"requester_email": requester_email, "requestor_email": requestor_email,
}, },
) )
except EmailSendingError: except EmailSendingError:
@ -691,7 +693,7 @@ class DomainAddUserView(DomainFormBaseView):
if add_success: if add_success:
messages.success(self.request, f"{email} has been invited to this domain.") messages.success(self.request, f"{email} has been invited to this domain.")
def _make_invitation(self, email_address: str, requester: User): def _make_invitation(self, email_address: str, requestor: User):
"""Make a Domain invitation for this email and redirect with a message.""" """Make a Domain invitation for this email and redirect with a message."""
invitation, created = DomainInvitation.objects.get_or_create(email=email_address, domain=self.object) invitation, created = DomainInvitation.objects.get_or_create(email=email_address, domain=self.object)
if not created: if not created:
@ -701,22 +703,22 @@ class DomainAddUserView(DomainFormBaseView):
f"{email_address} has already been invited to this domain.", f"{email_address} has already been invited to this domain.",
) )
else: else:
self._send_domain_invitation_email(email=email_address, requester=requester) self._send_domain_invitation_email(email=email_address, requestor=requestor)
return redirect(self.get_success_url()) return redirect(self.get_success_url())
def form_valid(self, form): def form_valid(self, form):
"""Add the specified user on this domain.""" """Add the specified user on this domain."""
requested_email = form.cleaned_data["email"] requested_email = form.cleaned_data["email"]
requester = self.request.user requestor = self.request.user
# look up a user with that email # look up a user with that email
try: try:
requested_user = User.objects.get(email=requested_email) requested_user = User.objects.get(email=requested_email)
except User.DoesNotExist: except User.DoesNotExist:
# no matching user, go make an invitation # no matching user, go make an invitation
return self._make_invitation(requested_email, requester) return self._make_invitation(requested_email, requestor)
else: else:
# if user already exists then just send an email # if user already exists then just send an email
self._send_domain_invitation_email(requested_email, requester, add_success=False) self._send_domain_invitation_email(requested_email, requestor, add_success=False)
try: try:
UserDomainRole.objects.create( UserDomainRole.objects.create(

View file

@ -7,15 +7,55 @@ def index(request):
"""This page is available to anyone without logging in.""" """This page is available to anyone without logging in."""
context = {} context = {}
if request.user.is_authenticated: if request.user.is_authenticated:
applications = DomainApplication.objects.filter(creator=request.user) # Get all domain applications the user has access to
# Let's exclude the approved applications since our applications, deletable_applications = _get_applications(request)
# domain_applications context will be used to populate
# the active applications table
context["domain_applications"] = applications.exclude(status="approved")
user_domain_roles = UserDomainRole.objects.filter(user=request.user) context["domain_applications"] = applications
domain_ids = user_domain_roles.values_list("domain_id", flat=True)
domains = Domain.objects.filter(id__in=domain_ids)
# Get all domains the user has access to
domains = _get_domains(request)
context["domains"] = domains context["domains"] = domains
# Determine if the user will see applications that they can delete
has_deletable_applications = deletable_applications.exists()
context["has_deletable_applications"] = has_deletable_applications
# If they can delete applications, add the delete button to the context
if has_deletable_applications:
# Add the delete modal button to the context
modal_button = (
'<button type="submit" '
'class="usa-button usa-button--secondary" '
'name="delete-application">Yes, delete request</button>'
)
context["modal_button"] = modal_button
return render(request, "home.html", context) return render(request, "home.html", context)
def _get_applications(request):
"""Given the current request,
get all DomainApplications that are associated with the UserDomainRole object.
Returns a tuple of all applications, and those that are deletable by the user.
"""
# Let's exclude the approved applications since our
# domain_applications context will be used to populate
# the active applications table
applications = DomainApplication.objects.filter(creator=request.user).exclude(
status=DomainApplication.ApplicationStatus.APPROVED
)
# Create a placeholder DraftDomain for each incomplete draft
valid_statuses = [DomainApplication.ApplicationStatus.STARTED, DomainApplication.ApplicationStatus.WITHDRAWN]
deletable_applications = applications.filter(status__in=valid_statuses)
return (applications, deletable_applications)
def _get_domains(request):
"""Given the current request,
get all domains that are associated with the UserDomainRole object"""
user_domain_roles = UserDomainRole.objects.filter(user=request.user)
domain_ids = user_domain_roles.values_list("domain_id", flat=True)
return Domain.objects.filter(id__in=domain_ids)

View file

@ -122,3 +122,11 @@ class DomainInvitationPermissionDeleteView(DomainInvitationPermission, DeleteVie
model = DomainInvitation model = DomainInvitation
object: DomainInvitation # workaround for type mismatch in DeleteView object: DomainInvitation # workaround for type mismatch in DeleteView
class DomainApplicationPermissionDeleteView(DomainApplicationPermission, DeleteView, abc.ABC):
"""Abstract view for deleting a DomainApplication."""
model = DomainApplication
object: DomainApplication