Merge branch 'main' into za/1291-allow-staff-to-access-user-domain-roles

This commit is contained in:
zandercymatics 2023-12-06 08:53:43 -07:00
commit 278166729b
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
59 changed files with 1357 additions and 150 deletions

33
.github/workflows/daily-csv-upload.yaml vendored Normal file
View file

@ -0,0 +1,33 @@
name: Upload current-full.csv and current-federal.csv
run-name: Upload current-full.csv and current-federal.csv
on:
schedule:
# Runs every day at 5 AM UTC.
- cron: "0 5 * * *"
jobs:
upload-reports:
runs-on: ubuntu-latest
env:
CF_USERNAME: CF_${{ secrets.CF_REPORT_ENV }}_USERNAME
CF_PASSWORD: CF_${{ secrets.CF_REPORT_ENV }}_PASSWORD
steps:
- name: Generate current-federal.csv
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov
cf_space: ${{ secrets.CF_REPORT_ENV }}
cf_command: "run-task getgov-${{ secrets.CF_REPORT_ENV }} --command 'python manage.py generate_current_federal_report' --name federal"
- name: Generate current-full.csv
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov
cf_space: ${{ secrets.CF_REPORT_ENV }}
cf_command: "run-task getgov-${{ secrets.CF_REPORT_ENV }} --command 'python manage.py generate_current_full_report' --name full"

View file

@ -15,7 +15,6 @@ on:
jobs:
deploy-development:
if: ${{ github.ref_type == 'tag' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3

View file

@ -47,9 +47,8 @@ jobs:
working-directory: ./src
run: docker compose run app python manage.py collectstatic --no-input
- name: Deploy to cloud.gov sandbox
uses: 18f/cg-deploy-action@main
uses: cloud-gov/cg-cli-tools@main
env:
DEPLOY_NOW: thanks
ENVIRONMENT: ${{ needs.variables.outputs.environment }}
CF_USERNAME: CF_${{ needs.variables.outputs.environment }}_USERNAME
CF_PASSWORD: CF_${{ needs.variables.outputs.environment }}_PASSWORD
@ -58,7 +57,7 @@ jobs:
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov
cf_space: ${{ env.ENVIRONMENT }}
push_arguments: "-f ops/manifests/manifest-${{ env.ENVIRONMENT }}.yaml"
cf_manifest: ops/manifests/manifest-${{ env.ENVIRONMENT }}.yaml
comment:
runs-on: ubuntu-latest
needs: [variables, deploy]

View file

@ -30,12 +30,10 @@ jobs:
working-directory: ./src
run: docker compose run app python manage.py collectstatic --no-input
- name: Deploy to cloud.gov sandbox
uses: 18f/cg-deploy-action@main
env:
DEPLOY_NOW: thanks
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets.CF_STABLE_USERNAME }}
cf_password: ${{ secrets.CF_STABLE_PASSWORD }}
cf_org: cisa-dotgov
cf_space: stable
push_arguments: "-f ops/manifests/manifest-stable.yaml"
cf_manifest: "ops/manifests/manifest-stable.yaml"

View file

@ -9,7 +9,7 @@ on:
- 'docs/**'
- '**.md'
- '.gitignore'
tags:
- staging-*
@ -30,12 +30,10 @@ jobs:
working-directory: ./src
run: docker compose run app python manage.py collectstatic --no-input
- name: Deploy to cloud.gov sandbox
uses: 18f/cg-deploy-action@main
env:
DEPLOY_NOW: thanks
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets.CF_STAGING_USERNAME }}
cf_password: ${{ secrets.CF_STAGING_PASSWORD }}
cf_org: cisa-dotgov
cf_space: staging
push_arguments: "-f ops/manifests/manifest-staging.yaml"
cf_manifest: "ops/manifests/manifest-staging.yaml"

View file

@ -38,10 +38,10 @@ jobs:
CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
steps:
- name: Run Django migrations for ${{ github.event.inputs.environment }}
uses: 18f/cg-deploy-action@main
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov
cf_space: ${{ github.event.inputs.environment }}
full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py migrate' --name migrate"
cf_command: "run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py migrate' --name migrate"

View file

@ -38,28 +38,28 @@ jobs:
CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
steps:
- name: Delete existing data for ${{ github.event.inputs.environment }}
uses: 18f/cg-deploy-action@main
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov
cf_space: ${{ github.event.inputs.environment }}
full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py flush --no-input' --name flush"
cf_command: "run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py flush --no-input' --name flush"
- name: Run Django migrations for ${{ github.event.inputs.environment }}
uses: 18f/cg-deploy-action@main
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov
cf_space: ${{ github.event.inputs.environment }}
full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py migrate' --name migrate"
cf_command: "run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py migrate' --name migrate"
- name: Load fake data for ${{ github.event.inputs.environment }}
uses: 18f/cg-deploy-action@main
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov
cf_space: ${{ github.event.inputs.environment }}
full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py load' --name loaddata"
cf_command: "run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py load' --name loaddata"

View file

