mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-25 20:18:38 +02:00
Merge branch 'main' into za/1291-allow-staff-to-access-user-domain-roles
This commit is contained in:
commit
278166729b
59 changed files with 1357 additions and 150 deletions
33
.github/workflows/daily-csv-upload.yaml
vendored
Normal file
33
.github/workflows/daily-csv-upload.yaml
vendored
Normal 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"
|
||||
|
1
.github/workflows/deploy-development.yaml
vendored
1
.github/workflows/deploy-development.yaml
vendored
|
@ -15,7 +15,6 @@ on:
|
|||
|
||||
jobs:
|
||||
deploy-development:
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
|
5
.github/workflows/deploy-sandbox.yaml
vendored
5
.github/workflows/deploy-sandbox.yaml
vendored
|
@ -47,9 +47,8 @@ jobs:
|
|||
working-directory: ./src
|
||||
run: docker compose run app python manage.py collectstatic --no-input
|
||||
- name: Deploy to cloud.gov sandbox
|
||||
uses: 18f/cg-deploy-action@main
|
||||
uses: cloud-gov/cg-cli-tools@main
|
||||
env:
|
||||
DEPLOY_NOW: thanks
|
||||
ENVIRONMENT: ${{ needs.variables.outputs.environment }}
|
||||
CF_USERNAME: CF_${{ needs.variables.outputs.environment }}_USERNAME
|
||||
CF_PASSWORD: CF_${{ needs.variables.outputs.environment }}_PASSWORD
|
||||
|
@ -58,7 +57,7 @@ jobs:
|
|||
cf_password: ${{ secrets[env.CF_PASSWORD] }}
|
||||
cf_org: cisa-dotgov
|
||||
cf_space: ${{ env.ENVIRONMENT }}
|
||||
push_arguments: "-f ops/manifests/manifest-${{ env.ENVIRONMENT }}.yaml"
|
||||
cf_manifest: ops/manifests/manifest-${{ env.ENVIRONMENT }}.yaml
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [variables, deploy]
|
||||
|
|
6
.github/workflows/deploy-stable.yaml
vendored
6
.github/workflows/deploy-stable.yaml
vendored
|
@ -30,12 +30,10 @@ jobs:
|
|||
working-directory: ./src
|
||||
run: docker compose run app python manage.py collectstatic --no-input
|
||||
- name: Deploy to cloud.gov sandbox
|
||||
uses: 18f/cg-deploy-action@main
|
||||
env:
|
||||
DEPLOY_NOW: thanks
|
||||
uses: cloud-gov/cg-cli-tools@main
|
||||
with:
|
||||
cf_username: ${{ secrets.CF_STABLE_USERNAME }}
|
||||
cf_password: ${{ secrets.CF_STABLE_PASSWORD }}
|
||||
cf_org: cisa-dotgov
|
||||
cf_space: stable
|
||||
push_arguments: "-f ops/manifests/manifest-stable.yaml"
|
||||
cf_manifest: "ops/manifests/manifest-stable.yaml"
|
||||
|
|
8
.github/workflows/deploy-staging.yaml
vendored
8
.github/workflows/deploy-staging.yaml
vendored
|
@ -9,7 +9,7 @@ on:
|
|||
- 'docs/**'
|
||||
- '**.md'
|
||||
- '.gitignore'
|
||||
|
||||
|
||||
tags:
|
||||
- staging-*
|
||||
|
||||
|
@ -30,12 +30,10 @@ jobs:
|
|||
working-directory: ./src
|
||||
run: docker compose run app python manage.py collectstatic --no-input
|
||||
- name: Deploy to cloud.gov sandbox
|
||||
uses: 18f/cg-deploy-action@main
|
||||
env:
|
||||
DEPLOY_NOW: thanks
|
||||
uses: cloud-gov/cg-cli-tools@main
|
||||
with:
|
||||
cf_username: ${{ secrets.CF_STAGING_USERNAME }}
|
||||
cf_password: ${{ secrets.CF_STAGING_PASSWORD }}
|
||||
cf_org: cisa-dotgov
|
||||
cf_space: staging
|
||||
push_arguments: "-f ops/manifests/manifest-staging.yaml"
|
||||
cf_manifest: "ops/manifests/manifest-staging.yaml"
|
||||
|
|
4
.github/workflows/migrate.yaml
vendored
4
.github/workflows/migrate.yaml
vendored
|
@ -38,10 +38,10 @@ jobs:
|
|||
CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
|
||||
steps:
|
||||
- name: Run Django migrations for ${{ github.event.inputs.environment }}
|
||||
uses: 18f/cg-deploy-action@main
|
||||
uses: cloud-gov/cg-cli-tools@main
|
||||
with:
|
||||
cf_username: ${{ secrets[env.CF_USERNAME] }}
|
||||
cf_password: ${{ secrets[env.CF_PASSWORD] }}
|
||||
cf_org: cisa-dotgov
|
||||
cf_space: ${{ github.event.inputs.environment }}
|
||||
full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py migrate' --name migrate"
|
||||
cf_command: "run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py migrate' --name migrate"
|
||||
|
|
12
.github/workflows/reset-db.yaml
vendored
12
.github/workflows/reset-db.yaml
vendored
|
@ -38,28 +38,28 @@ jobs:
|
|||
CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
|
||||
steps:
|
||||
- name: Delete existing data for ${{ github.event.inputs.environment }}
|
||||
uses: 18f/cg-deploy-action@main
|
||||
uses: cloud-gov/cg-cli-tools@main
|
||||
with:
|
||||
cf_username: ${{ secrets[env.CF_USERNAME] }}
|
||||
cf_password: ${{ secrets[env.CF_PASSWORD] }}
|
||||
cf_org: cisa-dotgov
|
||||
cf_space: ${{ github.event.inputs.environment }}
|
||||
full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py flush --no-input' --name flush"
|
||||
cf_command: "run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py flush --no-input' --name flush"
|
||||
|
||||
- name: Run Django migrations for ${{ github.event.inputs.environment }}
|
||||
uses: 18f/cg-deploy-action@main
|
||||
uses: cloud-gov/cg-cli-tools@main
|
||||
with:
|
||||
cf_username: ${{ secrets[env.CF_USERNAME] }}
|
||||
cf_password: ${{ secrets[env.CF_PASSWORD] }}
|
||||
cf_org: cisa-dotgov
|
||||
cf_space: ${{ github.event.inputs.environment }}
|
||||
full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py migrate' --name migrate"
|
||||
cf_command: "run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py migrate' --name migrate"
|
||||
|
||||
- name: Load fake data for ${{ github.event.inputs.environment }}
|
||||
uses: 18f/cg-deploy-action@main
|
||||
uses: cloud-gov/cg-cli-tools@main
|
||||
with:
|
||||
cf_username: ${{ secrets[env.CF_USERNAME] }}
|
||||
cf_password: ${{ secrets[env.CF_PASSWORD] }}
|
||||
cf_org: cisa-dotgov
|
||||
cf_space: ${{ github.event.inputs.environment }}
|
||||
full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py load' --name loaddata"
|
||||
cf_command: "run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py load' --name loaddata"
|
||||
|
|
|
@ -295,7 +295,7 @@ sudo sntp -sS time.nist.gov
|
|||
```
|
||||
|
||||
## Connection pool
|
||||
To handle our connection to the registry, we utilize a connection pool to keep a socket open to increase responsiveness. In order to accomplish this, we are utilizing a heavily modified version of the (geventconnpool)[https://github.com/rasky/geventconnpool] library.
|
||||
To handle our connection to the registry, we utilize a connection pool to keep a socket open to increase responsiveness. In order to accomplish this, we are utilizing a heavily modified version of the [geventconnpool](https://github.com/rasky/geventconnpool) library.
|
||||
|
||||
### Settings
|
||||
The config for the connection pool exists inside the `settings.py` file.
|
||||
|
@ -319,4 +319,36 @@ Our connection pool has a built-in `pool_status` object which you can call at an
|
|||
5. `print(registry.pool_status.connection_success)`
|
||||
* Should return true
|
||||
|
||||
If you have multiple instances (staging for example), then repeat commands 1-5 for each instance you want to test.
|
||||
If you have multiple instances (staging for example), then repeat commands 1-5 for each instance you want to test.
|
||||
|
||||
## Adding a S3 instance to your sandbox
|
||||
This can either be done through the CLI, or through the cloud.gov dashboard. Generally, it is better to do it through the dashboard as it handles app binding for you.
|
||||
|
||||
To associate a S3 instance to your sandbox, follow these steps:
|
||||
1. Navigate to https://dashboard.fr.cloud.gov/login
|
||||
2. Select your sandbox from the `Applications` tab
|
||||
3. Click `Services` on the application nav bar
|
||||
4. Add a new service (plus symbol)
|
||||
5. Click `Marketplace Service`
|
||||
6. On the `Select the service` dropdown, select `s3`
|
||||
7. Under the dropdown on `Select Plan`, select `basic-sandbox`
|
||||
8. Under `Service Instance` enter `getgov-s3` for the name
|
||||
|
||||
See this [resource](https://cloud.gov/docs/services/s3/) for information on associating an S3 instance with your sandbox through the CLI.
|
||||
|
||||
### Testing your S3 instance locally
|
||||
To test the S3 bucket associated with your sandbox, you will need to add four additional variables to your `.env` file. These are as follows:
|
||||
|
||||
```
|
||||
AWS_S3_ACCESS_KEY_ID = "{string value of `access_key_id` in getgov-s3}"
|
||||
AWS_S3_SECRET_ACCESS_KEY = "{string value of `secret_access_key` in getgov-s3}"
|
||||
AWS_S3_REGION = "{string value of `region` in getgov-s3}"
|
||||
AWS_S3_BUCKET_NAME = "{string value of `bucket` in getgov-s3}"
|
||||
```
|
||||
|
||||
You can view these variables by running the following command:
|
||||
```
|
||||
cf env getgov-{app name}
|
||||
```
|
||||
|
||||
Then, copy the variables under the section labled `s3`.
|
|
@ -8,6 +8,7 @@ from django.test import RequestFactory
|
|||
from ..views import available, check_domain_available
|
||||
from .common import less_console_noise
|
||||
from registrar.tests.common import MockEppLib
|
||||
from registrar.utility.errors import GenericError, GenericErrorCodes
|
||||
from unittest.mock import call
|
||||
|
||||
from epplibwrapper import (
|
||||
|
@ -100,16 +101,25 @@ class AvailableViewTest(MockEppLib):
|
|||
response = available(request, domain="igorville")
|
||||
self.assertTrue(json.loads(response.content)["available"])
|
||||
|
||||
def test_error_handling(self):
|
||||
"""Calling with bad strings raises an error."""
|
||||
def test_bad_string_handling(self):
|
||||
"""Calling with bad strings returns unavailable."""
|
||||
bad_string = "blah!;"
|
||||
request = self.factory.get(API_BASE_PATH + bad_string)
|
||||
request.user = self.user
|
||||
response = available(request, domain=bad_string)
|
||||
self.assertFalse(json.loads(response.content)["available"])
|
||||
# domain set to raise error returns false for availability
|
||||
error_domain_available = available(request, "errordomain.gov")
|
||||
self.assertFalse(json.loads(error_domain_available.content)["available"])
|
||||
|
||||
def test_error_handling(self):
|
||||
"""Error thrown while calling availabilityAPI returns error."""
|
||||
request = self.factory.get(API_BASE_PATH + "errordomain.gov")
|
||||
request.user = self.user
|
||||
# domain set to raise error returns false for availability and error message
|
||||
error_domain_response = available(request, domain="errordomain.gov")
|
||||
self.assertFalse(json.loads(error_domain_response.content)["available"])
|
||||
self.assertEqual(
|
||||
GenericError.get_error_message(GenericErrorCodes.CANNOT_CONTACT_REGISTRY),
|
||||
json.loads(error_domain_response.content)["message"],
|
||||
)
|
||||
|
||||
|
||||
class AvailableAPITest(MockEppLib):
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
"""Internal API views"""
|
||||
from django.apps import apps
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.http import JsonResponse
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from registrar.templatetags.url_helpers import public_site_url
|
||||
from registrar.utility.errors import GenericError, GenericErrorCodes
|
||||
|
||||
import requests
|
||||
|
||||
|
@ -12,6 +13,8 @@ from login_required import login_not_required
|
|||
|
||||
from cachetools.func import ttl_cache
|
||||
|
||||
from registrar.utility.s3_bucket import S3ClientError, S3ClientHelper
|
||||
|
||||
|
||||
DOMAIN_FILE_URL = "https://raw.githubusercontent.com/cisagov/dotgov-data/main/current-full.csv"
|
||||
|
||||
|
@ -30,7 +33,7 @@ DOMAIN_API_MESSAGES = {
|
|||
),
|
||||
"invalid": "Enter a domain using only letters, numbers, or hyphens (though we don't recommend using hyphens).",
|
||||
"success": "That domain is available!",
|
||||
"error": "Error finding domain availability.",
|
||||
"error": GenericError.get_error_message(GenericErrorCodes.CANNOT_CONTACT_REGISTRY),
|
||||
}
|
||||
|
||||
|
||||
|
@ -63,17 +66,14 @@ def check_domain_available(domain):
|
|||
|
||||
The given domain is lowercased to match against the domains list. If the
|
||||
given domain doesn't end with .gov, ".gov" is added when looking for
|
||||
a match.
|
||||
a match. If check fails, throws a RegistryError.
|
||||
"""
|
||||
Domain = apps.get_model("registrar.Domain")
|
||||
try:
|
||||
if domain.endswith(".gov"):
|
||||
return Domain.available(domain)
|
||||
else:
|
||||
# domain search string doesn't end with .gov, add it on here
|
||||
return Domain.available(domain + ".gov")
|
||||
except Exception:
|
||||
return False
|
||||
if domain.endswith(".gov"):
|
||||
return Domain.available(domain)
|
||||
else:
|
||||
# domain search string doesn't end with .gov, add it on here
|
||||
return Domain.available(domain + ".gov")
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
|
@ -97,3 +97,36 @@ def available(request, domain=""):
|
|||
return JsonResponse({"available": False, "message": DOMAIN_API_MESSAGES["unavailable"]})
|
||||
except Exception:
|
||||
return JsonResponse({"available": False, "message": DOMAIN_API_MESSAGES["error"]})
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@login_not_required
|
||||
def get_current_full(request, file_name="current-full.csv"):
|
||||
"""This will return the file content of current-full.csv which is the command
|
||||
output of generate_current_full_report.py. This command iterates through each Domain
|
||||
and returns a CSV representation."""
|
||||
return serve_file(file_name)
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@login_not_required
|
||||
def get_current_federal(request, file_name="current-federal.csv"):
|
||||
"""This will return the file content of current-federal.csv which is the command
|
||||
output of generate_current_federal_report.py. This command iterates through each Domain
|
||||
and returns a CSV representation."""
|
||||
return serve_file(file_name)
|
||||
|
||||
|
||||
def serve_file(file_name):
|
||||
"""Downloads a file based on a given filepath. Returns a 500 if not found."""
|
||||
s3_client = S3ClientHelper()
|
||||
# Serve the CSV file. If not found, an exception will be thrown.
|
||||
# This will then be caught by flat, causing it to not read it - which is what we want.
|
||||
try:
|
||||
file = s3_client.get_file(file_name, decode_to_utf=True)
|
||||
except S3ClientError as err:
|
||||
# TODO - #1317: Notify operations when auto report generation fails
|
||||
raise err
|
||||
|
||||
response = HttpResponse(file)
|
||||
return response
|
||||
|
|
|
@ -51,6 +51,11 @@ services:
|
|||
# AWS credentials
|
||||
- AWS_ACCESS_KEY_ID
|
||||
- AWS_SECRET_ACCESS_KEY
|
||||
# AWS S3 bucket credentials
|
||||
- AWS_S3_ACCESS_KEY_ID
|
||||
- AWS_S3_SECRET_ACCESS_KEY
|
||||
- AWS_S3_REGION
|
||||
- AWS_S3_BUCKET_NAME
|
||||
stdin_open: true
|
||||
tty: true
|
||||
ports:
|
||||
|
|
|
@ -118,6 +118,15 @@ class ListHeaderAdmin(AuditedAdmin):
|
|||
)
|
||||
return filters
|
||||
|
||||
# customize the help_text for all formfields for manytomany
|
||||
def formfield_for_manytomany(self, db_field, request, **kwargs):
|
||||
formfield = super().formfield_for_manytomany(db_field, request, **kwargs)
|
||||
formfield.help_text = (
|
||||
formfield.help_text
|
||||
+ " If more than one value is selected, the change/delete/view actions will be disabled."
|
||||
)
|
||||
return formfield
|
||||
|
||||
|
||||
class UserContactInline(admin.StackedInline):
|
||||
"""Edit a user's profile on the user page."""
|
||||
|
@ -325,6 +334,14 @@ class WebsiteAdmin(ListHeaderAdmin):
|
|||
class UserDomainRoleAdmin(ListHeaderAdmin):
|
||||
"""Custom user domain role admin class."""
|
||||
|
||||
class Meta:
|
||||
"""Contains meta information about this class"""
|
||||
|
||||
model = models.UserDomainRole
|
||||
fields = "__all__"
|
||||
|
||||
_meta = Meta()
|
||||
|
||||
# Columns
|
||||
list_display = [
|
||||
"user",
|
||||
|
@ -336,10 +353,11 @@ class UserDomainRoleAdmin(ListHeaderAdmin):
|
|||
search_fields = [
|
||||
"user__first_name",
|
||||
"user__last_name",
|
||||
"user__email",
|
||||
"domain__name",
|
||||
"role",
|
||||
]
|
||||
search_help_text = "Search by user, domain, or role."
|
||||
search_help_text = "Search by firstname, lastname, email, domain, or role."
|
||||
|
||||
autocomplete_fields = ["user", "domain"]
|
||||
|
||||
|
@ -448,7 +466,7 @@ class DomainInformationAdmin(ListHeaderAdmin):
|
|||
"No other employees from your organization?",
|
||||
{"fields": ["no_other_contacts_rationale"]},
|
||||
),
|
||||
("Anything else we should know?", {"fields": ["anything_else"]}),
|
||||
("Anything else?", {"fields": ["anything_else"]}),
|
||||
(
|
||||
"Requirements for operating .gov domains",
|
||||
{"fields": ["is_policy_acknowledged"]},
|
||||
|
@ -467,6 +485,17 @@ class DomainInformationAdmin(ListHeaderAdmin):
|
|||
"is_policy_acknowledged",
|
||||
]
|
||||
|
||||
# For each filter_horizontal, init in admin js extendFilterHorizontalWidgets
|
||||
# to activate the edit/delete/view buttons
|
||||
filter_horizontal = ("other_contacts",)
|
||||
|
||||
# lists in filter_horizontal are not sorted properly, sort them
|
||||
# by first_name
|
||||
def formfield_for_manytomany(self, db_field, request, **kwargs):
|
||||
if db_field.name in ("other_contacts",):
|
||||
kwargs["queryset"] = models.Contact.objects.all().order_by("first_name") # Sort contacts
|
||||
return super().formfield_for_manytomany(db_field, request, **kwargs)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""Set the read-only state on form elements.
|
||||
We have 1 conditions that determine which fields are read-only:
|
||||
|
@ -583,7 +612,7 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
|||
"No other employees from your organization?",
|
||||
{"fields": ["no_other_contacts_rationale"]},
|
||||
),
|
||||
("Anything else we should know?", {"fields": ["anything_else"]}),
|
||||
("Anything else?", {"fields": ["anything_else"]}),
|
||||
(
|
||||
"Requirements for operating .gov domains",
|
||||
{"fields": ["is_policy_acknowledged"]},
|
||||
|
@ -603,6 +632,15 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
|||
"is_policy_acknowledged",
|
||||
]
|
||||
|
||||
filter_horizontal = ("current_websites", "alternative_domains", "other_contacts")
|
||||
|
||||
# lists in filter_horizontal are not sorted properly, sort them
|
||||
# by website
|
||||
def formfield_for_manytomany(self, db_field, request, **kwargs):
|
||||
if db_field.name in ("current_websites", "alternative_domains"):
|
||||
kwargs["queryset"] = models.Website.objects.all().order_by("website") # Sort websites
|
||||
return super().formfield_for_manytomany(db_field, request, **kwargs)
|
||||
|
||||
# Trigger action when a fieldset is changed
|
||||
def save_model(self, request, obj, form, change):
|
||||
if obj and obj.creator.status != models.User.RESTRICTED:
|
||||
|
@ -731,6 +769,16 @@ class DomainInformationInline(admin.StackedInline):
|
|||
|
||||
fieldsets = DomainInformationAdmin.fieldsets
|
||||
analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields
|
||||
# For each filter_horizontal, init in admin js extendFilterHorizontalWidgets
|
||||
# to activate the edit/delete/view buttons
|
||||
filter_horizontal = ("other_contacts",)
|
||||
|
||||
# lists in filter_horizontal are not sorted properly, sort them
|
||||
# by first_name
|
||||
def formfield_for_manytomany(self, db_field, request, **kwargs):
|
||||
if db_field.name in ("other_contacts",):
|
||||
kwargs["queryset"] = models.Contact.objects.all().order_by("first_name") # Sort contacts
|
||||
return super().formfield_for_manytomany(db_field, request, **kwargs)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
return DomainInformationAdmin.get_readonly_fields(self, request, obj=None)
|
||||
|
|
|
@ -47,4 +47,231 @@ function openInNewTab(el, removeAttribute = false){
|
|||
}
|
||||
|
||||
prepareDjangoAdmin();
|
||||
})();
|
||||
})();
|
||||
|
||||
/**
|
||||
* An IIFE to listen to changes on filter_horizontal and enable or disable the change/delete/view buttons as applicable
|
||||
*
|
||||
*/
|
||||
(function extendFilterHorizontalWidgets() {
|
||||
// Initialize custom filter_horizontal widgets; each widget has a "from" select list
|
||||
// and a "to" select list; initialization is based off of the presence of the
|
||||
// "to" select list
|
||||
checkToListThenInitWidget('id_other_contacts_to', 0);
|
||||
checkToListThenInitWidget('id_domain_info-0-other_contacts_to', 0);
|
||||
checkToListThenInitWidget('id_current_websites_to', 0);
|
||||
checkToListThenInitWidget('id_alternative_domains_to', 0);
|
||||
})();
|
||||
|
||||
// Function to check for the existence of the "to" select list element in the DOM, and if and when found,
|
||||
// initialize the associated widget
|
||||
function checkToListThenInitWidget(toListId, attempts) {
|
||||
let toList = document.getElementById(toListId);
|
||||
attempts++;
|
||||
|
||||
if (attempts < 6) {
|
||||
if ((toList !== null)) {
|
||||
// toList found, handle it
|
||||
// Add an event listener on the element
|
||||
// Add disabled buttons on the element's great-grandparent
|
||||
initializeWidgetOnToList(toList, toListId);
|
||||
} else {
|
||||
// Element not found, check again after a delay
|
||||
setTimeout(() => checkToListThenInitWidget(toListId, attempts), 1000); // Check every 1000 milliseconds (1 second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the widget:
|
||||
// add related buttons to the widget for edit, delete and view
|
||||
// add event listeners on the from list, the to list, and selector buttons which either enable or disable the related buttons
|
||||
function initializeWidgetOnToList(toList, toListId) {
|
||||
// create the change button
|
||||
let changeLink = createAndCustomizeLink(
|
||||
toList,
|
||||
toListId,
|
||||
'related-widget-wrapper-link change-related',
|
||||
'Change',
|
||||
'/public/admin/img/icon-changelink.svg',
|
||||
{
|
||||
'contacts': '/admin/registrar/contact/__fk__/change/?_to_field=id&_popup=1',
|
||||
'websites': '/admin/registrar/website/__fk__/change/?_to_field=id&_popup=1',
|
||||
'alternative_domains': '/admin/registrar/website/__fk__/change/?_to_field=id&_popup=1',
|
||||
},
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
let hasDeletePermission = hasDeletePermissionOnPage();
|
||||
|
||||
let deleteLink = null;
|
||||
if (hasDeletePermission) {
|
||||
// create the delete button if user has permission to delete
|
||||
deleteLink = createAndCustomizeLink(
|
||||
toList,
|
||||
toListId,
|
||||
'related-widget-wrapper-link delete-related',
|
||||
'Delete',
|
||||
'/public/admin/img/icon-deletelink.svg',
|
||||
{
|
||||
'contacts': '/admin/registrar/contact/__fk__/delete/?_to_field=id&_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));
|
||||
}
|
||||
|
|
|
@ -33,11 +33,20 @@ env = environs.Env()
|
|||
# Get secrets from Cloud.gov user provided service, if exists
|
||||
# If not, get secrets from environment variables
|
||||
key_service = AppEnv().get_service(name="getgov-credentials")
|
||||
|
||||
|
||||
# Get secrets from Cloud.gov user provided s3 service, if it exists
|
||||
s3_key_service = AppEnv().get_service(name="getgov-s3")
|
||||
|
||||
if key_service and key_service.credentials:
|
||||
if s3_key_service and s3_key_service.credentials:
|
||||
# Concatenate the credentials from our S3 service into our secret service
|
||||
key_service.credentials.update(s3_key_service.credentials)
|
||||
secret = key_service.credentials.get
|
||||
else:
|
||||
secret = env
|
||||
|
||||
|
||||
# # # ###
|
||||
# Values obtained externally #
|
||||
# # # ###
|
||||
|
@ -58,6 +67,12 @@ secret_key = secret("DJANGO_SECRET_KEY")
|
|||
secret_aws_ses_key_id = secret("AWS_ACCESS_KEY_ID", None)
|
||||
secret_aws_ses_key = secret("AWS_SECRET_ACCESS_KEY", None)
|
||||
|
||||
# These keys are present in a getgov-s3 instance, or they can be defined locally
|
||||
aws_s3_region_name = secret("region", None) or secret("AWS_S3_REGION", None)
|
||||
secret_aws_s3_key_id = secret("access_key_id", None) or secret("AWS_S3_ACCESS_KEY_ID", None)
|
||||
secret_aws_s3_key = secret("secret_access_key", None) or secret("AWS_S3_SECRET_ACCESS_KEY", None)
|
||||
secret_aws_s3_bucket_name = secret("bucket", None) or secret("AWS_S3_BUCKET_NAME", None)
|
||||
|
||||
secret_registry_cl_id = secret("REGISTRY_CL_ID")
|
||||
secret_registry_password = secret("REGISTRY_PASSWORD")
|
||||
secret_registry_cert = b64decode(secret("REGISTRY_CERT", ""))
|
||||
|
@ -257,7 +272,14 @@ AUTH_USER_MODEL = "registrar.User"
|
|||
AWS_ACCESS_KEY_ID = secret_aws_ses_key_id
|
||||
AWS_SECRET_ACCESS_KEY = secret_aws_ses_key
|
||||
AWS_REGION = "us-gov-west-1"
|
||||
# https://boto3.amazonaws.com/v1/documentation/api/latest/guide/retries.html#standard-retry-mode
|
||||
|
||||
# Configuration for accessing AWS S3
|
||||
AWS_S3_ACCESS_KEY_ID = secret_aws_s3_key_id
|
||||
AWS_S3_SECRET_ACCESS_KEY = secret_aws_s3_key
|
||||
AWS_S3_REGION = aws_s3_region_name
|
||||
AWS_S3_BUCKET_NAME = secret_aws_s3_bucket_name
|
||||
|
||||
# https://boto3.amazonaws.com/v1/documentation/latest/guide/retries.html#standard-retry-mode
|
||||
AWS_RETRY_MODE: Final = "standard"
|
||||
# base 2 exponential backoff with max of 20 seconds:
|
||||
AWS_MAX_ATTEMPTS = 3
|
||||
|
|
|
@ -11,7 +11,8 @@ from django.views.generic import RedirectView
|
|||
from registrar import views
|
||||
from registrar.views.application import Step
|
||||
from registrar.views.utility import always_404
|
||||
from api.views import available
|
||||
from api.views import available, get_current_federal, get_current_full
|
||||
|
||||
|
||||
APPLICATION_NAMESPACE = views.ApplicationWizard.URL_NAMESPACE
|
||||
application_urls = [
|
||||
|
@ -73,6 +74,8 @@ urlpatterns = [
|
|||
path("openid/", include("djangooidc.urls")),
|
||||
path("register/", include((application_urls, APPLICATION_NAMESPACE))),
|
||||
path("api/v1/available/<domain>", available, name="available"),
|
||||
path("api/v1/get-report/current-federal", get_current_federal, name="get-current-federal"),
|
||||
path("api/v1/get-report/current-full", get_current_full, name="get-current-full"),
|
||||
path(
|
||||
"todo",
|
||||
lambda r: always_404(r, "We forgot to include this link, sorry."),
|
||||
|
|
|
@ -244,7 +244,7 @@ class OrganizationContactForm(RegistrarForm):
|
|||
)
|
||||
address_line2 = forms.CharField(
|
||||
required=False,
|
||||
label="Street address line 2",
|
||||
label="Street address line 2 (optional)",
|
||||
)
|
||||
city = forms.CharField(
|
||||
label="City",
|
||||
|
@ -268,7 +268,7 @@ class OrganizationContactForm(RegistrarForm):
|
|||
)
|
||||
urbanization = forms.CharField(
|
||||
required=False,
|
||||
label="Urbanization (Puerto Rico only)",
|
||||
label="Urbanization (required for Puerto Rico only)",
|
||||
)
|
||||
|
||||
def clean_federal_agency(self):
|
||||
|
@ -331,7 +331,7 @@ class AuthorizingOfficialForm(RegistrarForm):
|
|||
)
|
||||
middle_name = forms.CharField(
|
||||
required=False,
|
||||
label="Middle name",
|
||||
label="Middle name (optional)",
|
||||
)
|
||||
last_name = forms.CharField(
|
||||
label="Last name / family name",
|
||||
|
@ -399,13 +399,15 @@ class AlternativeDomainForm(RegistrarForm):
|
|||
raise forms.ValidationError(DOMAIN_API_MESSAGES["extra_dots"], code="extra_dots")
|
||||
except errors.DomainUnavailableError:
|
||||
raise forms.ValidationError(DOMAIN_API_MESSAGES["unavailable"], code="unavailable")
|
||||
except errors.RegistrySystemError:
|
||||
raise forms.ValidationError(DOMAIN_API_MESSAGES["error"], code="error")
|
||||
except ValueError:
|
||||
raise forms.ValidationError(DOMAIN_API_MESSAGES["invalid"], code="invalid")
|
||||
return validated
|
||||
|
||||
alternative_domain = forms.CharField(
|
||||
required=False,
|
||||
label="Alternative domain",
|
||||
label="",
|
||||
)
|
||||
|
||||
|
||||
|
@ -484,6 +486,8 @@ class DotGovDomainForm(RegistrarForm):
|
|||
raise forms.ValidationError(DOMAIN_API_MESSAGES["extra_dots"], code="extra_dots")
|
||||
except errors.DomainUnavailableError:
|
||||
raise forms.ValidationError(DOMAIN_API_MESSAGES["unavailable"], code="unavailable")
|
||||
except errors.RegistrySystemError:
|
||||
raise forms.ValidationError(DOMAIN_API_MESSAGES["error"], code="error")
|
||||
except ValueError:
|
||||
raise forms.ValidationError(DOMAIN_API_MESSAGES["invalid"], code="invalid")
|
||||
return validated
|
||||
|
@ -529,7 +533,7 @@ class YourContactForm(RegistrarForm):
|
|||
)
|
||||
middle_name = forms.CharField(
|
||||
required=False,
|
||||
label="Middle name",
|
||||
label="Middle name (optional)",
|
||||
)
|
||||
last_name = forms.CharField(
|
||||
label="Last name / family name",
|
||||
|
@ -558,7 +562,7 @@ class OtherContactsForm(RegistrarForm):
|
|||
)
|
||||
middle_name = forms.CharField(
|
||||
required=False,
|
||||
label="Middle name",
|
||||
label="Middle name (optional)",
|
||||
)
|
||||
last_name = forms.CharField(
|
||||
label="Last name / family name",
|
||||
|
@ -610,8 +614,8 @@ class NoOtherContactsForm(RegistrarForm):
|
|||
required=True,
|
||||
# label has to end in a space to get the label_suffix to show
|
||||
label=(
|
||||
"Please explain why there are no other employees from your organization"
|
||||
" we can contact to help us assess your eligibility for a .gov domain."
|
||||
"Please explain why there are no other employees from your organization "
|
||||
"we can contact to help us assess your eligibility for a .gov domain."
|
||||
),
|
||||
widget=forms.Textarea(),
|
||||
)
|
||||
|
@ -620,7 +624,7 @@ class NoOtherContactsForm(RegistrarForm):
|
|||
class AnythingElseForm(RegistrarForm):
|
||||
anything_else = forms.CharField(
|
||||
required=False,
|
||||
label="Anything else we should know?",
|
||||
label="Anything else?",
|
||||
widget=forms.Textarea(),
|
||||
validators=[
|
||||
MaxLengthValidator(
|
||||
|
|
|
@ -181,6 +181,9 @@ class ContactForm(forms.ModelForm):
|
|||
for field_name in self.required:
|
||||
self.fields[field_name].required = True
|
||||
|
||||
# Set custom form label
|
||||
self.fields["middle_name"].label = "Middle name (optional)"
|
||||
|
||||
# Set custom error messages
|
||||
self.fields["first_name"].error_messages = {"required": "Enter your first name / given name."}
|
||||
self.fields["last_name"].error_messages = {"required": "Enter your last name / family name."}
|
||||
|
@ -190,7 +193,7 @@ class ContactForm(forms.ModelForm):
|
|||
self.fields["email"].error_messages = {
|
||||
"required": "Enter your email address in the required format, like name@example.com."
|
||||
}
|
||||
self.fields["phone"].error_messages = {"required": "Enter your phone number."}
|
||||
self.fields["phone"].error_messages["required"] = "Enter your phone number."
|
||||
|
||||
|
||||
class AuthorizingOfficialContactForm(ContactForm):
|
||||
|
@ -213,14 +216,14 @@ class AuthorizingOfficialContactForm(ContactForm):
|
|||
self.fields["email"].error_messages = {
|
||||
"required": "Enter an email address in the required format, like name@example.com."
|
||||
}
|
||||
self.fields["phone"].error_messages = {"required": "Enter a phone number for your authorizing official."}
|
||||
self.fields["phone"].error_messages["required"] = "Enter a phone number for your authorizing official."
|
||||
|
||||
|
||||
class DomainSecurityEmailForm(forms.Form):
|
||||
"""Form for adding or editing a security email to a domain."""
|
||||
|
||||
security_email = forms.EmailField(
|
||||
label="Security email",
|
||||
label="Security email (optional)",
|
||||
required=False,
|
||||
error_messages={
|
||||
"invalid": str(SecurityEmailError(code=SecurityEmailErrorCodes.BAD_DATA)),
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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)",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -26,7 +26,7 @@ class Contact(TimeStampedModel):
|
|||
middle_name = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Middle name",
|
||||
help_text="Middle name (optional)",
|
||||
)
|
||||
last_name = models.TextField(
|
||||
null=True,
|
||||
|
|
|
@ -442,7 +442,7 @@ class DomainApplication(TimeStampedModel):
|
|||
address_line2 = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Street address line 2",
|
||||
help_text="Street address line 2 (optional)",
|
||||
)
|
||||
city = models.TextField(
|
||||
null=True,
|
||||
|
@ -465,7 +465,7 @@ class DomainApplication(TimeStampedModel):
|
|||
urbanization = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Urbanization (Puerto Rico only)",
|
||||
help_text="Urbanization (required for Puerto Rico only)",
|
||||
)
|
||||
|
||||
about_your_organization = models.TextField(
|
||||
|
@ -487,6 +487,7 @@ class DomainApplication(TimeStampedModel):
|
|||
"registrar.Website",
|
||||
blank=True,
|
||||
related_name="current+",
|
||||
verbose_name="websites",
|
||||
)
|
||||
|
||||
approved_domain = models.OneToOneField(
|
||||
|
@ -532,6 +533,7 @@ class DomainApplication(TimeStampedModel):
|
|||
"registrar.Contact",
|
||||
blank=True,
|
||||
related_name="contact_applications",
|
||||
verbose_name="contacts",
|
||||
)
|
||||
|
||||
no_other_contacts_rationale = models.TextField(
|
||||
|
@ -543,7 +545,7 @@ class DomainApplication(TimeStampedModel):
|
|||
anything_else = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Anything else we should know?",
|
||||
help_text="Anything else?",
|
||||
)
|
||||
|
||||
is_policy_acknowledged = models.BooleanField(
|
||||
|
|
|
@ -106,8 +106,8 @@ class DomainInformation(TimeStampedModel):
|
|||
address_line2 = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Street address line 2",
|
||||
verbose_name="Street address line 2",
|
||||
help_text="Street address line 2 (optional)",
|
||||
verbose_name="Street address line 2 (optional)",
|
||||
)
|
||||
city = models.TextField(
|
||||
null=True,
|
||||
|
@ -131,8 +131,8 @@ class DomainInformation(TimeStampedModel):
|
|||
urbanization = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Urbanization (Puerto Rico only)",
|
||||
verbose_name="Urbanization (Puerto Rico only)",
|
||||
help_text="Urbanization (required for Puerto Rico only)",
|
||||
verbose_name="Urbanization (required for Puerto Rico only)",
|
||||
)
|
||||
|
||||
about_your_organization = models.TextField(
|
||||
|
@ -179,6 +179,7 @@ class DomainInformation(TimeStampedModel):
|
|||
"registrar.Contact",
|
||||
blank=True,
|
||||
related_name="contact_applications_information",
|
||||
verbose_name="contacts",
|
||||
)
|
||||
|
||||
no_other_contacts_rationale = models.TextField(
|
||||
|
@ -190,7 +191,7 @@ class DomainInformation(TimeStampedModel):
|
|||
anything_else = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Anything else we should know?",
|
||||
help_text="Anything else?",
|
||||
)
|
||||
|
||||
is_policy_acknowledged = models.BooleanField(
|
||||
|
|
|
@ -84,7 +84,7 @@ class TransitionDomain(TimeStampedModel):
|
|||
middle_name = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Middle name",
|
||||
help_text="Middle name (optional)",
|
||||
)
|
||||
last_name = models.TextField(
|
||||
null=True,
|
||||
|
|
|
@ -2,6 +2,7 @@ import re
|
|||
|
||||
from api.views import check_domain_available
|
||||
from registrar.utility import errors
|
||||
from epplibwrapper.errors import RegistryError
|
||||
|
||||
|
||||
class DomainHelper:
|
||||
|
@ -29,19 +30,19 @@ class DomainHelper:
|
|||
if not isinstance(domain, str):
|
||||
raise ValueError("Domain name must be a string")
|
||||
domain = domain.lower().strip()
|
||||
if domain == "":
|
||||
if blank_ok:
|
||||
return domain
|
||||
else:
|
||||
raise errors.BlankValueError()
|
||||
if domain == "" and not blank_ok:
|
||||
raise errors.BlankValueError()
|
||||
if domain.endswith(".gov"):
|
||||
domain = domain[:-4]
|
||||
if "." in domain:
|
||||
raise errors.ExtraDotsError()
|
||||
if not DomainHelper.string_could_be_domain(domain + ".gov"):
|
||||
raise ValueError()
|
||||
if not check_domain_available(domain):
|
||||
raise errors.DomainUnavailableError()
|
||||
try:
|
||||
if not check_domain_available(domain):
|
||||
raise errors.DomainUnavailableError()
|
||||
except RegistryError as err:
|
||||
raise errors.RegistrySystemError() from err
|
||||
return domain
|
||||
|
||||
@classmethod
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
<link rel="apple-touch-icon" size="180x180"
|
||||
href="{% static 'img/registrar/favicons/favicon-180.png' %}"
|
||||
>
|
||||
<script type="application/javascript" src="{% static 'js/get-gov-admin.js' %}" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block form_required_fields_help_text %}
|
||||
<p class="text-semibold"><abbr class="usa-hint usa-hint--required" title="required">*</abbr>This question is required.</p>
|
||||
{# commented out so it does not appear on this page #}
|
||||
{% endblock %}
|
||||
|
||||
{% block form_fields %}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
{% load field_helpers %}
|
||||
|
||||
{% block form_instructions %}
|
||||
<p>Is there anything else we should know about your domain request?</p>
|
||||
<p>Is there anything else you'd like us to know about your domain request? This question is optional.</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block form_required_fields_help_text %}
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
{% block form_instructions %}
|
||||
<p>Enter your organization’s current public website, if you have one. For example,
|
||||
www.city.com. We can better evaluate your domain request if we know about domains
|
||||
you’re already using. If you already have any .gov domains please include them.</p>
|
||||
you’re already using. If you already have any .gov domains please include them. This question is optional.</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block form_required_fields_help_text %}
|
||||
|
|
|
@ -38,8 +38,7 @@
|
|||
|
||||
<fieldset class="usa-fieldset margin-top-2">
|
||||
<legend>
|
||||
<h2>What .gov domain do you want? <abbr class="usa-hint usa-hint--required
|
||||
text-super" title="required">*</abbr></h2>
|
||||
<h2>What .gov domain do you want?</h2>
|
||||
</legend>
|
||||
|
||||
<p id="domain_instructions" class="margin-top-05">After you enter your domain, we’ll make sure it’s
|
||||
|
@ -47,8 +46,6 @@
|
|||
these initial checks, we’ll verify that it meets all of our requirements once you
|
||||
complete and submit the rest of this form.</p>
|
||||
|
||||
<p class="text-semibold"><abbr class="usa-hint usa-hint--required" title="required">*</abbr> This question is required.</p>
|
||||
|
||||
{% with attr_aria_describedby="domain_instructions domain_instructions2" %}
|
||||
{# attr_validate / validate="domain" invokes code in get-gov.js #}
|
||||
{% with append_gov=True attr_validate="domain" add_label_class="usa-sr-only" %}
|
||||
|
@ -66,11 +63,11 @@
|
|||
|
||||
<fieldset class="usa-fieldset margin-top-1">
|
||||
<legend>
|
||||
<h2>Alternative domains</h2>
|
||||
<h2>Alternative domains (optional)</h2>
|
||||
</legend>
|
||||
|
||||
<p id="alt_domain_instructions" class="margin-top-05">Are there other domains you’d like if we can’t give
|
||||
you your first choice? Entering alternative domains is optional.</p>
|
||||
you your first choice?</p>
|
||||
|
||||
{% with attr_aria_describedby="alt_domain_instructions" %}
|
||||
{# attr_validate / validate="domain" invokes code in get-gov.js #}
|
||||
|
|
|
@ -22,6 +22,14 @@
|
|||
{% include "includes/form_messages.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% if pending_requests_message %}
|
||||
<div class="usa-alert usa-alert--info margin-bottom-3">
|
||||
<div class="usa-alert__body">
|
||||
{{ pending_requests_message }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block form_errors %}
|
||||
{% comment %}
|
||||
to make sense of this loop, consider that
|
||||
|
@ -48,9 +56,12 @@
|
|||
{% block form_instructions %}
|
||||
{% endblock %}
|
||||
|
||||
{% block form_required_fields_help_text %}
|
||||
{% include "includes/required_fields.html" %}
|
||||
{% endblock %}
|
||||
<!-- The "No other employees from your organization?" page is a one-field form and should not have the required fields sentence -->
|
||||
{% if steps.current != "no_other_contacts" %}
|
||||
{% block form_required_fields_help_text %}
|
||||
{% include "includes/required_fields.html" %}
|
||||
{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
<form id="step__{{steps.current}}" class="usa-form usa-form--large" method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
@ -66,6 +77,13 @@
|
|||
value="next"
|
||||
class="usa-button"
|
||||
>Save and continue</button>
|
||||
{% elif pending_requests_exist %}
|
||||
<button
|
||||
type="submit"
|
||||
name="submit_button"
|
||||
value="save_and_return"
|
||||
class="usa-button usa-button--outline"
|
||||
>Save and return to manage your domains</button>
|
||||
{% else %}
|
||||
<button
|
||||
type="submit"
|
||||
|
|
|
@ -2,9 +2,7 @@
|
|||
{% load field_helpers %}
|
||||
|
||||
{% block form_instructions %}
|
||||
<h2 class="margin-bottom-05">
|
||||
Is your organization an election office? <abbr class="usa-hint usa-hint--required" title="required">*</abbr>
|
||||
</h2>
|
||||
<h2 class="margin-bottom-05">Is your organization an election office?</h2>
|
||||
|
||||
<p>An election office is a government entity whose <em>primary</em> responsibility is overseeing elections and/or conducting voter registration.</p>
|
||||
|
||||
|
@ -13,7 +11,7 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block form_required_fields_help_text %}
|
||||
<p class="text-semibold"><abbr class="usa-hint usa-hint--required" title="required">*</abbr> This question is required.</p>
|
||||
{# commented out so it does not appear on this page #}
|
||||
{% endblock %}
|
||||
|
||||
{% block form_fields %}
|
||||
|
|
|
@ -3,15 +3,14 @@
|
|||
|
||||
{% block form_instructions %}
|
||||
<h2 class="margin-bottom-05">
|
||||
Which federal branch is your organization in? <abbr class="usa-hint usa-hint--required text-super" title="required">*</abbr>
|
||||
Which federal branch is your organization in?
|
||||
</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block form_required_fields_help_text %}
|
||||
<p class="text-semibold"><abbr class="usa-hint usa-hint--required" title="required">*</abbr> This question is required.</p>
|
||||
{# commented out so it does not appear on this page #}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block form_fields %}
|
||||
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
|
||||
{% input_with_errors forms.0.federal_type %}
|
||||
|
|
|
@ -3,16 +3,15 @@
|
|||
|
||||
{% block form_instructions %}
|
||||
<h2 class="margin-bottom-05">
|
||||
What kind of U.S.-based government organization do you represent? <abbr class="usa-hint usa-hint--required text-super" title="required">*</abbr>
|
||||
What kind of U.S.-based government organization do you represent?
|
||||
</h2>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block form_required_fields_help_text %}
|
||||
<p class="text-semibold"><abbr class="usa-hint usa-hint--required" title="required">*</abbr> This question is required.</p>
|
||||
{# commented out so it does not appear on this page #}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block form_fields %}
|
||||
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
|
||||
{% input_with_errors forms.0.organization_type %}
|
||||
|
|
|
@ -13,18 +13,16 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block form_required_fields_help_text %}
|
||||
{# there are no required fields on this page so don't show this #}
|
||||
{% include "includes/required_fields.html" %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block form_fields %}
|
||||
{{ forms.0.management_form }}
|
||||
{# forms.0 is a formset and this iterates over its forms #}
|
||||
{% for form in forms.0.forms %}
|
||||
<fieldset class="usa-fieldset">
|
||||
<legend>
|
||||
<h2>Organization contact {{ forloop.counter }}</h2>
|
||||
<h2>Organization contact {{ forloop.counter }} (optional)</h2>
|
||||
</legend>
|
||||
|
||||
{% input_with_errors form.first_name %}
|
||||
|
|
|
@ -13,11 +13,9 @@ Read about <a class="usa-link" rel="noopener noreferrer" target="_blank" href="{
|
|||
{% endblock %}
|
||||
|
||||
{% block form_required_fields_help_text %}
|
||||
<p class="text-semibold"><abbr class="usa-hint usa-hint--required" title="required">*</abbr> This question is required.</p>
|
||||
{# commented out so it does not appear on this page #}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block form_fields %}
|
||||
{% with attr_maxlength=1000 add_label_class="usa-sr-only" %}
|
||||
{% input_with_errors forms.0.purpose %}
|
||||
|
|
|
@ -50,6 +50,10 @@
|
|||
<p>We understand the critical importance of the availability of .gov domains. Suspending or terminating a .gov domain is reserved for prolonged, unresolved, serious violations where the registrant is non-responsive. We'll make extensive efforts to contact registrants and to identify potential solutions. We'll make reasonable accommodations for remediation timelines based on the severity of the issue.</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block form_required_fields_help_text %}
|
||||
{# commented out so it does not appear on this page #}
|
||||
{% endblock %}
|
||||
|
||||
{% block form_fields %}
|
||||
<fieldset class="usa-fieldset">
|
||||
<legend>
|
||||
|
|
|
@ -111,7 +111,7 @@
|
|||
|
||||
{% include "includes/summary_item.html" with title='Other employees from your organization' value=domainapplication.other_contacts.all contact='true' list='true' heading_level=heading_level %}
|
||||
|
||||
{% include "includes/summary_item.html" with title='Anything else we should know' value=domainapplication.anything_else|default:"No" heading_level=heading_level %}
|
||||
{% include "includes/summary_item.html" with title='Anything else?' value=domainapplication.anything_else|default:"No" heading_level=heading_level %}
|
||||
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
|
|
@ -1,11 +1,6 @@
|
|||
{% extends 'admin/change_form.html' %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block extrahead %}
|
||||
{{ block.super }}
|
||||
<script type="application/javascript" src="{% static 'js/get-gov-admin.js' %}" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block field_sets %}
|
||||
<div class="submit-row">
|
||||
<input id="manageDomainSubmitButton" type="submit" value="Manage domain" name="_edit_domain">
|
||||
|
|
|
@ -7,7 +7,13 @@
|
|||
{% else %}
|
||||
{{ field.label }}
|
||||
{% endif %}
|
||||
|
||||
{% if widget.attrs.required %}
|
||||
<abbr class="usa-hint usa-hint--required" title="required">*</abbr>
|
||||
<!--Don't add asterisk to one-field forms -->
|
||||
{% if field.label == "Is your organization an election office?" or field.label == "What .gov domain do you want?" or field.label == "I read and agree to the requirements for operating .gov domains." or field.label == "Please explain why there are no other employees from your organization we can contact to help us assess your eligibility for a .gov domain." %}
|
||||
{% else %}
|
||||
<abbr class="usa-hint usa-hint--required" title="required">*</abbr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
</{{ label_tag }}>
|
||||
|
|
|
@ -40,9 +40,9 @@
|
|||
>
|
||||
{% else %}
|
||||
<div id="enable-dnssec">
|
||||
<div class="usa-alert usa-alert--info usa-alert--slim">
|
||||
<div class="usa-alert usa-alert--info">
|
||||
<div class="usa-alert__body">
|
||||
It is strongly recommended that you only enable DNSSEC if you know how to set it up properly at your hosting service. If you make a mistake, it could cause your domain name to stop working.
|
||||
<p class="margin-y-0">It is strongly recommended that you only enable DNSSEC if you know how to set it up properly at your hosting service. If you make a mistake, it could cause your domain name to stop working.</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{% url 'domain-dns-dnssec-dsdata' pk=domain.id %}" class="usa-button">Enable DNSSEC</a>
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
|
||||
<p>Add a name server record by entering the address (e.g., ns1.nameserver.com) in the name server fields below. You must add at least two name servers (13 max).</p>
|
||||
|
||||
<div class="usa-alert usa-alert--slim usa-alert--info">
|
||||
<div class="usa-alert usa-alert--info">
|
||||
<div class="usa-alert__body">
|
||||
<p class="margin-top-0">Add an IP address only when your name server's address includes your domain name (e.g., if your domain name is “example.gov” and your name server is “ns1.example.gov,” then an IP address is required). Multiple IP addresses must be separated with commas.</p>
|
||||
<p class="margin-bottom-0">This step is uncommon unless you self-host your DNS or use custom addresses for your nameserver.</p>
|
||||
|
|
|
@ -34,6 +34,6 @@ Other employees from your organization:
|
|||
{% for other in application.other_contacts.all %}
|
||||
{% spaceless %}{% include "emails/includes/contact.txt" with contact=other %}{% endspaceless %}
|
||||
{% endfor %}{% endif %}{% if application.anything_else %}
|
||||
Anything else we should know?
|
||||
Anything else?
|
||||
{{ application.anything_else }}
|
||||
{% endif %}
|
|
@ -1,3 +1,3 @@
|
|||
<p class="margin-top-3">
|
||||
Required fields are marked with an asterisk (<abbr class="usa-hint usa-hint--required" title="required">*</abbr>).
|
||||
<em>Required fields are marked with an asterisk (<abbr class="usa-hint usa-hint--required" title="required">*</abbr>).</em>
|
||||
</p>
|
||||
|
|
3
src/registrar/tests/data/fake_current_federal.csv
Normal file
3
src/registrar/tests/data/fake_current_federal.csv
Normal 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,,,,
|
|
4
src/registrar/tests/data/fake_current_full.csv
Normal file
4
src/registrar/tests/data/fake_current_full.csv
Normal 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,,,,,
|
|
|
@ -13,6 +13,7 @@ from registrar.admin import (
|
|||
MyUserAdmin,
|
||||
AuditedAdmin,
|
||||
ContactAdmin,
|
||||
UserDomainRoleAdmin,
|
||||
)
|
||||
from registrar.models import (
|
||||
Domain,
|
||||
|
@ -21,6 +22,7 @@ from registrar.models import (
|
|||
User,
|
||||
DomainInvitation,
|
||||
)
|
||||
from registrar.models.user_domain_role import UserDomainRole
|
||||
from .common import (
|
||||
completed_application,
|
||||
generic_domain_object,
|
||||
|
@ -886,6 +888,86 @@ class DomainInvitationAdminTest(TestCase):
|
|||
self.assertContains(response, retrieved_html, count=1)
|
||||
|
||||
|
||||
class UserDomainRoleAdminTest(TestCase):
|
||||
def setUp(self):
|
||||
"""Setup environment for a mock admin user"""
|
||||
self.site = AdminSite()
|
||||
self.factory = RequestFactory()
|
||||
self.admin = ListHeaderAdmin(model=UserDomainRoleAdmin, admin_site=None)
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
self.superuser = create_superuser()
|
||||
|
||||
def tearDown(self):
|
||||
"""Delete all Users, Domains, and UserDomainRoles"""
|
||||
User.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
UserDomainRole.objects.all().delete()
|
||||
|
||||
def test_email_not_in_search(self):
|
||||
"""Tests the search bar in Django Admin for UserDomainRoleAdmin.
|
||||
Should return no results for an invalid email."""
|
||||
# Have to get creative to get past linter
|
||||
p = "adminpass"
|
||||
self.client.login(username="superuser", password=p)
|
||||
|
||||
fake_user = User.objects.create(
|
||||
username="dummyuser", first_name="Stewart", last_name="Jones", email="AntarcticPolarBears@example.com"
|
||||
)
|
||||
fake_domain = Domain.objects.create(name="test123")
|
||||
UserDomainRole.objects.create(user=fake_user, domain=fake_domain, role="manager")
|
||||
# Make the request using the Client class
|
||||
# which handles CSRF
|
||||
# Follow=True handles the redirect
|
||||
response = self.client.get(
|
||||
"/admin/registrar/userdomainrole/",
|
||||
{
|
||||
"q": "testmail@igorville.com",
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Assert that the query is added to the extra_context
|
||||
self.assertIn("search_query", response.context)
|
||||
# Assert the content of filters and search_query
|
||||
search_query = response.context["search_query"]
|
||||
self.assertEqual(search_query, "testmail@igorville.com")
|
||||
|
||||
# We only need to check for the end of the HTML string
|
||||
self.assertNotContains(response, "Stewart Jones AntarcticPolarBears@example.com</a></th>")
|
||||
|
||||
def test_email_in_search(self):
|
||||
"""Tests the search bar in Django Admin for UserDomainRoleAdmin.
|
||||
Should return results for an valid email."""
|
||||
# Have to get creative to get past linter
|
||||
p = "adminpass"
|
||||
self.client.login(username="superuser", password=p)
|
||||
|
||||
fake_user = User.objects.create(
|
||||
username="dummyuser", first_name="Joe", last_name="Jones", email="AntarcticPolarBears@example.com"
|
||||
)
|
||||
fake_domain = Domain.objects.create(name="fake")
|
||||
UserDomainRole.objects.create(user=fake_user, domain=fake_domain, role="manager")
|
||||
# Make the request using the Client class
|
||||
# which handles CSRF
|
||||
# Follow=True handles the redirect
|
||||
response = self.client.get(
|
||||
"/admin/registrar/userdomainrole/",
|
||||
{
|
||||
"q": "AntarcticPolarBears@example.com",
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Assert that the query is added to the extra_context
|
||||
self.assertIn("search_query", response.context)
|
||||
|
||||
search_query = response.context["search_query"]
|
||||
self.assertEqual(search_query, "AntarcticPolarBears@example.com")
|
||||
|
||||
# We only need to check for the end of the HTML string
|
||||
self.assertContains(response, "Joe Jones AntarcticPolarBears@example.com</a></th>", count=1)
|
||||
|
||||
|
||||
class ListHeaderAdminTest(TestCase):
|
||||
def setUp(self):
|
||||
self.site = AdminSite()
|
||||
|
|
|
@ -158,7 +158,7 @@ class TestEmails(TestCase):
|
|||
_, kwargs = self.mock_client.send_email.call_args
|
||||
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
|
||||
# spacing should be right between adjacent elements
|
||||
self.assertRegex(body, r"5557\n\nAnything else we should know?")
|
||||
self.assertRegex(body, r"5557\n\nAnything else?")
|
||||
|
||||
@boto3_mocking.patching
|
||||
def test_submission_confirmation_no_anything_else_spacing(self):
|
||||
|
@ -168,6 +168,6 @@ class TestEmails(TestCase):
|
|||
application.submit()
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
|
||||
self.assertNotIn("Anything else we should know", body)
|
||||
self.assertNotIn("Anything else", body)
|
||||
# spacing should be right between adjacent elements
|
||||
self.assertRegex(body, r"5557\n\n----")
|
||||
|
|
|
@ -1,11 +1,219 @@
|
|||
from django.test import TestCase
|
||||
from io import StringIO
|
||||
import csv
|
||||
import io
|
||||
from django.test import Client, RequestFactory, TestCase
|
||||
from io import StringIO
|
||||
from registrar.models.domain_information import DomainInformation
|
||||
from registrar.models.domain import Domain
|
||||
from registrar.models.user import User
|
||||
from django.contrib.auth import get_user_model
|
||||
from registrar.utility.csv_export import export_domains_to_writer
|
||||
from django.core.management import call_command
|
||||
from unittest.mock import MagicMock, call, mock_open, patch
|
||||
from api.views import get_current_federal, get_current_full
|
||||
from django.conf import settings
|
||||
from botocore.exceptions import ClientError
|
||||
import boto3_mocking
|
||||
from registrar.utility.s3_bucket import S3ClientError, S3ClientErrorCodes # type: ignore
|
||||
|
||||
|
||||
class CsvReportsTest(TestCase):
|
||||
"""Tests to determine if we are uploading our reports correctly"""
|
||||
|
||||
def setUp(self):
|
||||
"""Create fake domain data"""
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
self.factory = RequestFactory()
|
||||
username = "test_user"
|
||||
first_name = "First"
|
||||
last_name = "Last"
|
||||
email = "info@example.com"
|
||||
self.user = get_user_model().objects.create(
|
||||
username=username, first_name=first_name, last_name=last_name, email=email
|
||||
)
|
||||
|
||||
self.domain_1, _ = Domain.objects.get_or_create(name="cdomain1.gov", state=Domain.State.READY)
|
||||
self.domain_2, _ = Domain.objects.get_or_create(name="adomain2.gov", state=Domain.State.DNS_NEEDED)
|
||||
self.domain_3, _ = Domain.objects.get_or_create(name="ddomain3.gov", state=Domain.State.ON_HOLD)
|
||||
self.domain_4, _ = Domain.objects.get_or_create(name="bdomain4.gov", state=Domain.State.UNKNOWN)
|
||||
self.domain_4, _ = Domain.objects.get_or_create(name="bdomain4.gov", state=Domain.State.UNKNOWN)
|
||||
|
||||
self.domain_information_1, _ = DomainInformation.objects.get_or_create(
|
||||
creator=self.user,
|
||||
domain=self.domain_1,
|
||||
organization_type="federal",
|
||||
federal_agency="World War I Centennial Commission",
|
||||
federal_type="executive",
|
||||
)
|
||||
self.domain_information_2, _ = DomainInformation.objects.get_or_create(
|
||||
creator=self.user,
|
||||
domain=self.domain_2,
|
||||
organization_type="interstate",
|
||||
)
|
||||
self.domain_information_3, _ = DomainInformation.objects.get_or_create(
|
||||
creator=self.user,
|
||||
domain=self.domain_3,
|
||||
organization_type="federal",
|
||||
federal_agency="Armed Forces Retirement Home",
|
||||
)
|
||||
self.domain_information_4, _ = DomainInformation.objects.get_or_create(
|
||||
creator=self.user,
|
||||
domain=self.domain_4,
|
||||
organization_type="federal",
|
||||
federal_agency="Armed Forces Retirement Home",
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
"""Delete all faked data"""
|
||||
Domain.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
super().tearDown()
|
||||
|
||||
@boto3_mocking.patching
|
||||
def test_generate_federal_report(self):
|
||||
"""Ensures that we correctly generate current-federal.csv"""
|
||||
mock_client = MagicMock()
|
||||
fake_open = mock_open()
|
||||
expected_file_content = [
|
||||
call("Domain name,Domain type,Agency,Organization name,City,State,Security Contact Email\r\n"),
|
||||
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,, \r\n"),
|
||||
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,, \r\n"),
|
||||
]
|
||||
# We don't actually want to write anything for a test case,
|
||||
# we just want to verify what is being written.
|
||||
with boto3_mocking.clients.handler_for("s3", mock_client):
|
||||
with patch("builtins.open", fake_open):
|
||||
call_command("generate_current_federal_report", checkpath=False)
|
||||
content = fake_open()
|
||||
|
||||
content.write.assert_has_calls(expected_file_content)
|
||||
|
||||
@boto3_mocking.patching
|
||||
def test_generate_full_report(self):
|
||||
"""Ensures that we correctly generate current-full.csv"""
|
||||
mock_client = MagicMock()
|
||||
fake_open = mock_open()
|
||||
expected_file_content = [
|
||||
call("Domain name,Domain type,Agency,Organization name,City,State,Security Contact Email\r\n"),
|
||||
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,, \r\n"),
|
||||
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,, \r\n"),
|
||||
call("adomain2.gov,Interstate,,,,, \r\n"),
|
||||
]
|
||||
# We don't actually want to write anything for a test case,
|
||||
# we just want to verify what is being written.
|
||||
with boto3_mocking.clients.handler_for("s3", mock_client):
|
||||
with patch("builtins.open", fake_open):
|
||||
call_command("generate_current_full_report", checkpath=False)
|
||||
content = fake_open()
|
||||
|
||||
content.write.assert_has_calls(expected_file_content)
|
||||
|
||||
@boto3_mocking.patching
|
||||
def test_not_found_full_report(self):
|
||||
"""Ensures that we get a not found when the report doesn't exist"""
|
||||
|
||||
def side_effect(Bucket, Key):
|
||||
raise ClientError({"Error": {"Code": "NoSuchKey", "Message": "No such key"}}, "get_object")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_object.side_effect = side_effect
|
||||
|
||||
response = None
|
||||
with boto3_mocking.clients.handler_for("s3", mock_client):
|
||||
with patch("boto3.client", return_value=mock_client):
|
||||
with self.assertRaises(S3ClientError) as context:
|
||||
response = self.client.get("/api/v1/get-report/current-full")
|
||||
# Check that the response has status code 500
|
||||
self.assertEqual(response.status_code, 500)
|
||||
|
||||
# Check that we get the right error back from the page
|
||||
self.assertEqual(context.exception.code, S3ClientErrorCodes.FILE_NOT_FOUND_ERROR)
|
||||
|
||||
@boto3_mocking.patching
|
||||
def test_not_found_federal_report(self):
|
||||
"""Ensures that we get a not found when the report doesn't exist"""
|
||||
|
||||
def side_effect(Bucket, Key):
|
||||
raise ClientError({"Error": {"Code": "NoSuchKey", "Message": "No such key"}}, "get_object")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_object.side_effect = side_effect
|
||||
|
||||
with boto3_mocking.clients.handler_for("s3", mock_client):
|
||||
with patch("boto3.client", return_value=mock_client):
|
||||
with self.assertRaises(S3ClientError) as context:
|
||||
response = self.client.get("/api/v1/get-report/current-federal")
|
||||
# Check that the response has status code 500
|
||||
self.assertEqual(response.status_code, 500)
|
||||
|
||||
# Check that we get the right error back from the page
|
||||
self.assertEqual(context.exception.code, S3ClientErrorCodes.FILE_NOT_FOUND_ERROR)
|
||||
|
||||
@boto3_mocking.patching
|
||||
def test_load_federal_report(self):
|
||||
"""Tests the get_current_federal api endpoint"""
|
||||
self.maxDiff = None
|
||||
mock_client = MagicMock()
|
||||
mock_client_instance = mock_client.return_value
|
||||
|
||||
with open("registrar/tests/data/fake_current_federal.csv", "r") as file:
|
||||
file_content = file.read()
|
||||
|
||||
# Mock a recieved file
|
||||
mock_client_instance.get_object.return_value = {"Body": io.BytesIO(file_content.encode())}
|
||||
with boto3_mocking.clients.handler_for("s3", mock_client):
|
||||
request = self.factory.get("/fake-path")
|
||||
response = get_current_federal(request)
|
||||
|
||||
# Check that we are sending the correct calls.
|
||||
# Ensures that we are decoding the file content recieved from AWS.
|
||||
expected_call = [call.get_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key="current-federal.csv")]
|
||||
mock_client_instance.assert_has_calls(expected_call)
|
||||
|
||||
# Check that the response has status code 200
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Check that the response contains what we expect
|
||||
expected_file_content = (
|
||||
"Domain name,Domain type,Agency,Organization name,City,State,Security Contact Email\n"
|
||||
"cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,\n"
|
||||
"ddomain3.gov,Federal,Armed Forces Retirement Home,,,,"
|
||||
).encode()
|
||||
|
||||
self.assertEqual(expected_file_content, response.content)
|
||||
|
||||
@boto3_mocking.patching
|
||||
def test_load_full_report(self):
|
||||
"""Tests the current-federal api link"""
|
||||
mock_client = MagicMock()
|
||||
mock_client_instance = mock_client.return_value
|
||||
|
||||
with open("registrar/tests/data/fake_current_full.csv", "r") as file:
|
||||
file_content = file.read()
|
||||
|
||||
# Mock a recieved file
|
||||
mock_client_instance.get_object.return_value = {"Body": io.BytesIO(file_content.encode())}
|
||||
with boto3_mocking.clients.handler_for("s3", mock_client):
|
||||
request = self.factory.get("/fake-path")
|
||||
response = get_current_full(request)
|
||||
|
||||
# Check that we are sending the correct calls.
|
||||
# Ensures that we are decoding the file content recieved from AWS.
|
||||
expected_call = [call.get_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key="current-full.csv")]
|
||||
mock_client_instance.assert_has_calls(expected_call)
|
||||
|
||||
# Check that the response has status code 200
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Check that the response contains what we expect
|
||||
expected_file_content = (
|
||||
"Domain name,Domain type,Agency,Organization name,City,State,Security Contact Email\n"
|
||||
"cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,\n"
|
||||
"ddomain3.gov,Federal,Armed Forces Retirement Home,,,,\n"
|
||||
"adomain2.gov,Interstate,,,,,"
|
||||
).encode()
|
||||
|
||||
self.assertEqual(expected_file_content, response.content)
|
||||
|
||||
|
||||
class ExportDataTest(TestCase):
|
||||
|
|
|
@ -111,6 +111,8 @@ class TestURLAuth(TestCase):
|
|||
"/openid/callback/login/",
|
||||
"/openid/callback/logout/",
|
||||
"/api/v1/available/whitehouse.gov",
|
||||
"/api/v1/get-report/current-federal",
|
||||
"/api/v1/get-report/current-full",
|
||||
]
|
||||
|
||||
def assertURLIsProtectedByAuth(self, url):
|
||||
|
|
|
@ -144,6 +144,18 @@ class DomainApplicationTests(TestWithUser, WebTest):
|
|||
result = page.form.submit()
|
||||
self.assertIn("What kind of U.S.-based government organization do you represent?", result)
|
||||
|
||||
def test_application_multiple_applications_exist(self):
|
||||
"""Test that an info message appears when user has multiple applications already"""
|
||||
# create and submit an application
|
||||
application = completed_application(user=self.user)
|
||||
application.submit()
|
||||
application.save()
|
||||
|
||||
# now, attempt to create another one
|
||||
with less_console_noise():
|
||||
page = self.app.get("/register/").follow()
|
||||
self.assertContains(page, "You cannot submit this request yet")
|
||||
|
||||
@boto3_mocking.patching
|
||||
def test_application_form_submission(self):
|
||||
"""
|
||||
|
|
|
@ -13,6 +13,10 @@ class DomainUnavailableError(ValueError):
|
|||
pass
|
||||
|
||||
|
||||
class RegistrySystemError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class ActionNotAllowed(Exception):
|
||||
"""User accessed an action that is not
|
||||
allowed by the current state"""
|
||||
|
@ -42,7 +46,7 @@ class GenericError(Exception):
|
|||
GenericErrorCodes.CANNOT_CONTACT_REGISTRY: """
|
||||
We’re experiencing a system connection error. Please wait a few minutes
|
||||
and try again. If you continue to receive this error after a few tries,
|
||||
contact help@get.gov
|
||||
contact help@get.gov.
|
||||
""",
|
||||
GenericErrorCodes.GENERIC_ERROR: ("Value entered was wrong."),
|
||||
}
|
||||
|
@ -56,6 +60,10 @@ contact help@get.gov
|
|||
def __str__(self):
|
||||
return f"{self.message}"
|
||||
|
||||
@classmethod
|
||||
def get_error_message(self, code=None):
|
||||
return self._error_mapping.get(code)
|
||||
|
||||
|
||||
class NameserverErrorCodes(IntEnum):
|
||||
"""Used in the NameserverError class for
|
||||
|
|
149
src/registrar/utility/s3_bucket.py
Normal file
149
src/registrar/utility/s3_bucket.py
Normal 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
|
|
@ -3,6 +3,7 @@ import logging
|
|||
from django.http import Http404, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import resolve, reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import TemplateView
|
||||
from django.contrib import messages
|
||||
|
@ -85,7 +86,7 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
|
|||
Step.YOUR_CONTACT: _("Your contact information"),
|
||||
Step.OTHER_CONTACTS: _("Other employees from your organization"),
|
||||
Step.NO_OTHER_CONTACTS: _("No other employees from your organization?"),
|
||||
Step.ANYTHING_ELSE: _("Anything else we should know?"),
|
||||
Step.ANYTHING_ELSE: _("Anything else?"),
|
||||
Step.REQUIREMENTS: _("Requirements for operating .gov domains"),
|
||||
Step.REVIEW: _("Review and submit your domain request"),
|
||||
}
|
||||
|
@ -218,6 +219,23 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
|
|||
self.steps.current = current_url
|
||||
context = self.get_context_data()
|
||||
context["forms"] = self.get_forms()
|
||||
|
||||
# if pending requests exist and user does not have approved domains,
|
||||
# present message that domain application cannot be submitted
|
||||
pending_requests = self.pending_requests()
|
||||
if len(pending_requests) > 0:
|
||||
message_header = "You cannot submit this request yet"
|
||||
message_content = (
|
||||
f"<h4 class='usa-alert__heading'>{message_header}</h4> "
|
||||
"<p class='margin-bottom-0'>New domain requests cannot be submitted until we have finished "
|
||||
f"reviewing your pending request: <strong>{pending_requests[0].requested_domain}</strong>. "
|
||||
"You can continue to fill out this request and save it as a draft to be submitted later. "
|
||||
f"<a class='usa-link' href='{reverse('home')}'>View your pending requests.</a></p>"
|
||||
)
|
||||
context["pending_requests_message"] = mark_safe(message_content) # nosec
|
||||
|
||||
context["pending_requests_exist"] = len(pending_requests) > 0
|
||||
|
||||
return render(request, self.template_name, context)
|
||||
|
||||
def get_all_forms(self, **kwargs) -> list:
|
||||
|
@ -266,6 +284,37 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
|
|||
|
||||
return instantiated
|
||||
|
||||
def pending_requests(self):
|
||||
"""return an array of pending requests if user has pending requests
|
||||
and no approved requests"""
|
||||
if self.approved_applications_exist() or self.approved_domains_exist():
|
||||
return []
|
||||
else:
|
||||
return self.pending_applications()
|
||||
|
||||
def approved_applications_exist(self):
|
||||
"""Checks if user is creator of applications with APPROVED status"""
|
||||
approved_application_count = DomainApplication.objects.filter(
|
||||
creator=self.request.user, status=DomainApplication.APPROVED
|
||||
).count()
|
||||
return approved_application_count > 0
|
||||
|
||||
def approved_domains_exist(self):
|
||||
"""Checks if user has permissions on approved domains
|
||||
|
||||
This additional check is necessary to account for domains which were migrated
|
||||
and do not have an application"""
|
||||
return self.request.user.permissions.count() > 0
|
||||
|
||||
def pending_applications(self):
|
||||
"""Returns a List of user's applications with one of the following states:
|
||||
SUBMITTED, IN_REVIEW, ACTION_NEEDED"""
|
||||
# if the current application has ACTION_NEEDED status, this check should not be performed
|
||||
if self.application.status == DomainApplication.ACTION_NEEDED:
|
||||
return []
|
||||
check_statuses = [DomainApplication.SUBMITTED, DomainApplication.IN_REVIEW, DomainApplication.ACTION_NEEDED]
|
||||
return DomainApplication.objects.filter(creator=self.request.user, status__in=check_statuses)
|
||||
|
||||
def get_context_data(self):
|
||||
"""Define context for access on all wizard pages."""
|
||||
return {
|
||||
|
@ -328,6 +377,10 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
|
|||
if button == "save":
|
||||
messages.success(request, "Your progress has been saved!")
|
||||
return self.goto(self.steps.current)
|
||||
# if user opted to save progress and return,
|
||||
# return them to the home page
|
||||
if button == "save_and_return":
|
||||
return HttpResponseRedirect(reverse("home"))
|
||||
# otherwise, proceed as normal
|
||||
return self.goto_next_step()
|
||||
|
||||
|
|
|
@ -280,6 +280,7 @@ class DomainNameserversView(DomainFormBaseView):
|
|||
form.fields["server"].required = True
|
||||
else:
|
||||
form.fields["server"].required = False
|
||||
form.fields["server"].label += " (optional)"
|
||||
form.fields["domain"].initial = self.object.name
|
||||
return formset
|
||||
|
||||
|
@ -643,7 +644,46 @@ class DomainAddUserView(DomainFormBaseView):
|
|||
"""Get an absolute URL for this domain."""
|
||||
return self.request.build_absolute_uri(reverse("domain", kwargs={"pk": self.object.id}))
|
||||
|
||||
def _make_invitation(self, email_address):
|
||||
def _send_domain_invitation_email(self, email: str, add_success=True):
|
||||
"""Performs the sending of the domain invitation email,
|
||||
does not make a domain information object
|
||||
email: string- email to send to
|
||||
add_success: bool- default True indicates:
|
||||
adding a success message to the view if the email sending succeeds"""
|
||||
# created a new invitation in the database, so send an email
|
||||
domainInfoResults = DomainInformation.objects.filter(domain=self.object)
|
||||
domainInfo = domainInfoResults.first()
|
||||
first = ""
|
||||
last = ""
|
||||
if domainInfo is not None:
|
||||
first = domainInfo.creator.first_name
|
||||
last = domainInfo.creator.last_name
|
||||
full_name = f"{first} {last}"
|
||||
|
||||
try:
|
||||
send_templated_email(
|
||||
"emails/domain_invitation.txt",
|
||||
"emails/domain_invitation_subject.txt",
|
||||
to_address=email,
|
||||
context={
|
||||
"domain_url": self._domain_abs_url(),
|
||||
"domain": self.object,
|
||||
"full_name": full_name,
|
||||
},
|
||||
)
|
||||
except EmailSendingError:
|
||||
messages.warning(self.request, "Could not send email invitation.")
|
||||
logger.warn(
|
||||
"Could not sent email invitation to %s for domain %s",
|
||||
email,
|
||||
self.object,
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
if add_success:
|
||||
messages.success(self.request, f"Invited {email} to this domain.")
|
||||
|
||||
def _make_invitation(self, email_address: str):
|
||||
"""Make a Domain invitation for this email and redirect with a message."""
|
||||
invitation, created = DomainInvitation.objects.get_or_create(email=email_address, domain=self.object)
|
||||
if not created:
|
||||
|
@ -653,34 +693,7 @@ class DomainAddUserView(DomainFormBaseView):
|
|||
f"{email_address} has already been invited to this domain.",
|
||||
)
|
||||
else:
|
||||
# created a new invitation in the database, so send an email
|
||||
domaininfo = DomainInformation.objects.filter(domain=self.object)
|
||||
first = domaininfo.first().creator.first_name
|
||||
last = domaininfo.first().creator.last_name
|
||||
full_name = f"{first} {last}"
|
||||
|
||||
try:
|
||||
send_templated_email(
|
||||
"emails/domain_invitation.txt",
|
||||
"emails/domain_invitation_subject.txt",
|
||||
to_address=email_address,
|
||||
context={
|
||||
"domain_url": self._domain_abs_url(),
|
||||
"domain": self.object,
|
||||
"full_name": full_name,
|
||||
},
|
||||
)
|
||||
except EmailSendingError:
|
||||
messages.warning(self.request, "Could not send email invitation.")
|
||||
logger.warn(
|
||||
"Could not sent email invitation to %s for domain %s",
|
||||
email_address,
|
||||
self.object,
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
messages.success(self.request, f"Invited {email_address} to this domain.")
|
||||
|
||||
self._send_domain_invitation_email(email=email_address)
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
def form_valid(self, form):
|
||||
|
@ -692,6 +705,9 @@ class DomainAddUserView(DomainFormBaseView):
|
|||
except User.DoesNotExist:
|
||||
# no matching user, go make an invitation
|
||||
return self._make_invitation(requested_email)
|
||||
else:
|
||||
# if user already exists then just send an email
|
||||
self._send_domain_invitation_email(requested_email, add_success=False)
|
||||
|
||||
try:
|
||||
UserDomainRole.objects.create(
|
||||
|
|
|
@ -64,7 +64,9 @@
|
|||
10038 OUTOFSCOPE http://app:8080/withdrawconfirmed
|
||||
10038 OUTOFSCOPE http://app:8080/dns
|
||||
10038 OUTOFSCOPE http://app:8080/dnssec
|
||||
10038 OUTOFSCOPE http://app:8080/dns/nameservers
|
||||
10038 OUTOFSCOPE http://app:8080/dns/dnssec
|
||||
10038 OUTOFSCOPE http://app:8080/dns/dnssec/dsdata
|
||||
# This URL always returns 404, so include it as well.
|
||||
10038 OUTOFSCOPE http://app:8080/todo
|
||||
# OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue