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 %} 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."), }