@ -295,7 +295,7 @@ sudo sntp -sS time.nist.gov
```
## Connection pool
To handle our connection to the registry, we utilize a connection pool to keep a socket open to increase responsiveness. In order to accomplish this, we are utilizing a heavily modified version of the (geventconnpool)[https://github.com/rasky/geventconnpool] library.
To handle our connection to the registry, we utilize a connection pool to keep a socket open to increase responsiveness. In order to accomplish this, we are utilizing a heavily modified version of the [geventconnpool](https://github.com/rasky/geventconnpool) library.
### Settings
The config for the connection pool exists inside the `settings.py` file.
@ -319,4 +319,36 @@ Our connection pool has a built-in `pool_status` object which you can call at an
5. `print(registry.pool_status.connection_success)`
* Should return true
If you have multiple instances (staging for example), then repeat commands 1-5 for each instance you want to test.
If you have multiple instances (staging for example), then repeat commands 1-5 for each instance you want to test.
## Adding a S3 instance to your sandbox
This can either be done through the CLI, or through the cloud.gov dashboard. Generally, it is better to do it through the dashboard as it handles app binding for you.
To associate a S3 instance to your sandbox, follow these steps:
1. Navigate to https://dashboard.fr.cloud.gov/login
2. Select your sandbox from the `Applications` tab
3. Click `Services` on the application nav bar
4. Add a new service (plus symbol)
5. Click `Marketplace Service`
6. On the `Select the service` dropdown, select `s3`
7. Under the dropdown on `Select Plan`, select `basic-sandbox`
8. Under `Service Instance` enter `getgov-s3` for the name
See this [resource](https://cloud.gov/docs/services/s3/) for information on associating an S3 instance with your sandbox through the CLI.
### Testing your S3 instance locally
To test the S3 bucket associated with your sandbox, you will need to add four additional variables to your `.env` file. These are as follows:
```
AWS_S3_ACCESS_KEY_ID = "{string value of `access_key_id` in getgov-s3}"
AWS_S3_SECRET_ACCESS_KEY = "{string value of `secret_access_key` in getgov-s3}"
AWS_S3_REGION = "{string value of `region` in getgov-s3}"
AWS_S3_BUCKET_NAME = "{string value of `bucket` in getgov-s3}"
```
You can view these variables by running the following command:
```
cf env getgov-{app name}
```
Then, copy the variables under the section labled `s3`.

View file

@ -8,6 +8,7 @@ from django.test import RequestFactory
from ..views import available, check_domain_available
from .common import less_console_noise
from registrar.tests.common import MockEppLib
from registrar.utility.errors import GenericError, GenericErrorCodes
from unittest.mock import call
from epplibwrapper import (
@ -100,16 +101,25 @@ class AvailableViewTest(MockEppLib):
response = available(request, domain="igorville")
self.assertTrue(json.loads(response.content)["available"])
def test_error_handling(self):
"""Calling with bad strings raises an error."""
def test_bad_string_handling(self):
"""Calling with bad strings returns unavailable."""
bad_string = "blah!;"
request = self.factory.get(API_BASE_PATH + bad_string)
request.user = self.user
response = available(request, domain=bad_string)
self.assertFalse(json.loads(response.content)["available"])
# domain set to raise error returns false for availability
error_domain_available = available(request, "errordomain.gov")
self.assertFalse(json.loads(error_domain_available.content)["available"])
def test_error_handling(self):
"""Error thrown while calling availabilityAPI returns error."""
request = self.factory.get(API_BASE_PATH + "errordomain.gov")
request.user = self.user
# domain set to raise error returns false for availability and error message
error_domain_response = available(request, domain="errordomain.gov")
self.assertFalse(json.loads(error_domain_response.content)["available"])
self.assertEqual(
GenericError.get_error_message(GenericErrorCodes.CANNOT_CONTACT_REGISTRY),
json.loads(error_domain_response.content)["message"],
)
class AvailableAPITest(MockEppLib):

View file

@ -1,10 +1,11 @@
"""Internal API views"""
from django.apps import apps
from django.views.decorators.http import require_http_methods
from django.http import JsonResponse
from django.http import HttpResponse, JsonResponse
from django.utils.safestring import mark_safe
from registrar.templatetags.url_helpers import public_site_url
from registrar.utility.errors import GenericError, GenericErrorCodes
import requests
@ -12,6 +13,8 @@ from login_required import login_not_required
from cachetools.func import ttl_cache
from registrar.utility.s3_bucket import S3ClientError, S3ClientHelper
DOMAIN_FILE_URL = "https://raw.githubusercontent.com/cisagov/dotgov-data/main/current-full.csv"
@ -30,7 +33,7 @@ DOMAIN_API_MESSAGES = {
),
"invalid": "Enter a domain using only letters, numbers, or hyphens (though we don't recommend using hyphens).",
"success": "That domain is available!",
"error": "Error finding domain availability.",
"error": GenericError.get_error_message(GenericErrorCodes.CANNOT_CONTACT_REGISTRY),
}
@ -63,17 +66,14 @@ def check_domain_available(domain):
The given domain is lowercased to match against the domains list. If the
given domain doesn't end with .gov, ".gov" is added when looking for
a match.
a match. If check fails, throws a RegistryError.
"""
Domain = apps.get_model("registrar.Domain")
try:
if domain.endswith(".gov"):
return Domain.available(domain)
else:
# domain search string doesn't end with .gov, add it on here
return Domain.available(domain + ".gov")
except Exception:
return False
if domain.endswith(".gov"):
return Domain.available(domain)
else:
# domain search string doesn't end with .gov, add it on here
return Domain.available(domain + ".gov")
@require_http_methods(["GET"])
@ -97,3 +97,36 @@ def available(request, domain=""):
return JsonResponse({"available": False, "message": DOMAIN_API_MESSAGES["unavailable"]})
except Exception:
return JsonResponse({"available": False, "message": DOMAIN_API_MESSAGES["error"]})
@require_http_methods(["GET"])
@login_not_required
def get_current_full(request, file_name="current-full.csv"):
"""This will return the file content of current-full.csv which is the command
output of generate_current_full_report.py. This command iterates through each Domain
and returns a CSV representation."""
return serve_file(file_name)
@require_http_methods(["GET"])
@login_not_required
def get_current_federal(request, file_name="current-federal.csv"):
"""This will return the file content of current-federal.csv which is the command
output of generate_current_federal_report.py. This command iterates through each Domain
and returns a CSV representation."""
return serve_file(file_name)
def serve_file(file_name):
"""Downloads a file based on a given filepath. Returns a 500 if not found."""
s3_client = S3ClientHelper()
# Serve the CSV file. If not found, an exception will be thrown.
# This will then be caught by flat, causing it to not read it - which is what we want.
try:
file = s3_client.get_file(file_name, decode_to_utf=True)
except S3ClientError as err:
# TODO - #1317: Notify operations when auto report generation fails
raise err
response = HttpResponse(file)
return response

View file

@ -51,6 +51,11 @@ services:
# AWS credentials
- AWS_ACCESS_KEY_ID
- AWS_SECRET_ACCESS_KEY
# AWS S3 bucket credentials
- AWS_S3_ACCESS_KEY_ID
- AWS_S3_SECRET_ACCESS_KEY
- AWS_S3_REGION
- AWS_S3_BUCKET_NAME
stdin_open: true
tty: true
ports:

View file

@ -118,6 +118,15 @@ class ListHeaderAdmin(AuditedAdmin):
)
return filters
# customize the help_text for all formfields for manytomany
def formfield_for_manytomany(self, db_field, request, **kwargs):
formfield = super().formfield_for_manytomany(db_field, request, **kwargs)
formfield.help_text = (
formfield.help_text
+ " If more than one value is selected, the change/delete/view actions will be disabled."
)
return formfield
class UserContactInline(admin.StackedInline):
"""Edit a user's profile on the user page."""
@ -325,6 +334,14 @@ class WebsiteAdmin(ListHeaderAdmin):
class UserDomainRoleAdmin(ListHeaderAdmin):
"""Custom user domain role admin class."""
class Meta:
"""Contains meta information about this class"""
model = models.UserDomainRole
fields = "__all__"
_meta = Meta()
# Columns
list_display = [
"user",
@ -336,10 +353,11 @@ class UserDomainRoleAdmin(ListHeaderAdmin):
search_fields = [
"user__first_name",
"user__last_name",
"user__email",
"domain__name",
"role",
]
search_help_text = "Search by user, domain, or role."
search_help_text = "Search by firstname, lastname, email, domain, or role."
autocomplete_fields = ["user", "domain"]
@ -448,7 +466,7 @@ class DomainInformationAdmin(ListHeaderAdmin):
"No other employees from your organization?",
{"fields": ["no_other_contacts_rationale"]},
),
("Anything else we should know?", {"fields": ["anything_else"]}),
("Anything else?", {"fields": ["anything_else"]}),
(
"Requirements for operating .gov domains",
{"fields": ["is_policy_acknowledged"]},
@ -467,6 +485,17 @@ class DomainInformationAdmin(ListHeaderAdmin):
"is_policy_acknowledged",
]
# For each filter_horizontal, init in admin js extendFilterHorizontalWidgets
# to activate the edit/delete/view buttons
filter_horizontal = ("other_contacts",)
# lists in filter_horizontal are not sorted properly, sort them
# by first_name
def formfield_for_manytomany(self, db_field, request, **kwargs):
if db_field.name in ("other_contacts",):
kwargs["queryset"] = models.Contact.objects.all().order_by("first_name") # Sort contacts
return super().formfield_for_manytomany(db_field, request, **kwargs)
def get_readonly_fields(self, request, obj=None):
"""Set the read-only state on form elements.
We have 1 conditions that determine which fields are read-only:
@ -583,7 +612,7 @@ class DomainApplicationAdmin(ListHeaderAdmin):
"No other employees from your organization?",
{"fields": ["no_other_contacts_rationale"]},
),
("Anything else we should know?", {"fields": ["anything_else"]}),
("Anything else?", {"fields": ["anything_else"]}),
(
"Requirements for operating .gov domains",
{"fields": ["is_policy_acknowledged"]},
@ -603,6 +632,15 @@ class DomainApplicationAdmin(ListHeaderAdmin):
"is_policy_acknowledged",
]
filter_horizontal = ("current_websites", "alternative_domains", "other_contacts")
# lists in filter_horizontal are not sorted properly, sort them
# by website
def formfield_for_manytomany(self, db_field, request, **kwargs):
if db_field.name in ("current_websites", "alternative_domains"):
kwargs["queryset"] = models.Website.objects.all().order_by("website") # Sort websites
return super().formfield_for_manytomany(db_field, request, **kwargs)
# Trigger action when a fieldset is changed
def save_model(self, request, obj, form, change):
if obj and obj.creator.status != models.User.RESTRICTED:
@ -731,6 +769,16 @@ class DomainInformationInline(admin.StackedInline):
fieldsets = DomainInformationAdmin.fieldsets
analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields
# For each filter_horizontal, init in admin js extendFilterHorizontalWidgets
# to activate the edit/delete/view buttons
filter_horizontal = ("other_contacts",)
# lists in filter_horizontal are not sorted properly, sort them
# by first_name
def formfield_for_manytomany(self, db_field, request, **kwargs):
if db_field.name in ("other_contacts",):
kwargs["queryset"] = models.Contact.objects.all().order_by("first_name") # Sort contacts
return super().formfield_for_manytomany(db_field, request, **kwargs)
def get_readonly_fields(self, request, obj=None):
return DomainInformationAdmin.get_readonly_fields(self, request, obj=None)

View file

@ -47,4 +47,231 @@ function openInNewTab(el, removeAttribute = false){
}
prepareDjangoAdmin();
})();
})();
/**
* An IIFE to listen to changes on filter_horizontal and enable or disable the change/delete/view buttons as applicable
*
*/
(function extendFilterHorizontalWidgets() {
// Initialize custom filter_horizontal widgets; each widget has a "from" select list
// and a "to" select list; initialization is based off of the presence of the
// "to" select list
checkToListThenInitWidget('id_other_contacts_to', 0);
checkToListThenInitWidget('id_domain_info-0-other_contacts_to', 0);
checkToListThenInitWidget('id_current_websites_to', 0);
checkToListThenInitWidget('id_alternative_domains_to', 0);
})();
// Function to check for the existence of the "to" select list element in the DOM, and if and when found,
// initialize the associated widget
function checkToListThenInitWidget(toListId, attempts) {
let toList = document.getElementById(toListId);
attempts++;
if (attempts < 6) {
if ((toList !== null)) {
// toList found, handle it
// Add an event listener on the element
// Add disabled buttons on the element's great-grandparent
initializeWidgetOnToList(toList, toListId);
} else {
// Element not found, check again after a delay
setTimeout(() => checkToListThenInitWidget(toListId, attempts), 1000); // Check every 1000 milliseconds (1 second)
}
}
}
// Initialize the widget:
// add related buttons to the widget for edit, delete and view
// add event listeners on the from list, the to list, and selector buttons which either enable or disable the related buttons
function initializeWidgetOnToList(toList, toListId) {
// create the change button
let changeLink = createAndCustomizeLink(
toList,
toListId,
'related-widget-wrapper-link change-related',
'Change',
'/public/admin/img/icon-changelink.svg',
{
'contacts': '/admin/registrar/contact/__fk__/change/?_to_field=id&_popup=1',
'websites': '/admin/registrar/website/__fk__/change/?_to_field=id&_popup=1',
'alternative_domains': '/admin/registrar/website/__fk__/change/?_to_field=id&_popup=1',
},
true,
true
);
let hasDeletePermission = hasDeletePermissionOnPage();
let deleteLink = null;
if (hasDeletePermission) {
// create the delete button if user has permission to delete
deleteLink = createAndCustomizeLink(
toList,
toListId,
'related-widget-wrapper-link delete-related',
'Delete',
'/public/admin/img/icon-deletelink.svg',
{
'contacts': '/admin/registrar/contact/__fk__/delete/?_to_field=id&amp;_popup=1',
'websites': '/admin/registrar/website/__fk__/delete/?_to_field=id&_popup=1',
'alternative_domains': '/admin/registrar/website/__fk__/delete/?_to_field=id&_popup=1',
},
true,
false
);
}
// create the view button
let viewLink = createAndCustomizeLink(
toList,
toListId,
'related-widget-wrapper-link view-related',
'View',
'/public/admin/img/icon-viewlink.svg',
{
'contacts': '/admin/registrar/contact/__fk__/change/?_to_field=id',
'websites': '/admin/registrar/website/__fk__/change/?_to_field=id',
'alternative_domains': '/admin/registrar/website/__fk__/change/?_to_field=id',
},
false,
false
);
// identify the fromList element in the DOM
let fromList = toList.closest('.selector').querySelector(".selector-available select");
fromList.addEventListener('click', function(event) {
handleSelectClick(fromList, changeLink, deleteLink, viewLink);
});
toList.addEventListener('click', function(event) {
handleSelectClick(toList, changeLink, deleteLink, viewLink);
});
// Disable buttons when the selectors are interacted with (items are moved from one column to the other)
let selectorButtons = [];
selectorButtons.push(toList.closest(".selector").querySelector(".selector-chooseall"));
selectorButtons.push(toList.closest(".selector").querySelector(".selector-add"));
selectorButtons.push(toList.closest(".selector").querySelector(".selector-remove"));
selectorButtons.forEach((selector) => {
selector.addEventListener("click", ()=>{disableRelatedWidgetButtons(changeLink, deleteLink, viewLink)});
});
}
// create and customize the button, then add to the DOM, relative to the toList
// toList - the element in the DOM for the toList
// toListId - the ID of the element in the DOM
// className - className to add to the created link
// action - the action to perform on the item {change, delete, view}
// imgSrc - the img.src for the created link
// dataMappings - dictionary which relates toListId to href for the created link
// dataPopup - boolean for whether the link should produce a popup window
// firstPosition - boolean indicating if link should be first position in list of links, otherwise, should be last link
function createAndCustomizeLink(toList, toListId, className, action, imgSrc, dataMappings, dataPopup, firstPosition) {
// Create a link element
var link = document.createElement('a');
// Set class attribute for the link
link.className = className;
// Set id
// Determine function {change, link, view} from the className
// Add {function}_ to the beginning of the string
let modifiedLinkString = className.split('-')[0] + '_' + toListId;
// Remove '_to' from the end of the string
modifiedLinkString = modifiedLinkString.replace('_to', '');
link.id = modifiedLinkString;
// Set data-href-template
for (const [idPattern, template] of Object.entries(dataMappings)) {
if (toListId.includes(idPattern)) {
link.setAttribute('data-href-template', template);
break; // Stop checking once a match is found
}
}
if (dataPopup)
link.setAttribute('data-popup', 'yes');
link.setAttribute('title-template', action + " selected item")
link.title = link.getAttribute('title-template');
// Create an 'img' element
var img = document.createElement('img');
// Set attributes for the new image
img.src = imgSrc;
img.alt = action;
// Append the image to the link
link.appendChild(img);
let relatedWidgetWrapper = toList.closest('.related-widget-wrapper');
// If firstPosition is true, insert link as the first child element
if (firstPosition) {
relatedWidgetWrapper.insertBefore(link, relatedWidgetWrapper.children[0]);
} else {
// otherwise, insert the link prior to the last child (which is a div)
// and also prior to any text elements immediately preceding the last
// child node
var lastChild = relatedWidgetWrapper.lastChild;
// Check if lastChild is an element node (not a text node, comment, etc.)
if (lastChild.nodeType === 1) {
var previousSibling = lastChild.previousSibling;
// need to work around some white space which has been inserted into the dom
while (previousSibling.nodeType !== 1) {
previousSibling = previousSibling.previousSibling;
}
relatedWidgetWrapper.insertBefore(link, previousSibling.nextSibling);
}
}
// Return the link, which we'll use in the disable and enable functions
return link;
}
// Either enable or disable widget buttons when select is clicked. Action (enable or disable) taken depends on the count
// of selected items in selectElement. If exactly one item is selected, buttons are enabled, and urls for the buttons are
// associated with the selected item
function handleSelectClick(selectElement, changeLink, deleteLink, viewLink) {
// If one item is selected (across selectElement and relatedSelectElement), enable buttons; otherwise, disable them
if (selectElement.selectedOptions.length === 1) {
// enable buttons for selected item in selectElement
enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, selectElement.selectedOptions[0].value, selectElement.selectedOptions[0].text);
} else {
disableRelatedWidgetButtons(changeLink, deleteLink, viewLink);
}
}
// return true if there exist elements on the page with classname of delete-related.
// presence of one or more of these elements indicates user has permission to delete
function hasDeletePermissionOnPage() {
return document.querySelector('.delete-related') != null
}
function disableRelatedWidgetButtons(changeLink, deleteLink, viewLink) {
changeLink.removeAttribute('href');
changeLink.setAttribute('title', changeLink.getAttribute('title-template'));
if (deleteLink) {
deleteLink.removeAttribute('href');
deleteLink.setAttribute('title', deleteLink.getAttribute('title-template'));
}
viewLink.removeAttribute('href');
viewLink.setAttribute('title', viewLink.getAttribute('title-template'));
}
function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk, elementText) {
changeLink.setAttribute('href', changeLink.getAttribute('data-href-template').replace('__fk__', elementPk));
changeLink.setAttribute('title', changeLink.getAttribute('title-template').replace('selected item', elementText));
if (deleteLink) {
deleteLink.setAttribute('href', deleteLink.getAttribute('data-href-template').replace('__fk__', elementPk));
deleteLink.setAttribute('title', deleteLink.getAttribute('title-template').replace('selected item', elementText));
}
viewLink.setAttribute('href', viewLink.getAttribute('data-href-template').replace('__fk__', elementPk));
viewLink.setAttribute('title', viewLink.getAttribute('title-template').replace('selected item', elementText));
}

View file

@ -33,11 +33,20 @@ env = environs.Env()
# Get secrets from Cloud.gov user provided service, if exists
# If not, get secrets from environment variables
key_service = AppEnv().get_service(name="getgov-credentials")
# Get secrets from Cloud.gov user provided s3 service, if it exists
s3_key_service = AppEnv().get_service(name="getgov-s3")
if key_service and key_service.credentials:
if s3_key_service and s3_key_service.credentials:
# Concatenate the credentials from our S3 service into our secret service
key_service.credentials.update(s3_key_service.credentials)
secret = key_service.credentials.get
else:
secret = env
# # # ###
# Values obtained externally #
# # # ###
@ -58,6 +67,12 @@ secret_key = secret("DJANGO_SECRET_KEY")
secret_aws_ses_key_id = secret("AWS_ACCESS_KEY_ID", None)
secret_aws_ses_key = secret("AWS_SECRET_ACCESS_KEY", None)
# These keys are present in a getgov-s3 instance, or they can be defined locally
aws_s3_region_name = secret("region", None) or secret("AWS_S3_REGION", None)
secret_aws_s3_key_id = secret("access_key_id", None) or secret("AWS_S3_ACCESS_KEY_ID", None)
secret_aws_s3_key = secret("secret_access_key", None) or secret("AWS_S3_SECRET_ACCESS_KEY", None)
secret_aws_s3_bucket_name = secret("bucket", None) or secret("AWS_S3_BUCKET_NAME", None)
secret_registry_cl_id = secret("REGISTRY_CL_ID")
secret_registry_password = secret("REGISTRY_PASSWORD")
secret_registry_cert = b64decode(secret("REGISTRY_CERT", ""))
@ -257,7 +272,14 @@ AUTH_USER_MODEL = "registrar.User"
AWS_ACCESS_KEY_ID = secret_aws_ses_key_id
AWS_SECRET_ACCESS_KEY = secret_aws_ses_key
AWS_REGION = "us-gov-west-1"
# https://boto3.amazonaws.com/v1/documentation/api/latest/guide/retries.html#standard-retry-mode
# Configuration for accessing AWS S3
AWS_S3_ACCESS_KEY_ID = secret_aws_s3_key_id
AWS_S3_SECRET_ACCESS_KEY = secret_aws_s3_key
AWS_S3_REGION = aws_s3_region_name
AWS_S3_BUCKET_NAME = secret_aws_s3_bucket_name
# https://boto3.amazonaws.com/v1/documentation/latest/guide/retries.html#standard-retry-mode
AWS_RETRY_MODE: Final = "standard"
# base 2 exponential backoff with max of 20 seconds:
AWS_MAX_ATTEMPTS = 3

View file

@ -11,7 +11,8 @@ from django.views.generic import RedirectView
from registrar import views
from registrar.views.application import Step
from registrar.views.utility import always_404
from api.views import available
from api.views import available, get_current_federal, get_current_full
APPLICATION_NAMESPACE = views.ApplicationWizard.URL_NAMESPACE
application_urls = [
@ -73,6 +74,8 @@ urlpatterns = [
path("openid/", include("djangooidc.urls")),
path("register/", include((application_urls, APPLICATION_NAMESPACE))),
path("api/v1/available/<domain>", available, name="available"),
path("api/v1/get-report/current-federal", get_current_federal, name="get-current-federal"),
path("api/v1/get-report/current-full", get_current_full, name="get-current-full"),
path(
"todo",
lambda r: always_404(r, "We forgot to include this link, sorry."),

View file

@ -244,7 +244,7 @@ class OrganizationContactForm(RegistrarForm):
)
address_line2 = forms.CharField(
required=False,
label="Street address line 2",
label="Street address line 2 (optional)",
)
city = forms.CharField(
label="City",
@ -268,7 +268,7 @@ class OrganizationContactForm(RegistrarForm):
)
urbanization = forms.CharField(
required=False,
label="Urbanization (Puerto Rico only)",
label="Urbanization (required for Puerto Rico only)",
)
def clean_federal_agency(self):
@ -331,7 +331,7 @@ class AuthorizingOfficialForm(RegistrarForm):
)
middle_name = forms.CharField(
required=False,
label="Middle name",
label="Middle name (optional)",
)
last_name = forms.CharField(
label="Last name / family name",
@ -399,13 +399,15 @@ class AlternativeDomainForm(RegistrarForm):
raise forms.ValidationError(DOMAIN_API_MESSAGES["extra_dots"], code="extra_dots")
except errors.DomainUnavailableError:
raise forms.ValidationError(DOMAIN_API_MESSAGES["unavailable"], code="unavailable")
except errors.RegistrySystemError:
raise forms.ValidationError(DOMAIN_API_MESSAGES["error"], code="error")
except ValueError:
raise forms.ValidationError(DOMAIN_API_MESSAGES["invalid"], code="invalid")
return validated
alternative_domain = forms.CharField(
required=False,
label="Alternative domain",
label="",
)
@ -484,6 +486,8 @@ class DotGovDomainForm(RegistrarForm):
raise forms.ValidationError(DOMAIN_API_MESSAGES["extra_dots"], code="extra_dots")
except errors.DomainUnavailableError:
raise forms.ValidationError(DOMAIN_API_MESSAGES["unavailable"], code="unavailable")
except errors.RegistrySystemError:
raise forms.ValidationError(DOMAIN_API_MESSAGES["error"], code="error")
except ValueError:
raise forms.ValidationError(DOMAIN_API_MESSAGES["invalid"], code="invalid")
return validated
@ -529,7 +533,7 @@ class YourContactForm(RegistrarForm):
)
middle_name = forms.CharField(
required=False,
label="Middle name",
label="Middle name (optional)",
)
last_name = forms.CharField(
label="Last name / family name",
@ -558,7 +562,7 @@ class OtherContactsForm(RegistrarForm):
)
middle_name = forms.CharField(
required=False,
label="Middle name",
label="Middle name (optional)",
)
last_name = forms.CharField(
label="Last name / family name",
@ -610,8 +614,8 @@ class NoOtherContactsForm(RegistrarForm):
required=True,
# label has to end in a space to get the label_suffix to show
label=(
"Please explain why there are no other employees from your organization"
" we can contact to help us assess your eligibility for a .gov domain."
"Please explain why there are no other employees from your organization "
"we can contact to help us assess your eligibility for a .gov domain."
),
widget=forms.Textarea(),
)
@ -620,7 +624,7 @@ class NoOtherContactsForm(RegistrarForm):
class AnythingElseForm(RegistrarForm):
anything_else = forms.CharField(
required=False,
label="Anything else we should know?",
label="Anything else?",
widget=forms.Textarea(),
validators=[
MaxLengthValidator(

View file

@ -181,6 +181,9 @@ class ContactForm(forms.ModelForm):
for field_name in self.required:
self.fields[field_name].required = True
# Set custom form label
self.fields["middle_name"].label = "Middle name (optional)"
# Set custom error messages
self.fields["first_name"].error_messages = {"required": "Enter your first name / given name."}
self.fields["last_name"].error_messages = {"required": "Enter your last name / family name."}
@ -190,7 +193,7 @@ class ContactForm(forms.ModelForm):
self.fields["email"].error_messages = {
"required": "Enter your email address in the required format, like name@example.com."
}
self.fields["phone"].error_messages = {"required": "Enter your phone number."}
self.fields["phone"].error_messages["required"] = "Enter your phone number."
class AuthorizingOfficialContactForm(ContactForm):
@ -213,14 +216,14 @@ class AuthorizingOfficialContactForm(ContactForm):
self.fields["email"].error_messages = {
"required": "Enter an email address in the required format, like name@example.com."
}
self.fields["phone"].error_messages = {"required": "Enter a phone number for your authorizing official."}
self.fields["phone"].error_messages["required"] = "Enter a phone number for your authorizing official."
class DomainSecurityEmailForm(forms.Form):
"""Form for adding or editing a security email to a domain."""
security_email = forms.EmailField(
label="Security email",
label="Security email (optional)",
required=False,
error_messages={
"invalid": str(SecurityEmailError(code=SecurityEmailErrorCodes.BAD_DATA)),

View file

@ -0,0 +1,58 @@
"""Generates current-full.csv and current-federal.csv then uploads them to the desired URL."""
import logging
import os
from django.core.management import BaseCommand
from registrar.utility import csv_export
from registrar.utility.s3_bucket import S3ClientHelper
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = (
"Generates and uploads a current-federal.csv file to our S3 bucket "
"which is based off of all existing federal Domains."
)
def add_arguments(self, parser):
"""Add our two filename arguments."""
parser.add_argument("--directory", default="migrationdata", help="Desired directory")
parser.add_argument(
"--checkpath",
default=True,
help="Flag that determines if we do a check for os.path.exists. Used for test cases",
)
def handle(self, **options):
"""Grabs the directory then creates current-federal.csv in that directory"""
file_name = "current-federal.csv"
# Ensures a slash is added
directory = os.path.join(options.get("directory"), "")
check_path = options.get("checkpath")
logger.info("Generating report...")
try:
self.generate_current_federal_report(directory, file_name, check_path)
except Exception as err:
# TODO - #1317: Notify operations when auto report generation fails
raise err
else:
logger.info(f"Success! Created {file_name}")
def generate_current_federal_report(self, directory, file_name, check_path):
"""Creates a current-full.csv file under the specified directory,
then uploads it to a AWS S3 bucket"""
s3_client = S3ClientHelper()
file_path = os.path.join(directory, file_name)
# Generate a file locally for upload
with open(file_path, "w") as file:
csv_export.export_data_federal_to_csv(file)
if check_path and not os.path.exists(file_path):
raise FileNotFoundError(f"Could not find newly created file at '{file_path}'")
# Upload this generated file for our S3 instance
s3_client.upload_file(file_path, file_name)

View file

@ -0,0 +1,57 @@
"""Generates current-full.csv and current-federal.csv then uploads them to the desired URL."""
import logging
import os
from django.core.management import BaseCommand
from registrar.utility import csv_export
from registrar.utility.s3_bucket import S3ClientHelper
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = (
"Generates and uploads a current-full.csv file to our S3 bucket " "which is based off of all existing Domains."
)
def add_arguments(self, parser):
"""Add our two filename arguments."""
parser.add_argument("--directory", default="migrationdata", help="Desired directory")
parser.add_argument(
"--checkpath",
default=True,
help="Flag that determines if we do a check for os.path.exists. Used for test cases",
)
def handle(self, **options):
"""Grabs the directory then creates current-full.csv in that directory"""
file_name = "current-full.csv"
# Ensures a slash is added
directory = os.path.join(options.get("directory"), "")
check_path = options.get("checkpath")
logger.info("Generating report...")
try:
self.generate_current_full_report(directory, file_name, check_path)
except Exception as err:
# TODO - #1317: Notify operations when auto report generation fails
raise err
else:
logger.info(f"Success! Created {file_name}")
def generate_current_full_report(self, directory, file_name, check_path):
"""Creates a current-full.csv file under the specified directory,
then uploads it to a AWS S3 bucket"""
s3_client = S3ClientHelper()
file_path = os.path.join(directory, file_name)
# Generate a file locally for upload
with open(file_path, "w") as file:
csv_export.export_data_full_to_csv(file)
if check_path and not os.path.exists(file_path):
raise FileNotFoundError(f"Could not find newly created file at '{file_path}'")
# Upload this generated file for our S3 instance
s3_client.upload_file(file_path, file_name)

View file

@ -0,0 +1,36 @@
# Generated by Django 4.2.7 on 2023-12-05 10:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0048_alter_transitiondomain_status"),
]
operations = [
migrations.AlterField(
model_name="domainapplication",
name="current_websites",
field=models.ManyToManyField(
blank=True, related_name="current+", to="registrar.website", verbose_name="websites"
),
),
migrations.AlterField(
model_name="domainapplication",
name="other_contacts",
field=models.ManyToManyField(
blank=True, related_name="contact_applications", to="registrar.contact", verbose_name="contacts"
),
),
migrations.AlterField(
model_name="domaininformation",
name="other_contacts",
field=models.ManyToManyField(
blank=True,
related_name="contact_applications_information",
to="registrar.contact",
verbose_name="contacts",
),
),
]

View file

@ -0,0 +1,37 @@
# Generated by Django 4.2.7 on 2023-11-20 20:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0049_alter_domainapplication_current_websites_and_more"),
]
operations = [
migrations.AlterField(
model_name="contact",
name="middle_name",
field=models.TextField(blank=True, help_text="Middle name (optional)", null=True),
),
migrations.AlterField(
model_name="domainapplication",
name="address_line2",
field=models.TextField(blank=True, help_text="Street address line 2 (optional)", null=True),
),
migrations.AlterField(
model_name="domaininformation",
name="address_line2",
field=models.TextField(
blank=True,
help_text="Street address line 2 (optional)",
null=True,
verbose_name="Street address line 2 (optional)",
),
),
migrations.AlterField(
model_name="transitiondomain",
name="middle_name",
field=models.TextField(blank=True, help_text="Middle name (optional)", null=True),
),
]

View file

@ -0,0 +1,27 @@
# Generated by Django 4.2.7 on 2023-11-22 20:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0050_alter_contact_middle_name_and_more"),
]
operations = [
migrations.AlterField(
model_name="domainapplication",
name="urbanization",
field=models.TextField(blank=True, help_text="Urbanization (required for Puerto Rico only)", null=True),
),
migrations.AlterField(
model_name="domaininformation",
name="urbanization",
field=models.TextField(
blank=True,
help_text="Urbanization (required for Puerto Rico only)",
null=True,
verbose_name="Urbanization (required for Puerto Rico only)",
),
),
]

View file

@ -0,0 +1,22 @@
# Generated by Django 4.2.7 on 2023-11-29 19:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0051_alter_domainapplication_urbanization_and_more"),
]
operations = [
migrations.AlterField(
model_name="domainapplication",
name="anything_else",
field=models.TextField(blank=True, help_text="Anything else?", null=True),
),
migrations.AlterField(
model_name="domaininformation",
name="anything_else",
field=models.TextField(blank=True, help_text="Anything else?", null=True),
),
]

View file

@ -26,7 +26,7 @@ class Contact(TimeStampedModel):
middle_name = models.TextField(
null=True,
blank=True,
help_text="Middle name",
help_text="Middle name (optional)",
)
last_name = models.TextField(
null=True,

View file

@ -442,7 +442,7 @@ class DomainApplication(TimeStampedModel):
address_line2 = models.TextField(
null=True,
blank=True,
help_text="Street address line 2",
help_text="Street address line 2 (optional)",
)
city = models.TextField(
null=True,
@ -465,7 +465,7 @@ class DomainApplication(TimeStampedModel):
urbanization = models.TextField(
null=True,
blank=True,
help_text="Urbanization (Puerto Rico only)",
help_text="Urbanization (required for Puerto Rico only)",
)
about_your_organization = models.TextField(
@ -487,6 +487,7 @@ class DomainApplication(TimeStampedModel):
"registrar.Website",
blank=True,
related_name="current+",
verbose_name="websites",
)
approved_domain = models.OneToOneField(
@ -532,6 +533,7 @@ class DomainApplication(TimeStampedModel):
"registrar.Contact",
blank=True,
related_name="contact_applications",
verbose_name="contacts",
)
no_other_contacts_rationale = models.TextField(
@ -543,7 +545,7 @@ class DomainApplication(TimeStampedModel):
anything_else = models.TextField(
null=True,
blank=True,
help_text="Anything else we should know?",
help_text="Anything else?",
)
is_policy_acknowledged = models.BooleanField(

View file

@ -106,8 +106,8 @@ class DomainInformation(TimeStampedModel):
address_line2 = models.TextField(
null=True,
blank=True,
help_text="Street address line 2",
verbose_name="Street address line 2",
help_text="Street address line 2 (optional)",
verbose_name="Street address line 2 (optional)",
)
city = models.TextField(
null=True,
@ -131,8 +131,8 @@ class DomainInformation(TimeStampedModel):
urbanization = models.TextField(
null=True,
blank=True,
help_text="Urbanization (Puerto Rico only)",
verbose_name="Urbanization (Puerto Rico only)",
help_text="Urbanization (required for Puerto Rico only)",
verbose_name="Urbanization (required for Puerto Rico only)",
)
about_your_organization = models.TextField(
@ -179,6 +179,7 @@ class DomainInformation(TimeStampedModel):
"registrar.Contact",
blank=True,
related_name="contact_applications_information",
verbose_name="contacts",
)
no_other_contacts_rationale = models.TextField(
@ -190,7 +191,7 @@ class DomainInformation(TimeStampedModel):
anything_else = models.TextField(
null=True,
blank=True,
help_text="Anything else we should know?",
help_text="Anything else?",
)
is_policy_acknowledged = models.BooleanField(

View file

@ -84,7 +84,7 @@ class TransitionDomain(TimeStampedModel):
middle_name = models.TextField(
null=True,
blank=True,
help_text="Middle name",
help_text="Middle name (optional)",
)
last_name = models.TextField(
null=True,

View file

@ -2,6 +2,7 @@ import re
from api.views import check_domain_available
from registrar.utility import errors
from epplibwrapper.errors import RegistryError
class DomainHelper:
@ -29,19 +30,19 @@ class DomainHelper:
if not isinstance(domain, str):
raise ValueError("Domain name must be a string")
domain = domain.lower().strip()
if domain == "":
if blank_ok:
return domain
else:
raise errors.BlankValueError()
if domain == "" and not blank_ok:
raise errors.BlankValueError()
if domain.endswith(".gov"):
domain = domain[:-4]
if "." in domain:
raise errors.ExtraDotsError()
if not DomainHelper.string_could_be_domain(domain + ".gov"):
raise ValueError()
if not check_domain_available(domain):
raise errors.DomainUnavailableError()
try:
if not check_domain_available(domain):
raise errors.DomainUnavailableError()
except RegistryError as err:
raise errors.RegistrySystemError() from err
return domain
@classmethod

View file

@ -18,6 +18,7 @@
<link rel="apple-touch-icon" size="180x180"
href="{% static 'img/registrar/favicons/favicon-180.png' %}"
>
<script type="application/javascript" src="{% static 'js/get-gov-admin.js' %}" defer></script>
{% endblock %}
{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}

View file

@ -13,7 +13,7 @@
{% endblock %}
{% block form_required_fields_help_text %}
<p class="text-semibold"><abbr class="usa-hint usa-hint--required" title="required">*</abbr>This question is required.</p>
{# commented out so it does not appear on this page #}
{% endblock %}
{% block form_fields %}

View file

@ -2,7 +2,7 @@
{% load field_helpers %}
{% block form_instructions %}
<p>Is there anything else we should know about your domain request?</p>
<p>Is there anything else you'd like us to know about your domain request? This question is optional.</p>
{% endblock %}
{% block form_required_fields_help_text %}

View file

@ -4,7 +4,7 @@
{% block form_instructions %}
<p>Enter your organizations current public website, if you have one. For example,
www.city.com. We can better evaluate your domain request if we know about domains
youre already using. If you already have any .gov domains please include them.</p>
youre already using. If you already have any .gov domains please include them. This question is optional.</p>
{% endblock %}
{% block form_required_fields_help_text %}

View file

@ -38,8 +38,7 @@
<fieldset class="usa-fieldset margin-top-2">
<legend>
<h2>What .gov domain do you want?&nbsp;<abbr class="usa-hint usa-hint--required
text-super" title="required">*</abbr></h2>
<h2>What .gov domain do you want?</h2>
</legend>
<p id="domain_instructions" class="margin-top-05">After you enter your domain, well make sure its
@ -47,8 +46,6 @@
these initial checks, well verify that it meets all of our requirements once you
complete and submit the rest of this form.</p>
<p class="text-semibold"><abbr class="usa-hint usa-hint--required" title="required">*</abbr> This question is required.</p>
{% with attr_aria_describedby="domain_instructions domain_instructions2" %}
{# attr_validate / validate="domain" invokes code in get-gov.js #}
{% with append_gov=True attr_validate="domain" add_label_class="usa-sr-only" %}
@ -66,11 +63,11 @@
<fieldset class="usa-fieldset margin-top-1">
<legend>
<h2>Alternative domains</h2>
<h2>Alternative domains (optional)</h2>
</legend>
<p id="alt_domain_instructions" class="margin-top-05">Are there other domains youd like if we cant give
you your first choice? Entering alternative domains is optional.</p>
you your first choice?</p>
{% with attr_aria_describedby="alt_domain_instructions" %}
{# attr_validate / validate="domain" invokes code in get-gov.js #}

View file

@ -22,6 +22,14 @@
{% include "includes/form_messages.html" %}
{% endblock %}
{% if pending_requests_message %}
<div class="usa-alert usa-alert--info margin-bottom-3">
<div class="usa-alert__body">
{{ pending_requests_message }}
</div>
</div>
{% endif %}
{% block form_errors %}
{% comment %}
to make sense of this loop, consider that
@ -48,9 +56,12 @@
{% block form_instructions %}
{% endblock %}
{% block form_required_fields_help_text %}
{% include "includes/required_fields.html" %}
{% endblock %}
<!-- The "No other employees from your organization?" page is a one-field form and should not have the required fields sentence -->
{% if steps.current != "no_other_contacts" %}
{% block form_required_fields_help_text %}
{% include "includes/required_fields.html" %}
{% endblock %}
{% endif %}
<form id="step__{{steps.current}}" class="usa-form usa-form--large" method="post" novalidate>
{% csrf_token %}
@ -66,6 +77,13 @@
value="next"
class="usa-button"
>Save and continue</button>
{% elif pending_requests_exist %}
<button
type="submit"
name="submit_button"
value="save_and_return"
class="usa-button usa-button--outline"
>Save and return to manage your domains</button>
{% else %}
<button
type="submit"

View file

@ -2,9 +2,7 @@
{% load field_helpers %}
{% block form_instructions %}
<h2 class="margin-bottom-05">
Is your organization an election office? <abbr class="usa-hint usa-hint--required" title="required">*</abbr>
</h2>
<h2 class="margin-bottom-05">Is your organization an election office?</h2>
<p>An election office is a government entity whose <em>primary</em> responsibility is overseeing elections and/or conducting voter registration.</p>
@ -13,7 +11,7 @@
{% endblock %}
{% block form_required_fields_help_text %}
<p class="text-semibold"><abbr class="usa-hint usa-hint--required" title="required">*</abbr> This question is required.</p>
{# commented out so it does not appear on this page #}
{% endblock %}
{% block form_fields %}

View file

@ -3,15 +3,14 @@
{% block form_instructions %}
<h2 class="margin-bottom-05">
Which federal branch is your organization&nbsp;in?&nbsp;<abbr class="usa-hint usa-hint--required text-super" title="required">*</abbr>
Which federal branch is your organization in?
</h2>
{% endblock %}
{% block form_required_fields_help_text %}
<p class="text-semibold"><abbr class="usa-hint usa-hint--required" title="required">*</abbr> This question is required.</p>
{# commented out so it does not appear on this page #}
{% endblock %}
{% block form_fields %}
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% input_with_errors forms.0.federal_type %}

View file

@ -3,16 +3,15 @@
{% block form_instructions %}
<h2 class="margin-bottom-05">
What kind of U.S.-based government organization do you represent? <abbr class="usa-hint usa-hint--required text-super" title="required">*</abbr>
What kind of U.S.-based government organization do you represent?
</h2>
{% endblock %}
{% block form_required_fields_help_text %}
<p class="text-semibold"><abbr class="usa-hint usa-hint--required" title="required">*</abbr> This question is required.</p>
{# commented out so it does not appear on this page #}
{% endblock %}
{% block form_fields %}
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% input_with_errors forms.0.organization_type %}

View file

@ -13,18 +13,16 @@
{% endblock %}
{% block form_required_fields_help_text %}
{# there are no required fields on this page so don't show this #}
{% include "includes/required_fields.html" %}
{% endblock %}
{% block form_fields %}
{{ forms.0.management_form }}
{# forms.0 is a formset and this iterates over its forms #}
{% for form in forms.0.forms %}
<fieldset class="usa-fieldset">
<legend>
<h2>Organization contact {{ forloop.counter }}</h2>
<h2>Organization contact {{ forloop.counter }} (optional)</h2>
</legend>
{% input_with_errors form.first_name %}

View file

@ -13,11 +13,9 @@ Read about <a class="usa-link" rel="noopener noreferrer" target="_blank" href="{
{% endblock %}
{% block form_required_fields_help_text %}
<p class="text-semibold"><abbr class="usa-hint usa-hint--required" title="required">*</abbr> This question is required.</p>
{# commented out so it does not appear on this page #}
{% endblock %}
{% block form_fields %}
{% with attr_maxlength=1000 add_label_class="usa-sr-only" %}
{% input_with_errors forms.0.purpose %}

View file

@ -50,6 +50,10 @@
<p>We understand the critical importance of the availability of .gov domains. Suspending or terminating a .gov domain is reserved for prolonged, unresolved, serious violations where the registrant is non-responsive. We'll make extensive efforts to contact registrants and to identify potential solutions. We'll make reasonable accommodations for remediation timelines based on the severity of the issue.</p>
{% endblock %}
{% block form_required_fields_help_text %}
{# commented out so it does not appear on this page #}
{% endblock %}
{% block form_fields %}
<fieldset class="usa-fieldset">
<legend>

View file

@ -111,7 +111,7 @@
{% include "includes/summary_item.html" with title='Other employees from your organization' value=domainapplication.other_contacts.all contact='true' list='true' heading_level=heading_level %}
{% include "includes/summary_item.html" with title='Anything else we should know' value=domainapplication.anything_else|default:"No" heading_level=heading_level %}
{% include "includes/summary_item.html" with title='Anything else?' value=domainapplication.anything_else|default:"No" heading_level=heading_level %}
{% endwith %}
</div>

View file

@ -1,11 +1,6 @@
{% extends 'admin/change_form.html' %}
{% load i18n static %}
{% block extrahead %}
{{ block.super }}
<script type="application/javascript" src="{% static 'js/get-gov-admin.js' %}" defer></script>
{% endblock %}
{% block field_sets %}
<div class="submit-row">
<input id="manageDomainSubmitButton" type="submit" value="Manage domain" name="_edit_domain">

View file

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

View file

@ -40,9 +40,9 @@
>
{% else %}
<div id="enable-dnssec">
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert usa-alert--info">
<div class="usa-alert__body">
It is strongly recommended that you only enable DNSSEC if you know how to set it up properly at your hosting service. If you make a mistake, it could cause your domain name to stop working.
<p class="margin-y-0">It is strongly recommended that you only enable DNSSEC if you know how to set it up properly at your hosting service. If you make a mistake, it could cause your domain name to stop working.</p>
</div>
</div>
<a href="{% url 'domain-dns-dnssec-dsdata' pk=domain.id %}" class="usa-button">Enable DNSSEC</a>

View file

@ -15,7 +15,7 @@
<p>Add a name server record by entering the address (e.g., ns1.nameserver.com) in the name server fields below. You must add at least two name servers (13 max).</p>
<div class="usa-alert usa-alert--slim usa-alert--info">
<div class="usa-alert 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). Multiple IP addresses must be separated 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>

View file

@ -34,6 +34,6 @@ Other employees from your organization:
{% for other in application.other_contacts.all %}
{% spaceless %}{% include "emails/includes/contact.txt" with contact=other %}{% endspaceless %}
{% endfor %}{% endif %}{% if application.anything_else %}
Anything else we should know?
Anything else?
{{ application.anything_else }}
{% endif %}

View file

@ -1,3 +1,3 @@
<p class="margin-top-3">
Required fields are marked with an asterisk (<abbr class="usa-hint usa-hint--required" title="required">*</abbr>).
<em>Required fields are marked with an asterisk (<abbr class="usa-hint usa-hint--required" title="required">*</abbr>).</em>
</p>

View file

@ -0,0 +1,3 @@
Domain name,Domain type,Agency,Organization name,City,State,Security Contact Email
cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,
ddomain3.gov,Federal,Armed Forces Retirement Home,,,,
1 Domain name Domain type Agency Organization name City State Security Contact Email
2 cdomain1.gov Federal - Executive World War I Centennial Commission
3 ddomain3.gov Federal Armed Forces Retirement Home

View file

@ -0,0 +1,4 @@
Domain name,Domain type,Agency,Organization name,City,State,Security Contact Email
cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,
ddomain3.gov,Federal,Armed Forces Retirement Home,,,,
adomain2.gov,Interstate,,,,,
1 Domain name Domain type Agency Organization name City State Security Contact Email
2 cdomain1.gov Federal - Executive World War I Centennial Commission
3 ddomain3.gov Federal Armed Forces Retirement Home
4 adomain2.gov Interstate

View file

@ -13,6 +13,7 @@ from registrar.admin import (
MyUserAdmin,
AuditedAdmin,
ContactAdmin,
UserDomainRoleAdmin,
)
from registrar.models import (
Domain,
@ -21,6 +22,7 @@ from registrar.models import (
User,
DomainInvitation,
)
from registrar.models.user_domain_role import UserDomainRole
from .common import (
completed_application,
generic_domain_object,
@ -886,6 +888,86 @@ class DomainInvitationAdminTest(TestCase):
self.assertContains(response, retrieved_html, count=1)
class UserDomainRoleAdminTest(TestCase):
def setUp(self):
"""Setup environment for a mock admin user"""
self.site = AdminSite()
self.factory = RequestFactory()
self.admin = ListHeaderAdmin(model=UserDomainRoleAdmin, admin_site=None)
self.client = Client(HTTP_HOST="localhost:8080")
self.superuser = create_superuser()
def tearDown(self):
"""Delete all Users, Domains, and UserDomainRoles"""
User.objects.all().delete()
Domain.objects.all().delete()
UserDomainRole.objects.all().delete()
def test_email_not_in_search(self):
"""Tests the search bar in Django Admin for UserDomainRoleAdmin.
Should return no results for an invalid email."""
# Have to get creative to get past linter
p = "adminpass"
self.client.login(username="superuser", password=p)
fake_user = User.objects.create(
username="dummyuser", first_name="Stewart", last_name="Jones", email="AntarcticPolarBears@example.com"
)
fake_domain = Domain.objects.create(name="test123")
UserDomainRole.objects.create(user=fake_user, domain=fake_domain, role="manager")
# Make the request using the Client class
# which handles CSRF
# Follow=True handles the redirect
response = self.client.get(
"/admin/registrar/userdomainrole/",
{
"q": "testmail@igorville.com",
},
follow=True,
)
# Assert that the query is added to the extra_context
self.assertIn("search_query", response.context)
# Assert the content of filters and search_query
search_query = response.context["search_query"]
self.assertEqual(search_query, "testmail@igorville.com")
# We only need to check for the end of the HTML string
self.assertNotContains(response, "Stewart Jones AntarcticPolarBears@example.com</a></th>")
def test_email_in_search(self):
"""Tests the search bar in Django Admin for UserDomainRoleAdmin.
Should return results for an valid email."""
# Have to get creative to get past linter
p = "adminpass"
self.client.login(username="superuser", password=p)
fake_user = User.objects.create(
username="dummyuser", first_name="Joe", last_name="Jones", email="AntarcticPolarBears@example.com"
)
fake_domain = Domain.objects.create(name="fake")
UserDomainRole.objects.create(user=fake_user, domain=fake_domain, role="manager")
# Make the request using the Client class
# which handles CSRF
# Follow=True handles the redirect
response = self.client.get(
"/admin/registrar/userdomainrole/",
{
"q": "AntarcticPolarBears@example.com",
},
follow=True,
)
# Assert that the query is added to the extra_context
self.assertIn("search_query", response.context)
search_query = response.context["search_query"]
self.assertEqual(search_query, "AntarcticPolarBears@example.com")
# We only need to check for the end of the HTML string
self.assertContains(response, "Joe Jones AntarcticPolarBears@example.com</a></th>", count=1)
class ListHeaderAdminTest(TestCase):
def setUp(self):
self.site = AdminSite()

View file

@ -158,7 +158,7 @@ class TestEmails(TestCase):
_, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
# spacing should be right between adjacent elements
self.assertRegex(body, r"5557\n\nAnything else we should know?")
self.assertRegex(body, r"5557\n\nAnything else?")
@boto3_mocking.patching
def test_submission_confirmation_no_anything_else_spacing(self):
@ -168,6 +168,6 @@ class TestEmails(TestCase):
application.submit()
_, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertNotIn("Anything else we should know", body)
self.assertNotIn("Anything else", body)
# spacing should be right between adjacent elements
self.assertRegex(body, r"5557\n\n----")

View file

@ -1,11 +1,219 @@
from django.test import TestCase
from io import StringIO
import csv
import io
from django.test import Client, RequestFactory, TestCase
from io import StringIO
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
from django.core.management import call_command
from unittest.mock import MagicMock, call, mock_open, patch
from api.views import get_current_federal, get_current_full
from django.conf import settings
from botocore.exceptions import ClientError
import boto3_mocking
from registrar.utility.s3_bucket import S3ClientError, S3ClientErrorCodes # type: ignore
class CsvReportsTest(TestCase):
"""Tests to determine if we are uploading our reports correctly"""
def setUp(self):
"""Create fake domain data"""
self.client = Client(HTTP_HOST="localhost:8080")
self.factory = RequestFactory()
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):
"""Delete all faked data"""
Domain.objects.all().delete()
DomainInformation.objects.all().delete()
User.objects.all().delete()
super().tearDown()
@boto3_mocking.patching
def test_generate_federal_report(self):
"""Ensures that we correctly generate current-federal.csv"""
mock_client = MagicMock()
fake_open = mock_open()
expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security Contact Email\r\n"),
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,, \r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,, \r\n"),
]
# We don't actually want to write anything for a test case,
# we just want to verify what is being written.
with boto3_mocking.clients.handler_for("s3", mock_client):
with patch("builtins.open", fake_open):
call_command("generate_current_federal_report", checkpath=False)
content = fake_open()
content.write.assert_has_calls(expected_file_content)
@boto3_mocking.patching
def test_generate_full_report(self):
"""Ensures that we correctly generate current-full.csv"""
mock_client = MagicMock()
fake_open = mock_open()
expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security Contact Email\r\n"),
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,, \r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,, \r\n"),
call("adomain2.gov,Interstate,,,,, \r\n"),
]
# We don't actually want to write anything for a test case,
# we just want to verify what is being written.
with boto3_mocking.clients.handler_for("s3", mock_client):
with patch("builtins.open", fake_open):
call_command("generate_current_full_report", checkpath=False)
content = fake_open()
content.write.assert_has_calls(expected_file_content)
@boto3_mocking.patching
def test_not_found_full_report(self):
"""Ensures that we get a not found when the report doesn't exist"""
def side_effect(Bucket, Key):
raise ClientError({"Error": {"Code": "NoSuchKey", "Message": "No such key"}}, "get_object")
mock_client = MagicMock()
mock_client.get_object.side_effect = side_effect
response = None
with boto3_mocking.clients.handler_for("s3", mock_client):
with patch("boto3.client", return_value=mock_client):
with self.assertRaises(S3ClientError) as context:
response = self.client.get("/api/v1/get-report/current-full")
# Check that the response has status code 500
self.assertEqual(response.status_code, 500)
# Check that we get the right error back from the page
self.assertEqual(context.exception.code, S3ClientErrorCodes.FILE_NOT_FOUND_ERROR)
@boto3_mocking.patching
def test_not_found_federal_report(self):
"""Ensures that we get a not found when the report doesn't exist"""
def side_effect(Bucket, Key):
raise ClientError({"Error": {"Code": "NoSuchKey", "Message": "No such key"}}, "get_object")
mock_client = MagicMock()
mock_client.get_object.side_effect = side_effect
with boto3_mocking.clients.handler_for("s3", mock_client):
with patch("boto3.client", return_value=mock_client):
with self.assertRaises(S3ClientError) as context:
response = self.client.get("/api/v1/get-report/current-federal")
# Check that the response has status code 500
self.assertEqual(response.status_code, 500)
# Check that we get the right error back from the page
self.assertEqual(context.exception.code, S3ClientErrorCodes.FILE_NOT_FOUND_ERROR)
@boto3_mocking.patching
def test_load_federal_report(self):
"""Tests the get_current_federal api endpoint"""
self.maxDiff = None
mock_client = MagicMock()
mock_client_instance = mock_client.return_value
with open("registrar/tests/data/fake_current_federal.csv", "r") as file:
file_content = file.read()
# Mock a recieved file
mock_client_instance.get_object.return_value = {"Body": io.BytesIO(file_content.encode())}
with boto3_mocking.clients.handler_for("s3", mock_client):
request = self.factory.get("/fake-path")
response = get_current_federal(request)
# Check that we are sending the correct calls.
# Ensures that we are decoding the file content recieved from AWS.
expected_call = [call.get_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key="current-federal.csv")]
mock_client_instance.assert_has_calls(expected_call)
# Check that the response has status code 200
self.assertEqual(response.status_code, 200)
# Check that the response contains what we expect
expected_file_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,,,,"
).encode()
self.assertEqual(expected_file_content, response.content)
@boto3_mocking.patching
def test_load_full_report(self):
"""Tests the current-federal api link"""
mock_client = MagicMock()
mock_client_instance = mock_client.return_value
with open("registrar/tests/data/fake_current_full.csv", "r") as file:
file_content = file.read()
# Mock a recieved file
mock_client_instance.get_object.return_value = {"Body": io.BytesIO(file_content.encode())}
with boto3_mocking.clients.handler_for("s3", mock_client):
request = self.factory.get("/fake-path")
response = get_current_full(request)
# Check that we are sending the correct calls.
# Ensures that we are decoding the file content recieved from AWS.
expected_call = [call.get_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key="current-full.csv")]
mock_client_instance.assert_has_calls(expected_call)
# Check that the response has status code 200
self.assertEqual(response.status_code, 200)
# Check that the response contains what we expect
expected_file_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"
"adomain2.gov,Interstate,,,,,"
).encode()
self.assertEqual(expected_file_content, response.content)
class ExportDataTest(TestCase):

View file

@ -111,6 +111,8 @@ class TestURLAuth(TestCase):
"/openid/callback/login/",
"/openid/callback/logout/",
"/api/v1/available/whitehouse.gov",
"/api/v1/get-report/current-federal",
"/api/v1/get-report/current-full",
]
def assertURLIsProtectedByAuth(self, url):

View file

@ -144,6 +144,18 @@ class DomainApplicationTests(TestWithUser, WebTest):
result = page.form.submit()
self.assertIn("What kind of U.S.-based government organization do you represent?", result)
def test_application_multiple_applications_exist(self):
"""Test that an info message appears when user has multiple applications already"""
# create and submit an application
application = completed_application(user=self.user)
application.submit()
application.save()
# now, attempt to create another one
with less_console_noise():
page = self.app.get("/register/").follow()
self.assertContains(page, "You cannot submit this request yet")
@boto3_mocking.patching
def test_application_form_submission(self):
"""

View file

@ -13,6 +13,10 @@ class DomainUnavailableError(ValueError):
pass
class RegistrySystemError(ValueError):
pass
class ActionNotAllowed(Exception):
"""User accessed an action that is not
allowed by the current state"""
@ -42,7 +46,7 @@ class GenericError(Exception):
GenericErrorCodes.CANNOT_CONTACT_REGISTRY: """
Were 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
contact help@get.gov.
""",
GenericErrorCodes.GENERIC_ERROR: ("Value entered was wrong."),
}
@ -56,6 +60,10 @@ contact help@get.gov
def __str__(self):
return f"{self.message}"
@classmethod
def get_error_message(self, code=None):
return self._error_mapping.get(code)
class NameserverErrorCodes(IntEnum):
"""Used in the NameserverError class for

View file

@ -0,0 +1,149 @@
"""Utilities for accessing an AWS S3 bucket"""
from enum import IntEnum
import boto3
from botocore.exceptions import ClientError
from django.conf import settings
class S3ClientErrorCodes(IntEnum):
"""Used for S3ClientError
Error code overview:
- 1 ACCESS_S3_CLIENT_ERROR
- 2 UPLOAD_FILE_ERROR
- 3 FILE_NOT_FOUND_ERROR
- 4 GET_FILE_ERROR
"""
ACCESS_S3_CLIENT_ERROR = 1
UPLOAD_FILE_ERROR = 2
FILE_NOT_FOUND_ERROR = 3
GET_FILE_ERROR = 4
class S3ClientError(RuntimeError):
"""
Custom exception class for handling errors related to interactions with the S3 storage service via boto3.client.
This class maps error codes to human-readable error messages. When an instance of S3ClientError is created,
an error code can be passed in to set the error message for that instance.
Attributes:
_error_mapping: A dictionary mapping error codes to error messages.
code: The error code for a specific instance of S3ClientError.
message: The error message for a specific instance of S3ClientError, determined by the error code.
"""
_error_mapping = {
S3ClientErrorCodes.ACCESS_S3_CLIENT_ERROR: "Failed to establish a connection with the storage service.",
S3ClientErrorCodes.UPLOAD_FILE_ERROR: "File upload to the storage service failed.",
S3ClientErrorCodes.FILE_NOT_FOUND_ERROR: "Requested file not found in the storage service.",
S3ClientErrorCodes.GET_FILE_ERROR: (
"Retrieval of the requested file from " "the storage service failed due to an unspecified error."
),
}
def __init__(self, *args, code=None, **kwargs):
super().__init__(*args, **kwargs)
self.code = code
if self.code in self._error_mapping:
self.message = self._error_mapping.get(self.code)
def __str__(self):
return f"{self.message}"
class S3ClientHelper:
"""
A helper class for interacting with Amazon S3 via the boto3 client.
This class simplifies the process of initializing the boto3 client,
uploading files to S3, and retrieving files from S3.
Attributes:
boto_client: The boto3 client used to interact with S3.
"""
def __init__(self):
try:
self.boto_client = boto3.client(
"s3",
region_name=settings.AWS_S3_REGION,
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
config=settings.BOTO_CONFIG,
)
except Exception as exc:
raise S3ClientError(code=S3ClientErrorCodes.ACCESS_S3_CLIENT_ERROR) from exc
def get_bucket_name(self):
"""
Retrieves the name of the S3 bucket.
This method returns the name of the S3 bucket as defined in the application's settings.
Returns:
str: The name of the S3 bucket.
"""
return settings.AWS_S3_BUCKET_NAME
def upload_file(self, file_path, file_name):
"""
Uploads a file to the S3 bucket.
This method attempts to upload a file to the S3 bucket using the boto3 client.
If an exception occurs during the upload process, it raises an S3ClientError with an UPLOAD_FILE_ERROR code.
Args:
file_path (str): The path of the file to upload.
file_name (str): The name to give to the file in the S3 bucket.
Returns:
dict: The response from the boto3 client's upload_file method.
Raises:
S3ClientError: If the file cannot be uploaded to the S3 bucket.
"""
try:
response = self.boto_client.upload_file(file_path, self.get_bucket_name(), file_name)
except Exception as exc:
raise S3ClientError(code=S3ClientErrorCodes.UPLOAD_FILE_ERROR) from exc
return response
def get_file(self, file_name, decode_to_utf=False):
"""
Retrieves a file from the S3 bucket and returns its content.
This method attempts to retrieve a file from the S3 bucket using the boto3 client.
If the file is not found, it raises an S3ClientError with a FILE_NOT_FOUND_ERROR code.
For any other exceptions during the retrieval process, it raises an S3ClientError with a GET_FILE_ERROR code.
Args:
file_name (str): The name of the file to retrieve from the S3 bucket.
decode_to_utf (bool, optional): If True, the file content is decoded from bytes to a UTF-8 string.
Defaults to False.
Returns:
bytes or str: The content of the file. If decode_to_utf is True, this is a string. Otherwise, its bytes.
Raises:
S3ClientError: If the file cannot be retrieved from the S3 bucket.
"""
try:
response = self.boto_client.get_object(Bucket=self.get_bucket_name(), Key=file_name)
except ClientError as exc:
if exc.response["Error"]["Code"] == "NoSuchKey":
raise S3ClientError(code=S3ClientErrorCodes.FILE_NOT_FOUND_ERROR) from exc
else:
raise S3ClientError(code=S3ClientErrorCodes.GET_FILE_ERROR) from exc
except Exception as exc:
raise S3ClientError(code=S3ClientErrorCodes.GET_FILE_ERROR) from exc
file_content = response["Body"].read()
if decode_to_utf:
return file_content.decode("utf-8")
else:
return file_content

View file

@ -3,6 +3,7 @@ import logging
from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect, render
from django.urls import resolve, reverse
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView
from django.contrib import messages
@ -85,7 +86,7 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
Step.YOUR_CONTACT: _("Your contact information"),
Step.OTHER_CONTACTS: _("Other employees from your organization"),
Step.NO_OTHER_CONTACTS: _("No other employees from your organization?"),
Step.ANYTHING_ELSE: _("Anything else we should know?"),
Step.ANYTHING_ELSE: _("Anything else?"),
Step.REQUIREMENTS: _("Requirements for operating .gov domains"),
Step.REVIEW: _("Review and submit your domain request"),
}
@ -218,6 +219,23 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
self.steps.current = current_url
context = self.get_context_data()
context["forms"] = self.get_forms()
# if pending requests exist and user does not have approved domains,
# present message that domain application cannot be submitted
pending_requests = self.pending_requests()
if len(pending_requests) > 0:
message_header = "You cannot submit this request yet"
message_content = (
f"<h4 class='usa-alert__heading'>{message_header}</h4> "
"<p class='margin-bottom-0'>New domain requests cannot be submitted until we have finished "
f"reviewing your pending request: <strong>{pending_requests[0].requested_domain}</strong>. "
"You can continue to fill out this request and save it as a draft to be submitted later. "
f"<a class='usa-link' href='{reverse('home')}'>View your pending requests.</a></p>"
)
context["pending_requests_message"] = mark_safe(message_content) # nosec
context["pending_requests_exist"] = len(pending_requests) > 0
return render(request, self.template_name, context)
def get_all_forms(self, **kwargs) -> list:
@ -266,6 +284,37 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
return instantiated
def pending_requests(self):
"""return an array of pending requests if user has pending requests
and no approved requests"""
if self.approved_applications_exist() or self.approved_domains_exist():
return []
else:
return self.pending_applications()
def approved_applications_exist(self):
"""Checks if user is creator of applications with APPROVED status"""
approved_application_count = DomainApplication.objects.filter(
creator=self.request.user, status=DomainApplication.APPROVED
).count()
return approved_application_count > 0
def approved_domains_exist(self):
"""Checks if user has permissions on approved domains
This additional check is necessary to account for domains which were migrated
and do not have an application"""
return self.request.user.permissions.count() > 0
def pending_applications(self):
"""Returns a List of user's applications with one of the following states:
SUBMITTED, IN_REVIEW, ACTION_NEEDED"""
# if the current application has ACTION_NEEDED status, this check should not be performed
if self.application.status == DomainApplication.ACTION_NEEDED:
return []
check_statuses = [DomainApplication.SUBMITTED, DomainApplication.IN_REVIEW, DomainApplication.ACTION_NEEDED]
return DomainApplication.objects.filter(creator=self.request.user, status__in=check_statuses)
def get_context_data(self):
"""Define context for access on all wizard pages."""
return {
@ -328,6 +377,10 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
if button == "save":
messages.success(request, "Your progress has been saved!")
return self.goto(self.steps.current)
# if user opted to save progress and return,
# return them to the home page
if button == "save_and_return":
return HttpResponseRedirect(reverse("home"))
# otherwise, proceed as normal
return self.goto_next_step()

View file

@ -280,6 +280,7 @@ class DomainNameserversView(DomainFormBaseView):
form.fields["server"].required = True
else:
form.fields["server"].required = False
form.fields["server"].label += " (optional)"
form.fields["domain"].initial = self.object.name
return formset
@ -643,7 +644,46 @@ class DomainAddUserView(DomainFormBaseView):
"""Get an absolute URL for this domain."""
return self.request.build_absolute_uri(reverse("domain", kwargs={"pk": self.object.id}))
def _make_invitation(self, email_address):
def _send_domain_invitation_email(self, email: str, add_success=True):
"""Performs the sending of the domain invitation email,
does not make a domain information object
email: string- email to send to
add_success: bool- default True indicates:
adding a success message to the view if the email sending succeeds"""
# created a new invitation in the database, so send an email
domainInfoResults = DomainInformation.objects.filter(domain=self.object)
domainInfo = domainInfoResults.first()
first = ""
last = ""
if domainInfo is not None:
first = domainInfo.creator.first_name
last = domainInfo.creator.last_name
full_name = f"{first} {last}"
try:
send_templated_email(
"emails/domain_invitation.txt",
"emails/domain_invitation_subject.txt",
to_address=email,
context={
"domain_url": self._domain_abs_url(),
"domain": self.object,
"full_name": full_name,
},
)
except EmailSendingError:
messages.warning(self.request, "Could not send email invitation.")
logger.warn(
"Could not sent email invitation to %s for domain %s",
email,
self.object,
exc_info=True,
)
else:
if add_success:
messages.success(self.request, f"Invited {email} to this domain.")
def _make_invitation(self, email_address: str):
"""Make a Domain invitation for this email and redirect with a message."""
invitation, created = DomainInvitation.objects.get_or_create(email=email_address, domain=self.object)
if not created:
@ -653,34 +693,7 @@ class DomainAddUserView(DomainFormBaseView):
f"{email_address} has already been invited to this domain.",
)
else:
# created a new invitation in the database, so send an email
domaininfo = DomainInformation.objects.filter(domain=self.object)
first = domaininfo.first().creator.first_name
last = domaininfo.first().creator.last_name
full_name = f"{first} {last}"
try:
send_templated_email(
"emails/domain_invitation.txt",
"emails/domain_invitation_subject.txt",
to_address=email_address,
context={
"domain_url": self._domain_abs_url(),
"domain": self.object,
"full_name": full_name,
},
)
except EmailSendingError:
messages.warning(self.request, "Could not send email invitation.")
logger.warn(
"Could not sent email invitation to %s for domain %s",
email_address,
self.object,
exc_info=True,
)
else:
messages.success(self.request, f"Invited {email_address} to this domain.")
self._send_domain_invitation_email(email=email_address)
return redirect(self.get_success_url())
def form_valid(self, form):
@ -692,6 +705,9 @@ class DomainAddUserView(DomainFormBaseView):
except User.DoesNotExist:
# no matching user, go make an invitation
return self._make_invitation(requested_email)
else:
# if user already exists then just send an email
self._send_domain_invitation_email(requested_email, add_success=False)
try:
UserDomainRole.objects.create(

View file

@ -64,7 +64,9 @@
10038 OUTOFSCOPE http://app:8080/withdrawconfirmed
10038 OUTOFSCOPE http://app:8080/dns
10038 OUTOFSCOPE http://app:8080/dnssec
10038 OUTOFSCOPE http://app:8080/dns/nameservers
10038 OUTOFSCOPE http://app:8080/dns/dnssec
10038 OUTOFSCOPE http://app:8080/dns/dnssec/dsdata
# This URL always returns 404, so include it as well.
10038 OUTOFSCOPE http://app:8080/todo
# OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers