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: jobs:
deploy-development: deploy-development:
if: ${{ github.ref_type == 'tag' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3

View file

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

View file

@ -30,12 +30,10 @@ jobs:
working-directory: ./src working-directory: ./src
run: docker compose run app python manage.py collectstatic --no-input run: docker compose run app python manage.py collectstatic --no-input
- name: Deploy to cloud.gov sandbox - name: Deploy to cloud.gov sandbox
uses: 18f/cg-deploy-action@main uses: cloud-gov/cg-cli-tools@main
env:
DEPLOY_NOW: thanks
with: with:
cf_username: ${{ secrets.CF_STABLE_USERNAME }} cf_username: ${{ secrets.CF_STABLE_USERNAME }}
cf_password: ${{ secrets.CF_STABLE_PASSWORD }} cf_password: ${{ secrets.CF_STABLE_PASSWORD }}
cf_org: cisa-dotgov cf_org: cisa-dotgov
cf_space: stable 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/**' - 'docs/**'
- '**.md' - '**.md'
- '.gitignore' - '.gitignore'
tags: tags:
- staging-* - staging-*
@ -30,12 +30,10 @@ jobs:
working-directory: ./src working-directory: ./src
run: docker compose run app python manage.py collectstatic --no-input run: docker compose run app python manage.py collectstatic --no-input
- name: Deploy to cloud.gov sandbox - name: Deploy to cloud.gov sandbox
uses: 18f/cg-deploy-action@main uses: cloud-gov/cg-cli-tools@main
env:
DEPLOY_NOW: thanks
with: with:
cf_username: ${{ secrets.CF_STAGING_USERNAME }} cf_username: ${{ secrets.CF_STAGING_USERNAME }}
cf_password: ${{ secrets.CF_STAGING_PASSWORD }} cf_password: ${{ secrets.CF_STAGING_PASSWORD }}
cf_org: cisa-dotgov cf_org: cisa-dotgov
cf_space: staging 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 CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
steps: steps:
- name: Run Django migrations for ${{ github.event.inputs.environment }} - name: Run Django migrations for ${{ github.event.inputs.environment }}
uses: 18f/cg-deploy-action@main uses: cloud-gov/cg-cli-tools@main
with: with:
cf_username: ${{ secrets[env.CF_USERNAME] }} cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }} cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov cf_org: cisa-dotgov
cf_space: ${{ github.event.inputs.environment }} 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 CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
steps: steps:
- name: Delete existing data for ${{ github.event.inputs.environment }} - name: Delete existing data for ${{ github.event.inputs.environment }}
uses: 18f/cg-deploy-action@main uses: cloud-gov/cg-cli-tools@main
with: with:
cf_username: ${{ secrets[env.CF_USERNAME] }} cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }} cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov cf_org: cisa-dotgov
cf_space: ${{ github.event.inputs.environment }} 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 }} - name: Run Django migrations for ${{ github.event.inputs.environment }}
uses: 18f/cg-deploy-action@main uses: cloud-gov/cg-cli-tools@main
with: with:
cf_username: ${{ secrets[env.CF_USERNAME] }} cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }} cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov cf_org: cisa-dotgov
cf_space: ${{ github.event.inputs.environment }} 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 }} - name: Load fake data for ${{ github.event.inputs.environment }}
uses: 18f/cg-deploy-action@main uses: cloud-gov/cg-cli-tools@main
with: with:
cf_username: ${{ secrets[env.CF_USERNAME] }} cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }} cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov cf_org: cisa-dotgov
cf_space: ${{ github.event.inputs.environment }} 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 ## 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 ### Settings
The config for the connection pool exists inside the `settings.py` file. 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)` 5. `print(registry.pool_status.connection_success)`
* Should return true * 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 ..views import available, check_domain_available
from .common import less_console_noise from .common import less_console_noise
from registrar.tests.common import MockEppLib from registrar.tests.common import MockEppLib
from registrar.utility.errors import GenericError, GenericErrorCodes
from unittest.mock import call from unittest.mock import call
from epplibwrapper import ( from epplibwrapper import (
@ -100,16 +101,25 @@ class AvailableViewTest(MockEppLib):
response = available(request, domain="igorville") response = available(request, domain="igorville")
self.assertTrue(json.loads(response.content)["available"]) self.assertTrue(json.loads(response.content)["available"])
def test_error_handling(self): def test_bad_string_handling(self):
"""Calling with bad strings raises an error.""" """Calling with bad strings returns unavailable."""
bad_string = "blah!;" bad_string = "blah!;"
request = self.factory.get(API_BASE_PATH + bad_string) request = self.factory.get(API_BASE_PATH + bad_string)
request.user = self.user request.user = self.user
response = available(request, domain=bad_string) response = available(request, domain=bad_string)
self.assertFalse(json.loads(response.content)["available"]) self.assertFalse(json.loads(response.content)["available"])
# domain set to raise error returns false for availability
error_domain_available = available(request, "errordomain.gov") def test_error_handling(self):
self.assertFalse(json.loads(error_domain_available.content)["available"]) """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): class AvailableAPITest(MockEppLib):

View file

@ -1,10 +1,11 @@
"""Internal API views""" """Internal API views"""
from django.apps import apps from django.apps import apps
from django.views.decorators.http import require_http_methods 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 django.utils.safestring import mark_safe
from registrar.templatetags.url_helpers import public_site_url from registrar.templatetags.url_helpers import public_site_url
from registrar.utility.errors import GenericError, GenericErrorCodes
import requests import requests
@ -12,6 +13,8 @@ from login_required import login_not_required
from cachetools.func import ttl_cache 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" 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).", "invalid": "Enter a domain using only letters, numbers, or hyphens (though we don't recommend using hyphens).",
"success": "That domain is available!", "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 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 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") Domain = apps.get_model("registrar.Domain")
try: if domain.endswith(".gov"):
if domain.endswith(".gov"): return Domain.available(domain)
return Domain.available(domain) else:
else: # domain search string doesn't end with .gov, add it on here
# domain search string doesn't end with .gov, add it on here return Domain.available(domain + ".gov")
return Domain.available(domain + ".gov")
except Exception:
return False
@require_http_methods(["GET"]) @require_http_methods(["GET"])
@ -97,3 +97,36 @@ def available(request, domain=""):
return JsonResponse({"available": False, "message": DOMAIN_API_MESSAGES["unavailable"]}) return JsonResponse({"available": False, "message": DOMAIN_API_MESSAGES["unavailable"]})
except Exception: except Exception:
return JsonResponse({"available": False, "message": DOMAIN_API_MESSAGES["error"]}) 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 credentials
- AWS_ACCESS_KEY_ID - AWS_ACCESS_KEY_ID
- AWS_SECRET_ACCESS_KEY - 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 stdin_open: true
tty: true tty: true
ports: ports:

View file

@ -118,6 +118,15 @@ class ListHeaderAdmin(AuditedAdmin):
) )
return filters 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): class UserContactInline(admin.StackedInline):
"""Edit a user's profile on the user page.""" """Edit a user's profile on the user page."""
@ -325,6 +334,14 @@ class WebsiteAdmin(ListHeaderAdmin):
class UserDomainRoleAdmin(ListHeaderAdmin): class UserDomainRoleAdmin(ListHeaderAdmin):
"""Custom user domain role admin class.""" """Custom user domain role admin class."""
class Meta:
"""Contains meta information about this class"""
model = models.UserDomainRole
fields = "__all__"
_meta = Meta()
# Columns # Columns
list_display = [ list_display = [
"user", "user",
@ -336,10 +353,11 @@ class UserDomainRoleAdmin(ListHeaderAdmin):
search_fields = [ search_fields = [
"user__first_name", "user__first_name",
"user__last_name", "user__last_name",
"user__email",
"domain__name", "domain__name",
"role", "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"] autocomplete_fields = ["user", "domain"]
@ -448,7 +466,7 @@ class DomainInformationAdmin(ListHeaderAdmin):
"No other employees from your organization?", "No other employees from your organization?",
{"fields": ["no_other_contacts_rationale"]}, {"fields": ["no_other_contacts_rationale"]},
), ),
("Anything else we should know?", {"fields": ["anything_else"]}), ("Anything else?", {"fields": ["anything_else"]}),
( (
"Requirements for operating .gov domains", "Requirements for operating .gov domains",
{"fields": ["is_policy_acknowledged"]}, {"fields": ["is_policy_acknowledged"]},
@ -467,6 +485,17 @@ class DomainInformationAdmin(ListHeaderAdmin):
"is_policy_acknowledged", "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): def get_readonly_fields(self, request, obj=None):
"""Set the read-only state on form elements. """Set the read-only state on form elements.
We have 1 conditions that determine which fields are read-only: We have 1 conditions that determine which fields are read-only:
@ -583,7 +612,7 @@ class DomainApplicationAdmin(ListHeaderAdmin):
"No other employees from your organization?", "No other employees from your organization?",
{"fields": ["no_other_contacts_rationale"]}, {"fields": ["no_other_contacts_rationale"]},
), ),
("Anything else we should know?", {"fields": ["anything_else"]}), ("Anything else?", {"fields": ["anything_else"]}),
( (
"Requirements for operating .gov domains", "Requirements for operating .gov domains",
{"fields": ["is_policy_acknowledged"]}, {"fields": ["is_policy_acknowledged"]},
@ -603,6 +632,15 @@ class DomainApplicationAdmin(ListHeaderAdmin):
"is_policy_acknowledged", "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 # Trigger action when a fieldset is changed
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
if obj and obj.creator.status != models.User.RESTRICTED: if obj and obj.creator.status != models.User.RESTRICTED:
@ -731,6 +769,16 @@ class DomainInformationInline(admin.StackedInline):
fieldsets = DomainInformationAdmin.fieldsets fieldsets = DomainInformationAdmin.fieldsets
analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields 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): def get_readonly_fields(self, request, obj=None):
return DomainInformationAdmin.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(); 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 # Get secrets from Cloud.gov user provided service, if exists
# If not, get secrets from environment variables # If not, get secrets from environment variables
key_service = AppEnv().get_service(name="getgov-credentials") 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 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 secret = key_service.credentials.get
else: else:
secret = env secret = env
# # # ### # # # ###
# Values obtained externally # # 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_id = secret("AWS_ACCESS_KEY_ID", None)
secret_aws_ses_key = secret("AWS_SECRET_ACCESS_KEY", 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_cl_id = secret("REGISTRY_CL_ID")
secret_registry_password = secret("REGISTRY_PASSWORD") secret_registry_password = secret("REGISTRY_PASSWORD")
secret_registry_cert = b64decode(secret("REGISTRY_CERT", "")) 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_ACCESS_KEY_ID = secret_aws_ses_key_id
AWS_SECRET_ACCESS_KEY = secret_aws_ses_key AWS_SECRET_ACCESS_KEY = secret_aws_ses_key
AWS_REGION = "us-gov-west-1" 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" AWS_RETRY_MODE: Final = "standard"
# base 2 exponential backoff with max of 20 seconds: # base 2 exponential backoff with max of 20 seconds:
AWS_MAX_ATTEMPTS = 3 AWS_MAX_ATTEMPTS = 3

View file

@ -11,7 +11,8 @@ from django.views.generic import RedirectView
from registrar import views from registrar import views
from registrar.views.application import Step from registrar.views.application import Step
from registrar.views.utility import always_404 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_NAMESPACE = views.ApplicationWizard.URL_NAMESPACE
application_urls = [ application_urls = [
@ -73,6 +74,8 @@ urlpatterns = [
path("openid/", include("djangooidc.urls")), path("openid/", include("djangooidc.urls")),
path("register/", include((application_urls, APPLICATION_NAMESPACE))), path("register/", include((application_urls, APPLICATION_NAMESPACE))),
path("api/v1/available/<domain>", available, name="available"), 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( path(
"todo", "todo",
lambda r: always_404(r, "We forgot to include this link, sorry."), 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( address_line2 = forms.CharField(
required=False, required=False,
label="Street address line 2", label="Street address line 2 (optional)",
) )
city = forms.CharField( city = forms.CharField(
label="City", label="City",
@ -268,7 +268,7 @@ class OrganizationContactForm(RegistrarForm):
) )
urbanization = forms.CharField( urbanization = forms.CharField(
required=False, required=False,
label="Urbanization (Puerto Rico only)", label="Urbanization (required for Puerto Rico only)",
) )
def clean_federal_agency(self): def clean_federal_agency(self):
@ -331,7 +331,7 @@ class AuthorizingOfficialForm(RegistrarForm):
) )
middle_name = forms.CharField( middle_name = forms.CharField(
required=False, required=False,
label="Middle name", label="Middle name (optional)",
) )
last_name = forms.CharField( last_name = forms.CharField(
label="Last name / family name", label="Last name / family name",
@ -399,13 +399,15 @@ class AlternativeDomainForm(RegistrarForm):
raise forms.ValidationError(DOMAIN_API_MESSAGES["extra_dots"], code="extra_dots") raise forms.ValidationError(DOMAIN_API_MESSAGES["extra_dots"], code="extra_dots")
except errors.DomainUnavailableError: except errors.DomainUnavailableError:
raise forms.ValidationError(DOMAIN_API_MESSAGES["unavailable"], code="unavailable") raise forms.ValidationError(DOMAIN_API_MESSAGES["unavailable"], code="unavailable")
except errors.RegistrySystemError:
raise forms.ValidationError(DOMAIN_API_MESSAGES["error"], code="error")
except ValueError: except ValueError:
raise forms.ValidationError(DOMAIN_API_MESSAGES["invalid"], code="invalid") raise forms.ValidationError(DOMAIN_API_MESSAGES["invalid"], code="invalid")
return validated return validated
alternative_domain = forms.CharField( alternative_domain = forms.CharField(
required=False, required=False,
label="Alternative domain", label="",
) )
@ -484,6 +486,8 @@ class DotGovDomainForm(RegistrarForm):
raise forms.ValidationError(DOMAIN_API_MESSAGES["extra_dots"], code="extra_dots") raise forms.ValidationError(DOMAIN_API_MESSAGES["extra_dots"], code="extra_dots")
except errors.DomainUnavailableError: except errors.DomainUnavailableError:
raise forms.ValidationError(DOMAIN_API_MESSAGES["unavailable"], code="unavailable") raise forms.ValidationError(DOMAIN_API_MESSAGES["unavailable"], code="unavailable")
except errors.RegistrySystemError:
raise forms.ValidationError(DOMAIN_API_MESSAGES["error"], code="error")
except ValueError: except ValueError:
raise forms.ValidationError(DOMAIN_API_MESSAGES["invalid"], code="invalid") raise forms.ValidationError(DOMAIN_API_MESSAGES["invalid"], code="invalid")
return validated return validated
@ -529,7 +533,7 @@ class YourContactForm(RegistrarForm):
) )
middle_name = forms.CharField( middle_name = forms.CharField(
required=False, required=False,
label="Middle name", label="Middle name (optional)",
) )
last_name = forms.CharField( last_name = forms.CharField(
label="Last name / family name", label="Last name / family name",
@ -558,7 +562,7 @@ class OtherContactsForm(RegistrarForm):
) )
middle_name = forms.CharField( middle_name = forms.CharField(
required=False, required=False,
label="Middle name", label="Middle name (optional)",
) )
last_name = forms.CharField( last_name = forms.CharField(
label="Last name / family name", label="Last name / family name",
@ -610,8 +614,8 @@ class NoOtherContactsForm(RegistrarForm):
required=True, required=True,
# label has to end in a space to get the label_suffix to show # label has to end in a space to get the label_suffix to show
label=( label=(
"Please explain why there are no other employees from your organization" "Please explain why there are no other employees from your organization "
" we can contact to help us assess your eligibility for a .gov domain." "we can contact to help us assess your eligibility for a .gov domain."
), ),
widget=forms.Textarea(), widget=forms.Textarea(),
) )
@ -620,7 +624,7 @@ class NoOtherContactsForm(RegistrarForm):
class AnythingElseForm(RegistrarForm): class AnythingElseForm(RegistrarForm):
anything_else = forms.CharField( anything_else = forms.CharField(
required=False, required=False,
label="Anything else we should know?", label="Anything else?",
widget=forms.Textarea(), widget=forms.Textarea(),
validators=[ validators=[
MaxLengthValidator( MaxLengthValidator(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,7 +13,7 @@
{% endblock %} {% endblock %}
{% block form_required_fields_help_text %} {% 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 %} {% endblock %}
{% block form_fields %} {% block form_fields %}

View file

@ -2,7 +2,7 @@
{% load field_helpers %} {% load field_helpers %}
{% block form_instructions %} {% 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 %} {% endblock %}
{% block form_required_fields_help_text %} {% block form_required_fields_help_text %}

View file

@ -4,7 +4,7 @@
{% block form_instructions %} {% block form_instructions %}
<p>Enter your organizations current public website, if you have one. For example, <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 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 %} {% endblock %}
{% block form_required_fields_help_text %} {% block form_required_fields_help_text %}

View file

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

View file

@ -22,6 +22,14 @@
{% include "includes/form_messages.html" %} {% include "includes/form_messages.html" %}
{% endblock %} {% 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 %} {% block form_errors %}
{% comment %} {% comment %}
to make sense of this loop, consider that to make sense of this loop, consider that
@ -48,9 +56,12 @@
{% block form_instructions %} {% block form_instructions %}
{% endblock %} {% endblock %}
{% block form_required_fields_help_text %} <!-- The "No other employees from your organization?" page is a one-field form and should not have the required fields sentence -->
{% include "includes/required_fields.html" %} {% if steps.current != "no_other_contacts" %}
{% endblock %} {% 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> <form id="step__{{steps.current}}" class="usa-form usa-form--large" method="post" novalidate>
{% csrf_token %} {% csrf_token %}
@ -66,6 +77,13 @@
value="next" value="next"
class="usa-button" class="usa-button"
>Save and continue</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 %} {% else %}
<button <button
type="submit" type="submit"

View file

@ -2,9 +2,7 @@
{% load field_helpers %} {% load field_helpers %}
{% block form_instructions %} {% block form_instructions %}
<h2 class="margin-bottom-05"> <h2 class="margin-bottom-05">Is your organization an election office?</h2>
Is your organization an election office? <abbr class="usa-hint usa-hint--required" title="required">*</abbr>
</h2>
<p>An election office is a government entity whose <em>primary</em> responsibility is overseeing elections and/or conducting voter registration.</p> <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 %} {% endblock %}
{% block form_required_fields_help_text %} {% 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 %} {% endblock %}
{% block form_fields %} {% block form_fields %}

View file

@ -3,15 +3,14 @@
{% block form_instructions %} {% block form_instructions %}
<h2 class="margin-bottom-05"> <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> </h2>
{% endblock %} {% endblock %}
{% block form_required_fields_help_text %} {% 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 %} {% endblock %}
{% block form_fields %} {% block form_fields %}
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% input_with_errors forms.0.federal_type %} {% input_with_errors forms.0.federal_type %}

View file

@ -3,16 +3,15 @@
{% block form_instructions %} {% block form_instructions %}
<h2 class="margin-bottom-05"> <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> </h2>
{% endblock %} {% endblock %}
{% block form_required_fields_help_text %} {% 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 %} {% endblock %}
{% block form_fields %} {% block form_fields %}
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% input_with_errors forms.0.organization_type %} {% input_with_errors forms.0.organization_type %}

View file

@ -13,18 +13,16 @@
{% endblock %} {% endblock %}
{% block form_required_fields_help_text %} {% 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 %} {% endblock %}
{% block form_fields %} {% block form_fields %}
{{ forms.0.management_form }} {{ forms.0.management_form }}
{# forms.0 is a formset and this iterates over its forms #} {# forms.0 is a formset and this iterates over its forms #}
{% for form in forms.0.forms %} {% for form in forms.0.forms %}
<fieldset class="usa-fieldset"> <fieldset class="usa-fieldset">
<legend> <legend>
<h2>Organization contact {{ forloop.counter }}</h2> <h2>Organization contact {{ forloop.counter }} (optional)</h2>
</legend> </legend>
{% input_with_errors form.first_name %} {% 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 %} {% endblock %}
{% block form_required_fields_help_text %} {% 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 %} {% endblock %}
{% block form_fields %} {% block form_fields %}
{% with attr_maxlength=1000 add_label_class="usa-sr-only" %} {% with attr_maxlength=1000 add_label_class="usa-sr-only" %}
{% input_with_errors forms.0.purpose %} {% 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> <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 %} {% endblock %}
{% block form_required_fields_help_text %}
{# commented out so it does not appear on this page #}
{% endblock %}
{% block form_fields %} {% block form_fields %}
<fieldset class="usa-fieldset"> <fieldset class="usa-fieldset">
<legend> <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='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 %} {% endwith %}
</div> </div>

View file

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

View file

@ -7,7 +7,13 @@
{% else %} {% else %}
{{ field.label }} {{ field.label }}
{% endif %} {% endif %}
{% if widget.attrs.required %} {% 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 %} {% endif %}
</{{ label_tag }}> </{{ label_tag }}>

View file

@ -40,9 +40,9 @@
> >
{% else %} {% else %}
<div id="enable-dnssec"> <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"> <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>
</div> </div>
<a href="{% url 'domain-dns-dnssec-dsdata' pk=domain.id %}" class="usa-button">Enable DNSSEC</a> <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> <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"> <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-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> <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 %} {% for other in application.other_contacts.all %}
{% spaceless %}{% include "emails/includes/contact.txt" with contact=other %}{% endspaceless %} {% spaceless %}{% include "emails/includes/contact.txt" with contact=other %}{% endspaceless %}
{% endfor %}{% endif %}{% if application.anything_else %} {% endfor %}{% endif %}{% if application.anything_else %}
Anything else we should know? Anything else?
{{ application.anything_else }} {{ application.anything_else }}
{% endif %} {% endif %}

View file

@ -1,3 +1,3 @@
<p class="margin-top-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> </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, MyUserAdmin,
AuditedAdmin, AuditedAdmin,
ContactAdmin, ContactAdmin,
UserDomainRoleAdmin,
) )
from registrar.models import ( from registrar.models import (
Domain, Domain,
@ -21,6 +22,7 @@ from registrar.models import (
User, User,
DomainInvitation, DomainInvitation,
) )
from registrar.models.user_domain_role import UserDomainRole
from .common import ( from .common import (
completed_application, completed_application,
generic_domain_object, generic_domain_object,
@ -886,6 +888,86 @@ class DomainInvitationAdminTest(TestCase):
self.assertContains(response, retrieved_html, count=1) 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): class ListHeaderAdminTest(TestCase):
def setUp(self): def setUp(self):
self.site = AdminSite() self.site = AdminSite()

View file

@ -158,7 +158,7 @@ class TestEmails(TestCase):
_, kwargs = self.mock_client.send_email.call_args _, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
# spacing should be right between adjacent elements # 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 @boto3_mocking.patching
def test_submission_confirmation_no_anything_else_spacing(self): def test_submission_confirmation_no_anything_else_spacing(self):
@ -168,6 +168,6 @@ class TestEmails(TestCase):
application.submit() application.submit()
_, kwargs = self.mock_client.send_email.call_args _, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertNotIn("Anything else we should know", body) self.assertNotIn("Anything else", body)
# spacing should be right between adjacent elements # spacing should be right between adjacent elements
self.assertRegex(body, r"5557\n\n----") 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 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_information import DomainInformation
from registrar.models.domain import Domain from registrar.models.domain import Domain
from registrar.models.user import User from registrar.models.user import User
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from registrar.utility.csv_export import export_domains_to_writer 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): class ExportDataTest(TestCase):

View file

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

View file

@ -144,6 +144,18 @@ class DomainApplicationTests(TestWithUser, WebTest):
result = page.form.submit() result = page.form.submit()
self.assertIn("What kind of U.S.-based government organization do you represent?", result) 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 @boto3_mocking.patching
def test_application_form_submission(self): def test_application_form_submission(self):
""" """

View file

@ -13,6 +13,10 @@ class DomainUnavailableError(ValueError):
pass pass
class RegistrySystemError(ValueError):
pass
class ActionNotAllowed(Exception): class ActionNotAllowed(Exception):
"""User accessed an action that is not """User accessed an action that is not
allowed by the current state""" allowed by the current state"""
@ -42,7 +46,7 @@ class GenericError(Exception):
GenericErrorCodes.CANNOT_CONTACT_REGISTRY: """ GenericErrorCodes.CANNOT_CONTACT_REGISTRY: """
Were experiencing a system connection error. Please wait a few minutes 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, 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."), GenericErrorCodes.GENERIC_ERROR: ("Value entered was wrong."),
} }
@ -56,6 +60,10 @@ contact help@get.gov
def __str__(self): def __str__(self):
return f"{self.message}" return f"{self.message}"
@classmethod
def get_error_message(self, code=None):
return self._error_mapping.get(code)
class NameserverErrorCodes(IntEnum): class NameserverErrorCodes(IntEnum):
"""Used in the NameserverError class for """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.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import resolve, reverse from django.urls import resolve, reverse
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django.contrib import messages from django.contrib import messages
@ -85,7 +86,7 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
Step.YOUR_CONTACT: _("Your contact information"), Step.YOUR_CONTACT: _("Your contact information"),
Step.OTHER_CONTACTS: _("Other employees from your organization"), Step.OTHER_CONTACTS: _("Other employees from your organization"),
Step.NO_OTHER_CONTACTS: _("No 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.REQUIREMENTS: _("Requirements for operating .gov domains"),
Step.REVIEW: _("Review and submit your domain request"), Step.REVIEW: _("Review and submit your domain request"),
} }
@ -218,6 +219,23 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
self.steps.current = current_url self.steps.current = current_url
context = self.get_context_data() context = self.get_context_data()
context["forms"] = self.get_forms() 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) return render(request, self.template_name, context)
def get_all_forms(self, **kwargs) -> list: def get_all_forms(self, **kwargs) -> list:
@ -266,6 +284,37 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
return instantiated 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): def get_context_data(self):
"""Define context for access on all wizard pages.""" """Define context for access on all wizard pages."""
return { return {
@ -328,6 +377,10 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
if button == "save": if button == "save":
messages.success(request, "Your progress has been saved!") messages.success(request, "Your progress has been saved!")
return self.goto(self.steps.current) 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 # otherwise, proceed as normal
return self.goto_next_step() return self.goto_next_step()

View file

@ -280,6 +280,7 @@ class DomainNameserversView(DomainFormBaseView):
form.fields["server"].required = True form.fields["server"].required = True
else: else:
form.fields["server"].required = False form.fields["server"].required = False
form.fields["server"].label += " (optional)"
form.fields["domain"].initial = self.object.name form.fields["domain"].initial = self.object.name
return formset return formset
@ -643,7 +644,46 @@ class DomainAddUserView(DomainFormBaseView):
"""Get an absolute URL for this domain.""" """Get an absolute URL for this domain."""
return self.request.build_absolute_uri(reverse("domain", kwargs={"pk": self.object.id})) return self.request.build_absolute_uri(reverse("domain", kwargs={"pk": self.object.id}))
def _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.""" """Make a Domain invitation for this email and redirect with a message."""
invitation, created = DomainInvitation.objects.get_or_create(email=email_address, domain=self.object) invitation, created = DomainInvitation.objects.get_or_create(email=email_address, domain=self.object)
if not created: if not created:
@ -653,34 +693,7 @@ class DomainAddUserView(DomainFormBaseView):
f"{email_address} has already been invited to this domain.", f"{email_address} has already been invited to this domain.",
) )
else: else:
# created a new invitation in the database, so send an email self._send_domain_invitation_email(email=email_address)
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.")
return redirect(self.get_success_url()) return redirect(self.get_success_url())
def form_valid(self, form): def form_valid(self, form):
@ -692,6 +705,9 @@ class DomainAddUserView(DomainFormBaseView):
except User.DoesNotExist: except User.DoesNotExist:
# no matching user, go make an invitation # no matching user, go make an invitation
return self._make_invitation(requested_email) 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: try:
UserDomainRole.objects.create( UserDomainRole.objects.create(

View file

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