mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-19 17:25:56 +02:00
Merge branch 'main' of github.com:cisagov/manage.get.gov into es/476-add-cors-headers
This commit is contained in:
commit
adfcecacce
20 changed files with 743 additions and 124 deletions
|
@ -9,6 +9,29 @@ our `user_group` model and run in a migration.
|
|||
|
||||
For more details, refer to the [user group model](../../src/registrar/models/user_group.py).
|
||||
|
||||
## Adding a user as analyst or granting full access via django-admin
|
||||
|
||||
If a new team member has joined, then they will need to be granted analyst (`cisa_analysts_group`) or full access (`full_access_group`) permissions in order to view the admin pages. These admin pages are the ones found at manage.get.gov/admin.
|
||||
To do this, do the following:
|
||||
|
||||
1. The user in question will need to have a login.gov account and login into our system, this will create a `Users` table entry with their email address and name.
|
||||
2. On that `Users` table note that the `GROUP` column should be blank for them as they have no special permissions yet.
|
||||
3. Click on their username, then scroll down to the `User Permissions` section.
|
||||
4. Under `User Permissions`, see the `Groups` table which has a column for `Available groups` and `Chosen groups`. Select the permission you want from the `Available groups` column and click the right arrow to move it to the `Chosen groups`. Note, if you want this user to be an analyst select `cisa_analysts_group`, otherwise select the `full_access_group`.
|
||||
5. (Optional) If the user needs access to django admin (such as an analyst), then you will also need to make sure "Staff Status" is checked. This can be found in the same `User Permissions` section right below the checkbox for `Active`.
|
||||
6. Click `Save` to apply all changes
|
||||
|
||||
## Removing a user group permission via django-admin
|
||||
|
||||
If an employee was given the wrong permissions or has had a change in roles that subsequently requires a permission change, then their permissions should be updated in django-admin. Much like in the previous section you can accomplish this by doing the following:
|
||||
|
||||
1. Go to the `Users` table an select the username for the user in question
|
||||
2. Scroll down to the `User Permissions` section and find the `Groups` table which has a column for `Available groups` and `Chosen groups`.
|
||||
3. In this table, select the permission you want to remove from the `Chosen groups` and then click the left facing arrow to move the permission to `Available groups`.
|
||||
4. Depending on the scenario you may now need to add the opposite permission group to the `Chosen groups` section, please see the section above for instructions on how to do that.
|
||||
5. If the user should no longer see the admin page, you must ensure that under `User Permissions`, `Staff status` is NOT checked.
|
||||
6. Click `Save` to apply all changes
|
||||
|
||||
## Editing group permissions through code
|
||||
|
||||
We can edit and deploy new group permissions by:
|
||||
|
|
|
@ -76,16 +76,24 @@ These are the client certificate and its private key used to identify the regist
|
|||
|
||||
The private key is protected by a passphrase for safer transport and storage.
|
||||
|
||||
These were generated with:
|
||||
These were generated with the following steps:
|
||||
|
||||
### Step 1: Generate an unencrypted private key with a named curve
|
||||
|
||||
```bash
|
||||
openssl genpkey -out client.key \
|
||||
-algorithm EC -pkeyopt ec_paramgen_curve:P-256 \
|
||||
-aes-256-cbc
|
||||
openssl req -new -x509 -days 365 \
|
||||
-key client.key -out client.crt \
|
||||
-subj "/C=US/ST=DC/L=Washington/O=GSA/OU=18F/CN=GOV Prototype Registrar"
|
||||
openssl ecparam -name prime256v1 -genkey -out client_unencrypted.key
|
||||
```
|
||||
|
||||
### Step 2: Create an encrypted private key with a passphrase
|
||||
|
||||
```bash
|
||||
openssl pkcs8 -topk8 -v2 aes-256-cbc -in client_unencrypted.key -out client.key
|
||||
```
|
||||
|
||||
### Generate the certificate
|
||||
|
||||
```bash
|
||||
openssl req -new -x509 -days 365 -key client.key -out client.crt -subj "/C=US/ST=DC/L=Washington/O=GSA/OU=18F/CN=GOV Prototype Registrar"
|
||||
```
|
||||
|
||||
(If you can't use openssl on your computer directly, you can access it using Docker as `docker run --platform=linux/amd64 -it --rm -v $(pwd):/apps -w /apps alpine/openssl`.)
|
||||
|
@ -97,7 +105,14 @@ base64 client.key
|
|||
base64 client.crt
|
||||
```
|
||||
|
||||
You'll need to give the new certificate to the registry vendor _before_ rotating it in production.
|
||||
Note depending on your system you may need to instead run:
|
||||
|
||||
```bash
|
||||
base64 -i client.key
|
||||
base64 -i client.crt
|
||||
```
|
||||
|
||||
You'll need to give the new certificate to the registry vendor _before_ rotating it in production. Once it has been accepted by the vender, make sure to update the kdbx file on Google Drive.
|
||||
|
||||
## REGISTRY_HOSTNAME
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -4,7 +4,7 @@ applications:
|
|||
buildpacks:
|
||||
- python_buildpack
|
||||
path: ../../src
|
||||
instances: 1
|
||||
instances: 2
|
||||
memory: 512M
|
||||
stack: cflinuxfs4
|
||||
timeout: 180
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
@ -382,8 +383,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": {
|
||||
|
@ -494,11 +494,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
|
||||
|
@ -514,7 +515,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
|
||||
|
|
|
@ -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
|
||||
|
@ -44,6 +53,11 @@ class DomainNameserverForm(forms.Form):
|
|||
cleaned_data = super().clean()
|
||||
self.clean_empty_strings(cleaned_data)
|
||||
server = cleaned_data.get("server", "")
|
||||
# remove ANY spaces in the server field
|
||||
server = server.replace(" ", "")
|
||||
# lowercase the server
|
||||
server = server.lower()
|
||||
cleaned_data["server"] = server
|
||||
ip = cleaned_data.get("ip", None)
|
||||
# remove ANY spaces in the ip field
|
||||
ip = ip.replace(" ", "")
|
||||
|
@ -52,7 +66,7 @@ class DomainNameserverForm(forms.Form):
|
|||
ip_list = self.extract_ip_list(ip)
|
||||
|
||||
# validate if the form has a server or an ip
|
||||
if ip and ip_list or server:
|
||||
if (ip and ip_list) or server:
|
||||
self.validate_nameserver_ip_combo(domain, server, ip_list)
|
||||
|
||||
return cleaned_data
|
||||
|
|
|
@ -45,8 +45,10 @@ class Command(BaseCommand):
|
|||
self.transition_domains = TransitionDomain.objects.filter(
|
||||
email_sent=False,
|
||||
).order_by("username")
|
||||
logger.info("Found %d transition domains", len(self.transition_domains))
|
||||
|
||||
self.build_emails_to_send_array()
|
||||
logger.info("Prepared %d emails to send", len(self.emails_to_send))
|
||||
|
||||
if options["send_emails"]:
|
||||
logger.info("about to send emails")
|
||||
|
@ -58,6 +60,12 @@ class Command(BaseCommand):
|
|||
logger.info("done sending emails and updating transition_domains")
|
||||
else:
|
||||
logger.info("not sending emails")
|
||||
for email_context in self.emails_to_send:
|
||||
logger.info(
|
||||
"would send email to %s for %s",
|
||||
email_context["email"],
|
||||
email_context["domains"],
|
||||
)
|
||||
|
||||
def build_emails_to_send_array(self):
|
||||
"""this method sends emails to distinct usernames"""
|
||||
|
|
|
@ -3,7 +3,6 @@ import logging
|
|||
import ipaddress
|
||||
import re
|
||||
from datetime import date
|
||||
from string import digits
|
||||
from typing import Optional
|
||||
|
||||
from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore
|
||||
|
@ -277,7 +276,12 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
return response.code
|
||||
except RegistryError as e:
|
||||
logger.error("Error _create_host, code was %s error was %s" % (e.code, e))
|
||||
raise e
|
||||
# OBJECT_EXISTS is an expected error code that should not raise
|
||||
# an exception, rather return the code to be handled separately
|
||||
if e.code == ErrorCode.OBJECT_EXISTS:
|
||||
return e.code
|
||||
else:
|
||||
raise e
|
||||
|
||||
def _convert_list_to_dict(self, listToConvert: list[tuple[str, list]]):
|
||||
"""converts a list of hosts into a dictionary
|
||||
|
@ -305,30 +309,29 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
return bool(regex.match(nameserver))
|
||||
|
||||
@classmethod
|
||||
def isValidDomain(cls, nameserver: str):
|
||||
def isValidHost(cls, nameserver: str):
|
||||
"""Checks for validity of nameserver string based on these rules:
|
||||
- first character is alpha
|
||||
- last character is not - or .
|
||||
- all characters alpha, 0-9, -, or .
|
||||
- 2 character min, 24 character max (not including dashes/periods)
|
||||
- first character is alpha or digit
|
||||
- first and last character in each label is alpha or digit
|
||||
- all characters alpha (lowercase), digit, -, or .
|
||||
- each label has a min length of 1 and a max length of 63
|
||||
- total host name has a max length of 253
|
||||
"""
|
||||
# pattern to test for valid domain
|
||||
# pattern = r'^[a-zA-Z][a-zA-Z0-9-.]{0,22}[a-zA-Z0-9]$'
|
||||
pattern = r"^[a-zA-Z][a-zA-Z0-9-.]*(\.[a-zA-Z0-9-]+){2}[a-zA-Z0-9]$"
|
||||
# label pattern for each section of the host name, separated by .
|
||||
labelpattern = r"[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?"
|
||||
# lookahead pattern ensures first character not - and total length < 254
|
||||
lookaheadpatterns = r"^((?!-))(?=.{1,253}\.?$)"
|
||||
# pattern assembles lookaheadpatterns and ensures there are at least
|
||||
# 3 labels in the host name
|
||||
pattern = lookaheadpatterns + labelpattern + r"(\." + labelpattern + r"){2,}$"
|
||||
|
||||
# attempt to match the pattern
|
||||
match = re.match(pattern, nameserver)
|
||||
|
||||
# length of nameserver, not including - or .
|
||||
characters_to_exclude = "-."
|
||||
filtered_nameserver = "".join(
|
||||
char for char in nameserver if char not in characters_to_exclude
|
||||
)
|
||||
nameserverLength = len(filtered_nameserver)
|
||||
|
||||
# return true if nameserver matches, and length less than 25;
|
||||
# return true if nameserver matches
|
||||
# otherwise false
|
||||
return bool(match) and nameserverLength < 25
|
||||
return bool(match)
|
||||
|
||||
@classmethod
|
||||
def checkHostIPCombo(cls, name: str, nameserver: str, ip: list[str]):
|
||||
|
@ -349,7 +352,7 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
None"""
|
||||
if ip and not nameserver:
|
||||
raise NameserverError(code=nsErrorCodes.MISSING_HOST)
|
||||
elif nameserver and not cls.isValidDomain(nameserver):
|
||||
elif nameserver and not cls.isValidHost(nameserver):
|
||||
raise NameserverError(code=nsErrorCodes.INVALID_HOST, nameserver=nameserver)
|
||||
elif cls.isSubdomain(name, nameserver) and (ip is None or ip == []):
|
||||
raise NameserverError(code=nsErrorCodes.MISSING_IP, nameserver=nameserver)
|
||||
|
@ -362,7 +365,7 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
for addr in ip:
|
||||
if not cls._valid_ip_addr(addr):
|
||||
raise NameserverError(
|
||||
code=nsErrorCodes.INVALID_IP, nameserver=nameserver, ip=ip
|
||||
code=nsErrorCodes.INVALID_IP, nameserver=nameserver[:40], ip=ip
|
||||
)
|
||||
return None
|
||||
|
||||
|
@ -1228,34 +1231,6 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
# ForeignKey on DomainInvitation creates an "invitations" member for
|
||||
# all of the invitations that have been sent for this domain
|
||||
|
||||
def _validate_host_tuples(self, hosts: list[tuple[str]]):
|
||||
"""
|
||||
Helper function. Validate hostnames and IP addresses.
|
||||
|
||||
Raises:
|
||||
ValueError if hostname or IP address appears invalid or mismatched.
|
||||
"""
|
||||
for host in hosts:
|
||||
hostname = host[0].lower()
|
||||
addresses: tuple[str] = host[1:] # type: ignore
|
||||
if not bool(Domain.HOST_REGEX.match(hostname)):
|
||||
raise ValueError("Invalid hostname: %s." % hostname)
|
||||
if len(hostname) > Domain.MAX_LENGTH:
|
||||
raise ValueError("Too long hostname: %s" % hostname)
|
||||
|
||||
is_subordinate = hostname.split(".", 1)[-1] == self.name
|
||||
if is_subordinate and len(addresses) == 0:
|
||||
raise ValueError(
|
||||
"Must supply IP addresses for subordinate host %s" % hostname
|
||||
)
|
||||
if not is_subordinate and len(addresses) > 0:
|
||||
raise ValueError("Must not supply IP addresses for %s" % hostname)
|
||||
|
||||
for address in addresses:
|
||||
allow = set(":." + digits)
|
||||
if any(c not in allow for c in address):
|
||||
raise ValueError("Invalid IP address: %s." % address)
|
||||
|
||||
def _get_or_create_domain(self):
|
||||
"""Try to fetch info about this domain. Create it if it does not exist."""
|
||||
already_tried_to_create = False
|
||||
|
@ -1625,7 +1600,12 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
return response.code
|
||||
except RegistryError as e:
|
||||
logger.error("Error _update_host, code was %s error was %s" % (e.code, e))
|
||||
raise e
|
||||
# OBJECT_EXISTS is an expected error code that should not raise
|
||||
# an exception, rather return the code to be handled separately
|
||||
if e.code == ErrorCode.OBJECT_EXISTS:
|
||||
return e.code
|
||||
else:
|
||||
raise e
|
||||
|
||||
def addAndRemoveHostsFromDomain(
|
||||
self, hostsToAdd: list[str], hostsToDelete: list[str]
|
||||
|
|
|
@ -11,10 +11,6 @@ class DomainHelper:
|
|||
# begin or end with a hyphen, followed by a TLD of 2-6 alphabetic characters
|
||||
DOMAIN_REGEX = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)\.[A-Za-z]{2,6}$")
|
||||
|
||||
# a domain name is alphanumeric or hyphen, has at least 2 dots, doesn't
|
||||
# begin or end with a hyphen, followed by a TLD of 2-6 alphabetic characters
|
||||
HOST_REGEX = re.compile(r"^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\.){2,}([A-Za-z]){2,6}$")
|
||||
|
||||
# a domain can be no longer than 253 characters in total
|
||||
MAX_LENGTH = 253
|
||||
|
||||
|
|
23
src/registrar/templates/django/admin/domain_change_list.html
Normal file
23
src/registrar/templates/django/admin/domain_change_list.html
Normal file
|
@ -0,0 +1,23 @@
|
|||
{% extends "admin/change_list.html" %}
|
||||
|
||||
{% block object-tools %}
|
||||
|
||||
<ul class="object-tools">
|
||||
<li>
|
||||
<a href="{% url 'admin:registrar_domain_export_data_type' %}" class="button">Export all domain metadata</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'admin:registrar_domain_export_data_full' %}" class="button">Export current-full.csv</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'admin:registrar_domain_export_data_federal' %}" class="button">Export current-federal.csv</a>
|
||||
</li>
|
||||
{% if has_add_permission %}
|
||||
<li>
|
||||
<a href="{% url 'admin:registrar_domain_add' %}" class="addlink">
|
||||
Add Domain
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endblock %}
|
|
@ -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 %}
|
||||
<abbr class="usa-hint usa-hint--required" title="required">*</abbr>
|
||||
{% endif %}
|
||||
{% if span_for_text %}
|
||||
<span>{{ field.label }}</span>
|
||||
{% else %}
|
||||
{{ field.label }}
|
||||
{% endif %}
|
||||
{% if widget.attrs.required %}
|
||||
<abbr class="usa-hint usa-hint--required" title="required">*</abbr>
|
||||
{% endif %}
|
||||
</{{ label_tag }}>
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
<div class="usa-alert usa-alert--slim usa-alert--info">
|
||||
<div class="usa-alert__body">
|
||||
<p class="margin-top-0">Add an IP address only when your name server's address includes your domain name (e.g., if your domain name is "example.gov" and your name server is "ns1.example.gov,” then an IP address is required.) To add multiple IP addresses, separate them with commas.</p>
|
||||
<p class="margin-top-0">Add an IP address only when your name server's address includes your domain name (e.g., if your domain name is “example.gov” and your name server is “ns1.example.gov,” then an IP address is required.) To add multiple IP addresses, separate them with commas.</p>
|
||||
<p class="margin-bottom-0">This step is uncommon unless you self-host your DNS or use custom addresses for your nameserver.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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 <span>, 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 %}
|
||||
</div>
|
||||
|
@ -49,14 +52,11 @@
|
|||
{% endwith %}
|
||||
</div>
|
||||
<div class="tablet:grid-col-2">
|
||||
{% comment %} TODO: remove this if for 1192 {% endcomment %}
|
||||
{% if forloop.counter > 2 %}
|
||||
<button type="button" class="usa-button usa-button--unstyled display-block delete-record margin-bottom-075">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
|
||||
</svg><span class="margin-left-05">Delete</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
<button type="button" class="usa-button usa-button--unstyled display-block delete-record margin-bottom-075">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
|
||||
</svg><span class="margin-left-05">Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -1630,6 +1630,56 @@ class TestRegistrantNameservers(MockEppLib):
|
|||
return super().tearDown()
|
||||
|
||||
|
||||
class TestNameserverValidation(TestCase):
|
||||
"""Test the isValidDomain method which validates nameservers"""
|
||||
|
||||
def test_255_chars_is_too_long(self):
|
||||
"""Test that domain of 255 chars or longer is invalid"""
|
||||
domain_too_long = (
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
".bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
".bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
".bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
".bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
".bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.gov"
|
||||
)
|
||||
self.assertFalse(Domain.isValidHost(domain_too_long))
|
||||
|
||||
def test_64_char_label_too_long(self):
|
||||
"""Test that label of 64 characters or longer is invalid"""
|
||||
label_too_long = (
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
)
|
||||
domain_label_too_long = "www." + label_too_long + ".gov"
|
||||
self.assertFalse(Domain.isValidHost(domain_label_too_long))
|
||||
|
||||
def test_only_tld_and_sld(self):
|
||||
"""Test that host with only a tld and sld is invalid"""
|
||||
tld = "gov"
|
||||
sld = "example"
|
||||
domain_with_sld_and_tld = sld + "." + tld
|
||||
self.assertFalse(Domain.isValidHost(domain_with_sld_and_tld))
|
||||
|
||||
def test_improper_chars_in_nameserver(self):
|
||||
"""Test that host with improper chars is invalid"""
|
||||
invalid_chars = "*&^"
|
||||
domain_with_invalid_chars = "www.bad--" + invalid_chars + ".gov"
|
||||
self.assertFalse(Domain.isValidHost(domain_with_invalid_chars))
|
||||
|
||||
def test_misplaced_dashes(self):
|
||||
"""Test that misplaced dashes are invalid"""
|
||||
self.assertFalse(Domain.isValidHost("-www.example.gov"))
|
||||
self.assertFalse(Domain.isValidHost("www.example-.gov"))
|
||||
self.assertTrue(Domain.isValidHost("www.ex-ample.gov"))
|
||||
|
||||
def test_valid_hostname(self):
|
||||
"""Test that valid hostnames are valid"""
|
||||
self.assertTrue(Domain.isValidHost("www.tld.sld.gov"))
|
||||
self.assertTrue(Domain.isValidHost("www.valid.c"))
|
||||
self.assertTrue(Domain.isValidHost("2ww.valid.gov"))
|
||||
self.assertTrue(Domain.isValidHost("w.t.g"))
|
||||
|
||||
|
||||
class TestRegistrantDNSSEC(MockEppLib):
|
||||
"""Rule: Registrants may modify their secure DNS data"""
|
||||
|
||||
|
|
195
src/registrar/tests/test_reports.py
Normal file
195
src/registrar/tests/test_reports.py
Normal file
|
@ -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)
|
|
@ -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.
|
||||
|
@ -1665,7 +1671,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):
|
||||
|
@ -1833,7 +1844,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."),
|
||||
(
|
||||
|
@ -1868,7 +1883,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
|
||||
|
|
119
src/registrar/utility/csv_export.py
Normal file
119
src/registrar/utility/csv_export.py
Normal file
|
@ -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)
|
|
@ -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."),
|
||||
}
|
||||
|
||||
|
@ -104,7 +106,9 @@ class NameserverError(Exception):
|
|||
NameserverErrorCodes.MISSING_HOST: (
|
||||
"Name server must be provided to enter IP address."
|
||||
),
|
||||
NameserverErrorCodes.INVALID_HOST: ("Name server, {}, is not valid."),
|
||||
NameserverErrorCodes.INVALID_HOST: (
|
||||
"Enter a name server in the required format, like ns1.example.com"
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, code=None, nameserver=None, ip=None, **kwargs):
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue