diff --git a/ops/manifests/manifest-stable.yaml b/ops/manifests/manifest-stable.yaml
index 6295fa63b..e7b3c74ae 100644
--- a/ops/manifests/manifest-stable.yaml
+++ b/ops/manifests/manifest-stable.yaml
@@ -4,7 +4,7 @@ applications:
buildpacks:
- python_buildpack
path: ../../src
- instances: 1
+ instances: 2
memory: 512M
stack: cflinuxfs4
timeout: 180
@@ -23,6 +23,8 @@ applications:
DJANGO_LOG_LEVEL: INFO
# default public site location
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
+ # Which OIDC provider to use
+ OIDC_ACTIVE_PROVIDER: login.gov production
routes:
- route: getgov-stable.app.cloud.gov
services:
diff --git a/ops/manifests/manifest-staging.yaml b/ops/manifests/manifest-staging.yaml
index 3e80352ba..a1d09a555 100644
--- a/ops/manifests/manifest-staging.yaml
+++ b/ops/manifests/manifest-staging.yaml
@@ -4,7 +4,7 @@ applications:
buildpacks:
- python_buildpack
path: ../../src
- instances: 1
+ instances: 2
memory: 512M
stack: cflinuxfs4
timeout: 180
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 904ce66a4..8914e5c87 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -1,5 +1,6 @@
import logging
from django import forms
+from django.http import HttpResponse
from django_fsm import get_available_FIELD_transitions
from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
@@ -10,6 +11,7 @@ from django.urls import reverse
from epplibwrapper.errors import ErrorCode, RegistryError
from registrar.models.domain import Domain
from registrar.models.utility.admin_sort_fields import AdminSortFields
+from registrar.utility import csv_export
from . import models
from auditlog.models import LogEntry # type: ignore
from auditlog.admin import LogEntryAdmin # type: ignore
@@ -747,8 +749,59 @@ class DomainAdmin(ListHeaderAdmin):
search_fields = ["name"]
search_help_text = "Search by domain name."
change_form_template = "django/admin/domain_change_form.html"
+ change_list_template = "django/admin/domain_change_list.html"
readonly_fields = ["state"]
+ def export_data_type(self, request):
+ # match the CSV example with all the fields
+ response = HttpResponse(content_type="text/csv")
+ response["Content-Disposition"] = 'attachment; filename="domains-by-type.csv"'
+ csv_export.export_data_type_to_csv(response)
+ return response
+
+ def export_data_full(self, request):
+ # Smaller export based on 1
+ response = HttpResponse(content_type="text/csv")
+ response["Content-Disposition"] = 'attachment; filename="current-full.csv"'
+ csv_export.export_data_full_to_csv(response)
+ return response
+
+ def export_data_federal(self, request):
+ # Federal only
+ response = HttpResponse(content_type="text/csv")
+ response["Content-Disposition"] = 'attachment; filename="current-federal.csv"'
+ csv_export.export_data_federal_to_csv(response)
+ return response
+
+ def get_urls(self):
+ from django.urls import path
+
+ urlpatterns = super().get_urls()
+
+ # Used to extrapolate a path name, for instance
+ # name="{app_label}_{model_name}_export_data_type"
+ info = self.model._meta.app_label, self.model._meta.model_name
+
+ my_url = [
+ path(
+ "export_data_type/",
+ self.export_data_type,
+ name="%s_%s_export_data_type" % info,
+ ),
+ path(
+ "export_data_full/",
+ self.export_data_full,
+ name="%s_%s_export_data_full" % info,
+ ),
+ path(
+ "export_data_federal/",
+ self.export_data_federal,
+ name="%s_%s_export_data_federal" % info,
+ ),
+ ]
+
+ return my_url + urlpatterns
+
def response_change(self, request, obj):
# Create dictionary of action functions
ACTION_FUNCTIONS = {
diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js
index 1c678a4d6..b659b117e 100644
--- a/src/registrar/assets/js/get-gov.js
+++ b/src/registrar/assets/js/get-gov.js
@@ -273,31 +273,35 @@ function prepareDeleteButtons(formLabel) {
// h2 and legend for DS form, label for nameservers
Array.from(form.querySelectorAll('h2, legend, label, p')).forEach((node) => {
- // Ticket: 1192
- // if (isNameserversForm && index <= 1 && !node.innerHTML.includes('*')) {
- // // Create a new element
- // const newElement = document.createElement('abbr');
- // newElement.textContent = '*';
- // // TODO: finish building abbr
+ // 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
+ if (isNameserversForm && index <= 1 && node.innerHTML.includes('server') && !node.innerHTML.includes('*')) {
+ // Create a new element
+ const newElement = document.createElement('abbr');
+ newElement.textContent = '*';
+ newElement.setAttribute("title", "required");
+ newElement.classList.add("usa-hint", "usa-hint--required");
- // // Append the new element to the parent
- // node.appendChild(newElement);
- // // Find the next sibling that is an input element
- // let nextInputElement = node.nextElementSibling;
+ // Append the new element to the label
+ node.appendChild(newElement);
+ // Find the next sibling that is an input element
+ let nextInputElement = node.nextElementSibling;
- // while (nextInputElement) {
- // if (nextInputElement.tagName === 'INPUT') {
- // // Found the next input element
- // console.log(nextInputElement);
- // break;
- // }
- // nextInputElement = nextInputElement.nextElementSibling;
- // }
- // nextInputElement.required = true;
- // }
+ while (nextInputElement) {
+ if (nextInputElement.tagName === 'INPUT') {
+ // Found the next input element
+ nextInputElement.setAttribute("required", "")
+ break;
+ }
+ nextInputElement = nextInputElement.nextElementSibling;
+ }
+ nextInputElement.required = true;
+ }
- // Ticket: 1192 - remove if
- if (!(isNameserversForm && index <= 1)) {
+ 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}`);
}
@@ -305,7 +309,15 @@ function prepareDeleteButtons(formLabel) {
// Display the add more button if we have less than 13 forms
if (isNameserversForm && forms.length <= 13) {
- addButton.classList.remove("display-none")
+ console.log('remove disabled');
+ addButton.removeAttribute("disabled");
+ }
+
+ if (isNameserversForm && forms.length < 3) {
+ // Hide the delete buttons on the remaining nameservers
+ Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => {
+ deleteButton.setAttribute("disabled", "true");
+ });
}
});
@@ -333,6 +345,11 @@ function prepareDeleteButtons(formLabel) {
formLabel = "DS Data record";
}
+ // On load: Disable the add more button if we have 13 forms
+ if (isNameserversForm && document.querySelectorAll(".repeatable-form").length == 13) {
+ addButton.setAttribute("disabled", "true");
+ }
+
// Attach click event listener on the delete buttons of the existing forms
prepareDeleteButtons(formLabel);
@@ -348,6 +365,33 @@ function prepareDeleteButtons(formLabel) {
// For the eample on Nameservers
let formExampleRegex = RegExp(`ns(\\d){1}`, 'g');
+ // Some Nameserver form checks since the delete can mess up the source object we're copying
+ // in regards to required fields and hidden delete buttons
+ if (isNameserversForm) {
+
+ // If the source element we're copying has required on an input,
+ // reset that input
+ let formRequiredNeedsCleanUp = newForm.innerHTML.includes('*');
+ if (formRequiredNeedsCleanUp) {
+ newForm.querySelector('label abbr').remove();
+ // Get all input elements within the container
+ const inputElements = newForm.querySelectorAll("input");
+ // Loop through each input element and remove the 'required' attribute
+ inputElements.forEach((input) => {
+ if (input.hasAttribute("required")) {
+ input.removeAttribute("required");
+ }
+ });
+ }
+
+ // If the source element we're copying has an disabled delete button,
+ // enable that button
+ let deleteButton= newForm.querySelector('.delete-record');
+ if (deleteButton.hasAttribute("disabled")) {
+ deleteButton.removeAttribute("disabled");
+ }
+ }
+
formNum++;
newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `form-${formNum-1}-`);
newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum}`);
@@ -397,9 +441,18 @@ function prepareDeleteButtons(formLabel) {
// Attach click event listener on the delete buttons of the new form
prepareDeleteButtons(formLabel);
- // Hide the add more button if we have 13 forms
+ // Disable the add more button if we have 13 forms
if (isNameserversForm && formNum == 13) {
- addButton.classList.add("display-none")
+ addButton.setAttribute("disabled", "true");
+ }
+
+ if (isNameserversForm && forms.length >= 2) {
+ // Enable the delete buttons on the nameservers
+ forms.forEach((form, index) => {
+ Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => {
+ deleteButton.removeAttribute("disabled");
+ });
+ });
}
}
})();
diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss
index 35d089cbd..68ff51597 100644
--- a/src/registrar/assets/sass/_theme/_admin.scss
+++ b/src/registrar/assets/sass/_theme/_admin.scss
@@ -180,3 +180,48 @@ h1, h2, h3 {
background: var(--primary);
color: var(--header-link-color);
}
+
+// Font mismatch issue due to conflicts between django and uswds,
+// rough overrides for consistency and readability. May want to revise
+// in the future
+.object-tools li a,
+.object-tools p a {
+ font-family: "Source Sans Pro Web", "Helvetica Neue", Helvetica, Roboto, Arial, sans-serif;
+ text-transform: capitalize!important;
+ font-size: 14px!important;
+}
+
+// For consistency, make the overrided p a
+// object tool buttons the same size as the ul li a
+.object-tools p {
+ line-height: 1.25rem;
+}
+
+// Fix margins in mobile view
+@media (max-width: 767px) {
+ .object-tools li {
+ // our CSS is read before django's, so need !important
+ // to override
+ margin-left: 0!important;
+ margin-right: 15px;
+ }
+}
+
+// Fix height of buttons
+.object-tools li {
+ height: auto;
+}
+
+// Fixing height of buttons breaks layout because
+// object-tools and changelist are siblings with
+// flexbox positioning
+#changelist {
+ clear: both;
+}
+
+// Account for the h2, roughly 90px
+@include at-media(tablet) {
+ .object-tools {
+ padding-left: 90px;
+ }
+}
diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py
index 3e5734dcc..e0dedb60c 100644
--- a/src/registrar/config/settings.py
+++ b/src/registrar/config/settings.py
@@ -49,6 +49,7 @@ env_debug = env.bool("DJANGO_DEBUG", default=False)
env_log_level = env.str("DJANGO_LOG_LEVEL", "DEBUG")
env_base_url = env.str("DJANGO_BASE_URL")
env_getgov_public_site_url = env.str("GETGOV_PUBLIC_SITE_URL", "")
+env_oidc_active_provider = env.str("OIDC_ACTIVE_PROVIDER", "identity sandbox")
secret_login_key = b64decode(secret("DJANGO_SECRET_LOGIN_KEY", ""))
secret_key = secret("DJANGO_SECRET_KEY")
@@ -370,8 +371,7 @@ LOGGING = {
# each handler has its choice of format
"formatters": {
"verbose": {
- "format": "[%(asctime)s] %(levelname)s [%(name)s:%(lineno)s] "
- "%(message)s",
+ "format": "[%(asctime)s] %(levelname)s [%(name)s:%(lineno)s] %(message)s",
"datefmt": "%d/%b/%Y %H:%M:%S",
},
"simple": {
@@ -482,11 +482,12 @@ OIDC_ALLOW_DYNAMIC_OP = False
# which provider to use if multiple are available
# (code does not currently support user selection)
-OIDC_ACTIVE_PROVIDER = "login.gov"
+# See above for the default value if the env variable is missing
+OIDC_ACTIVE_PROVIDER = env_oidc_active_provider
OIDC_PROVIDERS = {
- "login.gov": {
+ "identity sandbox": {
"srv_discovery_url": "https://idp.int.identitysandbox.gov",
"behaviour": {
# the 'code' workflow requires direct connectivity from us to Login.gov
@@ -502,7 +503,26 @@ OIDC_PROVIDERS = {
"token_endpoint_auth_method": ["private_key_jwt"],
"sp_private_key": secret_login_key,
},
- }
+ },
+ "login.gov production": {
+ "srv_discovery_url": "https://secure.login.gov",
+ "behaviour": {
+ # the 'code' workflow requires direct connectivity from us to Login.gov
+ "response_type": "code",
+ "scope": ["email", "profile:name", "phone"],
+ "user_info_request": ["email", "first_name", "last_name", "phone"],
+ "acr_value": "http://idmanagement.gov/ns/assurance/ial/2",
+ },
+ "client_registration": {
+ "client_id": (
+ "urn:gov:cisa:openidconnect.profiles:sp:sso:cisa:dotgov_registrar"
+ ),
+ "redirect_uris": [f"{env_base_url}/openid/callback/login/"],
+ "post_logout_redirect_uris": [f"{env_base_url}/openid/callback/logout/"],
+ "token_endpoint_auth_method": ["private_key_jwt"],
+ "sp_private_key": secret_login_key,
+ },
+ },
}
# endregion
diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py
index 3aca7af6d..d48a14c6b 100644
--- a/src/registrar/forms/domain.py
+++ b/src/registrar/forms/domain.py
@@ -23,11 +23,6 @@ class DomainAddUserForm(forms.Form):
email = forms.EmailField(label="Email")
-class IPAddressField(forms.CharField):
- def validate(self, value):
- super().validate(value) # Run the default CharField validation
-
-
class DomainNameserverForm(forms.Form):
"""Form for changing nameservers."""
@@ -35,7 +30,21 @@ class DomainNameserverForm(forms.Form):
server = forms.CharField(label="Name server", strip=True)
- ip = forms.CharField(label="IP Address (IPv4 or IPv6)", strip=True, required=False)
+ ip = forms.CharField(
+ label="IP address (IPv4 or IPv6)",
+ strip=True,
+ required=False,
+ )
+
+ def __init__(self, *args, **kwargs):
+ super(DomainNameserverForm, self).__init__(*args, **kwargs)
+
+ # add custom error messages
+ self.fields["server"].error_messages.update(
+ {
+ "required": "A minimum of 2 name servers are required.",
+ }
+ )
def clean(self):
# clean is called from clean_forms, which is called from is_valid
diff --git a/src/registrar/templates/django/admin/domain_change_list.html b/src/registrar/templates/django/admin/domain_change_list.html
new file mode 100644
index 000000000..68fdbe7aa
--- /dev/null
+++ b/src/registrar/templates/django/admin/domain_change_list.html
@@ -0,0 +1,23 @@
+{% extends "admin/change_list.html" %}
+
+{% block object-tools %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/src/registrar/templates/django/forms/label.html b/src/registrar/templates/django/forms/label.html
index 6881922f2..da90a372a 100644
--- a/src/registrar/templates/django/forms/label.html
+++ b/src/registrar/templates/django/forms/label.html
@@ -2,8 +2,12 @@
class="{% if label_classes %} {{ label_classes }}{% endif %}{% if label_tag == 'legend' %} {{ legend_classes }}{% endif %}"
{% if not field.use_fieldset %}for="{{ widget.attrs.id }}"{% endif %}
>
-{{ field.label }}
-{% if widget.attrs.required %}
- *
-{% endif %}
+ {% if span_for_text %}
+ {{ field.label }}
+ {% else %}
+ {{ field.label }}
+ {% endif %}
+ {% if widget.attrs.required %}
+ *
+ {% endif %}
{{ label_tag }}>
diff --git a/src/registrar/templates/domain_nameservers.html b/src/registrar/templates/domain_nameservers.html
index d00126698..55519ef67 100644
--- a/src/registrar/templates/domain_nameservers.html
+++ b/src/registrar/templates/domain_nameservers.html
@@ -35,11 +35,14 @@
{{ form.domain }}
{% with sublabel_text="Example: ns"|concat:forloop.counter|concat:".example.com" %}
{% if forloop.counter <= 2 %}
- {% with attr_required=True add_group_class="usa-form-group--unstyled-error" %}
+ {# span_for_text will wrap the copy in s , which we'll use in the JS for this component #}
+ {% with attr_required=True add_group_class="usa-form-group--unstyled-error" span_for_text=True %}
{% input_with_errors form.server %}
{% endwith %}
{% else %}
- {% input_with_errors form.server %}
+ {% with span_for_text=True %}
+ {% input_with_errors form.server %}
+ {% endwith %}
{% endif %}
{% endwith %}
@@ -49,14 +52,11 @@
{% endwith %}
- {% comment %} TODO: remove this if for 1192 {% endcomment %}
- {% if forloop.counter > 2 %}
-
- {% endif %}
+
@@ -81,7 +81,7 @@
type="submit"
class="usa-button usa-button--outline"
name="btn-cancel-click"
- aria-label="Reset the data in the Name Server form to the registry state (undo changes)"
+ aria-label="Reset the data in the name server form to the registry state (undo changes)"
>Cancel
diff --git a/src/registrar/tests/data/test_contacts.txt b/src/registrar/tests/data/test_contacts.txt
index dec8f6816..89f57ccf8 100644
--- a/src/registrar/tests/data/test_contacts.txt
+++ b/src/registrar/tests/data/test_contacts.txt
@@ -1,7 +1,7 @@
-TESTUSER|52563_CONTACT_GOV-VRSN|919-000-0000||918-000-0000||testuser@gmail.com|GSA|VERISIGN|ctldbatch|2021-06-30T17:58:09Z|VERISIGN|ctldbatch|2021-06-30T18:18:09Z|
-RJD1|52545_CONTACT_GOV-VRSN|919-000-0000||918-000-0000||agustina.wyman7@test.com|GSA|VERISIGN|ctldbatch|2021-06-29T18:53:09Z|VERISIGN|ctldbatch|2021-06-29T18:58:08Z|
-JAKING|52555_CONTACT_GOV-VRSN|919-000-0000||918-000-0000||susy.martin4@test.com|GSA|VERISIGN|ctldbatch|2021-06-30T15:23:10Z|VERISIGN|ctldbatch|2021-06-30T15:38:10Z|
-JBOONE|52556_CONTACT_GOV-VRSN|919-000-0000||918-000-0000||stephania.winters4@test.com|GSA|VERISIGN|ctldbatch|2021-06-30T15:23:10Z|VERISIGN|ctldbatch|2021-06-30T18:28:09Z|
-MKELLEY|52557_CONTACT_GOV-VRSN|919-000-0000||918-000-0000||alexandra.bobbitt5@test.com|GSA|VERISIGN|ctldbatch|2021-06-30T15:23:10Z|VERISIGN|ctldbatch|2021-08-02T22:13:09Z|
-CWILSON|52562_CONTACT_GOV-VRSN|919-000-0000||918-000-0000||jospeh.mcdowell3@test.com|GSA|VERISIGN|ctldbatch|2021-06-30T17:58:09Z|VERISIGN|ctldbatch|2021-06-30T18:33:09Z|
-LMCCADE|52563_CONTACT_GOV-VRSN|919-000-0000||918-000-0000||reginald.ratcliff4@test.com|GSA|VERISIGN|ctldbatch|2021-06-30T17:58:09Z|VERISIGN|ctldbatch|2021-06-30T18:18:09Z|
\ No newline at end of file
+TESTUSER|12363_CONTACT|123-123-1234||918-000-0000||testuser@gmail.com|GSA|SOMECOMPANY|ctldbatch|2021-06-30T17:58:09Z|SOMECOMPANY|ctldbatch|2021-06-30T18:18:09Z|
+USER1|12345_CONTACT|123-123-1234||918-000-0000||agustina.wyman7@test.com|GSA|SOMECOMPANY|ctldbatch|2021-06-29T18:53:09Z|SOMECOMPANY|ctldbatch|2021-06-29T18:58:08Z|
+USER2|12355_CONTACT|123-123-1234||918-000-0000||susy.martin4@test.com|GSA|SOMECOMPANY|ctldbatch|2021-06-30T15:23:10Z|SOMECOMPANY|ctldbatch|2021-06-30T15:38:10Z|
+USER3|12356_CONTACT|123-123-1234||918-000-0000||stephania.winters4@test.com|GSA|SOMECOMPANY|ctldbatch|2021-06-30T15:23:10Z|SOMECOMPANY|ctldbatch|2021-06-30T18:28:09Z|
+USER4|12357_CONTACT|123-123-1234||918-000-0000||alexandra.bobbitt5@test.com|GSA|SOMECOMPANY|ctldbatch|2021-06-30T15:23:10Z|SOMECOMPANY|ctldbatch|2021-08-02T22:13:09Z|
+USER5|12362_CONTACT|123-123-1234||918-000-0000||jospeh.mcdowell3@test.com|GSA|SOMECOMPANY|ctldbatch|2021-06-30T17:58:09Z|SOMECOMPANY|ctldbatch|2021-06-30T18:33:09Z|
+USER6|12363_CONTACT|123-123-1234||918-000-0000||reginald.ratcliff4@test.com|GSA|SOMECOMPANY|ctldbatch|2021-06-30T17:58:09Z|SOMECOMPANY|ctldbatch|2021-06-30T18:18:09Z|
\ No newline at end of file
diff --git a/src/registrar/tests/data/test_domain_contacts.txt b/src/registrar/tests/data/test_domain_contacts.txt
index 069e5231e..3a1ed745f 100644
--- a/src/registrar/tests/data/test_domain_contacts.txt
+++ b/src/registrar/tests/data/test_domain_contacts.txt
@@ -1,8 +1,8 @@
-Anomaly.gov|ANOMALY|tech
-TestDomain.gov|TESTUSER|admin
-NEHRP.GOV|RJD1|admin
-NEHRP.GOV|JAKING|tech
-NEHRP.GOV|JBOONE|billing
-NELSONCOUNTY-VA.GOV|MKELLEY|admin
-NELSONCOUNTY-VA.GOV|CWILSON|billing
-NELSONCOUNTY-VA.GOV|LMCCADE|tech
\ No newline at end of file
+Anomaly.gov|ANOMALY|tech
+TestDomain.gov|TESTUSER|admin
+FakeWebsite1|USER1|admin
+FakeWebsite1|USER2|tech
+FakeWebsite1|USER3|billing
+FakeWebsite2.GOV|USER4|admin
+FakeWebsite2.GOV|USER5|billing
+FakeWebsite2.GOV|USER6|tech
\ No newline at end of file
diff --git a/src/registrar/tests/data/test_domain_statuses.txt b/src/registrar/tests/data/test_domain_statuses.txt
index 1f3cc8998..021e52ae7 100644
--- a/src/registrar/tests/data/test_domain_statuses.txt
+++ b/src/registrar/tests/data/test_domain_statuses.txt
@@ -1,4 +1,4 @@
Anomaly.gov|muahaha|
-TestDomain.gov|ok|
-NEHRP.GOV|serverHold|
-NELSONCOUNTY-VA.GOV|Hold|
\ No newline at end of file
+TestDomain.gov|ok|
+FakeWebsite1.GOV|serverHold|
+FakeWebsite2.GOV|Hold|
\ No newline at end of file
diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py
new file mode 100644
index 000000000..404ed358c
--- /dev/null
+++ b/src/registrar/tests/test_reports.py
@@ -0,0 +1,195 @@
+from django.test import TestCase
+from io import StringIO
+import csv
+from registrar.models.domain_information import DomainInformation
+from registrar.models.domain import Domain
+from registrar.models.user import User
+from django.contrib.auth import get_user_model
+from registrar.utility.csv_export import export_domains_to_writer
+
+
+class ExportDataTest(TestCase):
+ def setUp(self):
+ username = "test_user"
+ first_name = "First"
+ last_name = "Last"
+ email = "info@example.com"
+ self.user = get_user_model().objects.create(
+ username=username, first_name=first_name, last_name=last_name, email=email
+ )
+
+ self.domain_1, _ = Domain.objects.get_or_create(
+ name="cdomain1.gov", state=Domain.State.READY
+ )
+ self.domain_2, _ = Domain.objects.get_or_create(
+ name="adomain2.gov", state=Domain.State.DNS_NEEDED
+ )
+ self.domain_3, _ = Domain.objects.get_or_create(
+ name="ddomain3.gov", state=Domain.State.ON_HOLD
+ )
+ self.domain_4, _ = Domain.objects.get_or_create(
+ name="bdomain4.gov", state=Domain.State.UNKNOWN
+ )
+ self.domain_4, _ = Domain.objects.get_or_create(
+ name="bdomain4.gov", state=Domain.State.UNKNOWN
+ )
+
+ self.domain_information_1, _ = DomainInformation.objects.get_or_create(
+ creator=self.user,
+ domain=self.domain_1,
+ organization_type="federal",
+ federal_agency="World War I Centennial Commission",
+ federal_type="executive",
+ )
+ self.domain_information_2, _ = DomainInformation.objects.get_or_create(
+ creator=self.user,
+ domain=self.domain_2,
+ organization_type="interstate",
+ )
+ self.domain_information_3, _ = DomainInformation.objects.get_or_create(
+ creator=self.user,
+ domain=self.domain_3,
+ organization_type="federal",
+ federal_agency="Armed Forces Retirement Home",
+ )
+ self.domain_information_4, _ = DomainInformation.objects.get_or_create(
+ creator=self.user,
+ domain=self.domain_4,
+ organization_type="federal",
+ federal_agency="Armed Forces Retirement Home",
+ )
+
+ def tearDown(self):
+ Domain.objects.all().delete()
+ DomainInformation.objects.all().delete()
+ User.objects.all().delete()
+ super().tearDown()
+
+ def test_export_domains_to_writer(self):
+ """Test that export_domains_to_writer returns the
+ existing domain, test that sort by domain name works,
+ test that filter works"""
+ # Create a CSV file in memory
+ csv_file = StringIO()
+ writer = csv.writer(csv_file)
+
+ # Define columns, sort fields, and filter condition
+ columns = [
+ "Domain name",
+ "Domain type",
+ "Agency",
+ "Organization name",
+ "City",
+ "State",
+ "AO",
+ "AO email",
+ "Submitter",
+ "Submitter title",
+ "Submitter email",
+ "Submitter phone",
+ "Security Contact Email",
+ "Status",
+ ]
+ sort_fields = ["domain__name"]
+ filter_condition = {
+ "domain__state__in": [
+ Domain.State.READY,
+ Domain.State.DNS_NEEDED,
+ Domain.State.ON_HOLD,
+ ],
+ }
+
+ # Call the export function
+ export_domains_to_writer(writer, columns, sort_fields, filter_condition)
+
+ # Reset the CSV file's position to the beginning
+ csv_file.seek(0)
+
+ # Read the content into a variable
+ csv_content = csv_file.read()
+
+ # We expect READY domains,
+ # sorted alphabetially by domain name
+ expected_content = (
+ "Domain name,Domain type,Agency,Organization name,City,State,AO,"
+ "AO email,Submitter,Submitter title,Submitter email,Submitter phone,"
+ "Security Contact Email,Status\n"
+ "adomain2.gov,Interstate,dnsneeded\n"
+ "cdomain1.gov,Federal - Executive,World War I Centennial Commission,ready\n"
+ "ddomain3.gov,Federal,Armed Forces Retirement Home,onhold\n"
+ )
+
+ # Normalize line endings and remove commas,
+ # spaces and leading/trailing whitespace
+ csv_content = (
+ csv_content.replace(",,", "")
+ .replace(",", "")
+ .replace(" ", "")
+ .replace("\r\n", "\n")
+ .strip()
+ )
+ expected_content = (
+ expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
+ )
+
+ self.assertEqual(csv_content, expected_content)
+
+ def test_export_domains_to_writer_additional(self):
+ """An additional test for filters and multi-column sort"""
+ # Create a CSV file in memory
+ csv_file = StringIO()
+ writer = csv.writer(csv_file)
+
+ # Define columns, sort fields, and filter condition
+ columns = [
+ "Domain name",
+ "Domain type",
+ "Agency",
+ "Organization name",
+ "City",
+ "State",
+ "Security Contact Email",
+ ]
+ sort_fields = ["domain__name", "federal_agency", "organization_type"]
+ filter_condition = {
+ "organization_type__icontains": "federal",
+ "domain__state__in": [
+ Domain.State.READY,
+ Domain.State.DNS_NEEDED,
+ Domain.State.ON_HOLD,
+ ],
+ }
+
+ # Call the export function
+ export_domains_to_writer(writer, columns, sort_fields, filter_condition)
+
+ # Reset the CSV file's position to the beginning
+ csv_file.seek(0)
+
+ # Read the content into a variable
+ csv_content = csv_file.read()
+
+ # We expect READY domains,
+ # federal only
+ # sorted alphabetially by domain name
+ expected_content = (
+ "Domain name,Domain type,Agency,Organization name,City,"
+ "State,Security Contact Email\n"
+ "cdomain1.gov,Federal - Executive,World War I Centennial Commission\n"
+ "ddomain3.gov,Federal,Armed Forces Retirement Home\n"
+ )
+
+ # Normalize line endings and remove commas,
+ # spaces and leading/trailing whitespace
+ csv_content = (
+ csv_content.replace(",,", "")
+ .replace(",", "")
+ .replace(" ", "")
+ .replace("\r\n", "\n")
+ .strip()
+ )
+ expected_content = (
+ expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
+ )
+
+ self.assertEqual(csv_content, expected_content)
diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py
index 95af4c542..92fd5af44 100644
--- a/src/registrar/tests/test_views.py
+++ b/src/registrar/tests/test_views.py
@@ -1170,6 +1170,7 @@ class TestWithDomainPermissions(TestWithUser):
if hasattr(self.domain, "contacts"):
self.domain.contacts.all().delete()
DomainApplication.objects.all().delete()
+ DomainInformation.objects.all().delete()
PublicContact.objects.all().delete()
Domain.objects.all().delete()
UserDomainRole.objects.all().delete()
@@ -1464,7 +1465,12 @@ class TestDomainNameservers(TestDomainOverview):
# form submission was a post with an error, response should be a 200
# error text appears twice, once at the top of the page, once around
# the required field. form requires a minimum of 2 name servers
- self.assertContains(result, "This field is required.", count=2, status_code=200)
+ self.assertContains(
+ result,
+ "A minimum of 2 name servers are required.",
+ count=2,
+ status_code=200,
+ )
def test_domain_nameservers_form_submit_subdomain_missing_ip(self):
"""Nameserver form catches missing ip error on subdomain.
@@ -1632,7 +1638,12 @@ class TestDomainNameservers(TestDomainOverview):
# form submission was a post with an error, response should be a 200
# error text appears four times, twice at the top of the page,
# once around each required field.
- self.assertContains(result, "This field is required", count=4, status_code=200)
+ self.assertContains(
+ result,
+ "A minimum of 2 name servers are required.",
+ count=4,
+ status_code=200,
+ )
class TestDomainAuthorizingOfficial(TestDomainOverview):
@@ -1800,7 +1811,11 @@ class TestDomainSecurityEmail(TestDomainOverview):
(
"RegistryError",
form_data_registry_error,
- "Update failed. Cannot contact the registry.",
+ """
+We’re experiencing a system connection error. Please wait a few minutes
+and try again. If you continue to receive this error after a few tries,
+contact help@get.gov
+ """,
),
("ContactError", form_data_contact_error, "Value entered was wrong."),
(
@@ -1835,7 +1850,7 @@ class TestDomainSecurityEmail(TestDomainOverview):
self.assertEqual(len(messages), 1)
message = messages[0]
self.assertEqual(message.tags, message_tag)
- self.assertEqual(message.message, expected_message)
+ self.assertEqual(message.message.strip(), expected_message.strip())
def test_domain_overview_blocked_for_ineligible_user(self):
"""We could easily duplicate this test for all domain management
diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py
new file mode 100644
index 000000000..ffada0a0b
--- /dev/null
+++ b/src/registrar/utility/csv_export.py
@@ -0,0 +1,119 @@
+import csv
+from registrar.models.domain import Domain
+from registrar.models.domain_information import DomainInformation
+from registrar.models.public_contact import PublicContact
+
+
+def export_domains_to_writer(writer, columns, sort_fields, filter_condition):
+ # write columns headers to writer
+ writer.writerow(columns)
+
+ domainInfos = DomainInformation.objects.filter(**filter_condition).order_by(
+ *sort_fields
+ )
+ for domainInfo in domainInfos:
+ security_contacts = domainInfo.domain.contacts.filter(
+ contact_type=PublicContact.ContactTypeChoices.SECURITY
+ )
+
+ # create a dictionary of fields which can be included in output
+ FIELDS = {
+ "Domain name": domainInfo.domain.name,
+ "Domain type": domainInfo.get_organization_type_display()
+ + " - "
+ + domainInfo.get_federal_type_display()
+ if domainInfo.federal_type
+ else domainInfo.get_organization_type_display(),
+ "Agency": domainInfo.federal_agency,
+ "Organization name": domainInfo.organization_name,
+ "City": domainInfo.city,
+ "State": domainInfo.state_territory,
+ "AO": domainInfo.authorizing_official.first_name
+ + " "
+ + domainInfo.authorizing_official.last_name
+ if domainInfo.authorizing_official
+ else " ",
+ "AO email": domainInfo.authorizing_official.email
+ if domainInfo.authorizing_official
+ else " ",
+ "Security Contact Email": security_contacts[0].email
+ if security_contacts
+ else " ",
+ "Status": domainInfo.domain.state,
+ "Expiration Date": domainInfo.domain.expiration_date,
+ }
+ writer.writerow([FIELDS.get(column, "") for column in columns])
+
+
+def export_data_type_to_csv(csv_file):
+ writer = csv.writer(csv_file)
+ # define columns to include in export
+ columns = [
+ "Domain name",
+ "Domain type",
+ "Agency",
+ "Organization name",
+ "City",
+ "State",
+ "AO",
+ "AO email",
+ "Security Contact Email",
+ "Status",
+ "Expiration Date",
+ ]
+ sort_fields = ["domain__name"]
+ filter_condition = {
+ "domain__state__in": [
+ Domain.State.READY,
+ Domain.State.DNS_NEEDED,
+ Domain.State.ON_HOLD,
+ ],
+ }
+ export_domains_to_writer(writer, columns, sort_fields, filter_condition)
+
+
+def export_data_full_to_csv(csv_file):
+ writer = csv.writer(csv_file)
+ # define columns to include in export
+ columns = [
+ "Domain name",
+ "Domain type",
+ "Agency",
+ "Organization name",
+ "City",
+ "State",
+ "Security Contact Email",
+ ]
+ sort_fields = ["domain__name", "federal_agency", "organization_type"]
+ filter_condition = {
+ "domain__state__in": [
+ Domain.State.READY,
+ Domain.State.DNS_NEEDED,
+ Domain.State.ON_HOLD,
+ ],
+ }
+ export_domains_to_writer(writer, columns, sort_fields, filter_condition)
+
+
+def export_data_federal_to_csv(csv_file):
+ writer = csv.writer(csv_file)
+ # define columns to include in export
+ columns = [
+ "Domain name",
+ "Domain type",
+ "Agency",
+ "Organization name",
+ "City",
+ "State",
+ "Security Contact Email",
+ ]
+ sort_fields = ["domain__name", "federal_agency", "organization_type"]
+ filter_condition = {
+ "organization_type__icontains": "federal",
+ "domain__state__in": [
+ Domain.State.READY,
+ Domain.State.DNS_NEEDED,
+ Domain.State.ON_HOLD,
+ ],
+ }
+ export_domains_to_writer(writer, columns, sort_fields, filter_condition)
diff --git a/src/registrar/utility/errors.py b/src/registrar/utility/errors.py
index c1d3c5849..5d62953ac 100644
--- a/src/registrar/utility/errors.py
+++ b/src/registrar/utility/errors.py
@@ -39,9 +39,11 @@ class GenericError(Exception):
"""
_error_mapping = {
- GenericErrorCodes.CANNOT_CONTACT_REGISTRY: (
- "Update failed. Cannot contact the registry."
- ),
+ GenericErrorCodes.CANNOT_CONTACT_REGISTRY: """
+We’re experiencing a system connection error. Please wait a few minutes
+and try again. If you continue to receive this error after a few tries,
+contact help@get.gov
+ """,
GenericErrorCodes.GENERIC_ERROR: ("Value entered was wrong."),
}