mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-05-17 18:09:25 +02:00
merged main to resolve merge conflicts
This commit is contained in:
commit
2d4a5bdd08
65 changed files with 3040 additions and 558 deletions
1
.github/workflows/deploy-sandbox.yaml
vendored
1
.github/workflows/deploy-sandbox.yaml
vendored
|
@ -27,6 +27,7 @@ jobs:
|
||||||
|| startsWith(github.head_ref, 'cb/')
|
|| startsWith(github.head_ref, 'cb/')
|
||||||
|| startsWith(github.head_ref, 'hotgov/')
|
|| startsWith(github.head_ref, 'hotgov/')
|
||||||
|| startsWith(github.head_ref, 'litterbox/')
|
|| startsWith(github.head_ref, 'litterbox/')
|
||||||
|
|| startsWith(github.head_ref, 'ag/')
|
||||||
outputs:
|
outputs:
|
||||||
environment: ${{ steps.var.outputs.environment}}
|
environment: ${{ steps.var.outputs.environment}}
|
||||||
runs-on: "ubuntu-latest"
|
runs-on: "ubuntu-latest"
|
||||||
|
|
1
.github/workflows/migrate.yaml
vendored
1
.github/workflows/migrate.yaml
vendored
|
@ -16,6 +16,7 @@ on:
|
||||||
- stable
|
- stable
|
||||||
- staging
|
- staging
|
||||||
- development
|
- development
|
||||||
|
- ag
|
||||||
- litterbox
|
- litterbox
|
||||||
- hotgov
|
- hotgov
|
||||||
- cb
|
- cb
|
||||||
|
|
1
.github/workflows/reset-db.yaml
vendored
1
.github/workflows/reset-db.yaml
vendored
|
@ -16,6 +16,7 @@ on:
|
||||||
options:
|
options:
|
||||||
- staging
|
- staging
|
||||||
- development
|
- development
|
||||||
|
- ag
|
||||||
- litterbox
|
- litterbox
|
||||||
- hotgov
|
- hotgov
|
||||||
- cb
|
- cb
|
||||||
|
|
2
.github/workflows/test.yaml
vendored
2
.github/workflows/test.yaml
vendored
|
@ -70,6 +70,6 @@ jobs:
|
||||||
- name: run pa11y
|
- name: run pa11y
|
||||||
working-directory: ./src
|
working-directory: ./src
|
||||||
run: |
|
run: |
|
||||||
sleep 10;
|
sleep 20;
|
||||||
npm i -g pa11y-ci
|
npm i -g pa11y-ci
|
||||||
pa11y-ci
|
pa11y-ci
|
||||||
|
|
32
ops/manifests/manifest-ag.yaml
Normal file
32
ops/manifests/manifest-ag.yaml
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
---
|
||||||
|
applications:
|
||||||
|
- name: getgov-ag
|
||||||
|
buildpacks:
|
||||||
|
- python_buildpack
|
||||||
|
path: ../../src
|
||||||
|
instances: 1
|
||||||
|
memory: 512M
|
||||||
|
stack: cflinuxfs4
|
||||||
|
timeout: 180
|
||||||
|
command: ./run.sh
|
||||||
|
health-check-type: http
|
||||||
|
health-check-http-endpoint: /health
|
||||||
|
health-check-invocation-timeout: 40
|
||||||
|
env:
|
||||||
|
# Send stdout and stderr straight to the terminal without buffering
|
||||||
|
PYTHONUNBUFFERED: yup
|
||||||
|
# Tell Django where to find its configuration
|
||||||
|
DJANGO_SETTINGS_MODULE: registrar.config.settings
|
||||||
|
# Tell Django where it is being hosted
|
||||||
|
DJANGO_BASE_URL: https://getgov-ag.app.cloud.gov
|
||||||
|
# Tell Django how much stuff to log
|
||||||
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# default public site location
|
||||||
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
|
# Flag to disable/enable features in prod environments
|
||||||
|
IS_PRODUCTION: False
|
||||||
|
routes:
|
||||||
|
- route: getgov-ag.app.cloud.gov
|
||||||
|
services:
|
||||||
|
- getgov-credentials
|
||||||
|
- getgov-ag-database
|
|
@ -33,6 +33,7 @@ from django.contrib.auth.forms import UserChangeForm, UsernameField
|
||||||
from django_admin_multiple_choice_list_filter.list_filters import MultipleChoiceListFilter
|
from django_admin_multiple_choice_list_filter.list_filters import MultipleChoiceListFilter
|
||||||
from import_export import resources
|
from import_export import resources
|
||||||
from import_export.admin import ImportExportModelAdmin
|
from import_export.admin import ImportExportModelAdmin
|
||||||
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
@ -217,6 +218,7 @@ class DomainRequestAdminForm(forms.ModelForm):
|
||||||
status = cleaned_data.get("status")
|
status = cleaned_data.get("status")
|
||||||
investigator = cleaned_data.get("investigator")
|
investigator = cleaned_data.get("investigator")
|
||||||
rejection_reason = cleaned_data.get("rejection_reason")
|
rejection_reason = cleaned_data.get("rejection_reason")
|
||||||
|
action_needed_reason = cleaned_data.get("action_needed_reason")
|
||||||
|
|
||||||
# Get the old status
|
# Get the old status
|
||||||
initial_status = self.initial.get("status", None)
|
initial_status = self.initial.get("status", None)
|
||||||
|
@ -240,6 +242,8 @@ class DomainRequestAdminForm(forms.ModelForm):
|
||||||
# If the status is rejected, a rejection reason must exist
|
# If the status is rejected, a rejection reason must exist
|
||||||
if status == DomainRequest.DomainRequestStatus.REJECTED:
|
if status == DomainRequest.DomainRequestStatus.REJECTED:
|
||||||
self._check_for_valid_rejection_reason(rejection_reason)
|
self._check_for_valid_rejection_reason(rejection_reason)
|
||||||
|
elif status == DomainRequest.DomainRequestStatus.ACTION_NEEDED:
|
||||||
|
self._check_for_valid_action_needed_reason(action_needed_reason)
|
||||||
|
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
|
@ -263,6 +267,18 @@ class DomainRequestAdminForm(forms.ModelForm):
|
||||||
|
|
||||||
return is_valid
|
return is_valid
|
||||||
|
|
||||||
|
def _check_for_valid_action_needed_reason(self, action_needed_reason) -> bool:
|
||||||
|
"""
|
||||||
|
Checks if the action_needed_reason field is not none.
|
||||||
|
Adds form errors on failure.
|
||||||
|
"""
|
||||||
|
is_valid = action_needed_reason is not None and action_needed_reason != ""
|
||||||
|
if not is_valid:
|
||||||
|
error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.NO_ACTION_NEEDED_REASON)
|
||||||
|
self.add_error("action_needed_reason", error_message)
|
||||||
|
|
||||||
|
return is_valid
|
||||||
|
|
||||||
def _check_for_valid_investigator(self, investigator) -> bool:
|
def _check_for_valid_investigator(self, investigator) -> bool:
|
||||||
"""
|
"""
|
||||||
Checks if the investigator field is not none, and is staff.
|
Checks if the investigator field is not none, and is staff.
|
||||||
|
@ -1166,6 +1182,8 @@ class DomainInvitationAdmin(ListHeaderAdmin):
|
||||||
# error.
|
# error.
|
||||||
readonly_fields = ["status"]
|
readonly_fields = ["status"]
|
||||||
|
|
||||||
|
autocomplete_fields = ["domain"]
|
||||||
|
|
||||||
change_form_template = "django/admin/email_clipboard_change_form.html"
|
change_form_template = "django/admin/email_clipboard_change_form.html"
|
||||||
|
|
||||||
# Select domain invitations to change -> Domain invitations
|
# Select domain invitations to change -> Domain invitations
|
||||||
|
@ -1466,6 +1484,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
"fields": [
|
"fields": [
|
||||||
"status",
|
"status",
|
||||||
"rejection_reason",
|
"rejection_reason",
|
||||||
|
"action_needed_reason",
|
||||||
"investigator",
|
"investigator",
|
||||||
"creator",
|
"creator",
|
||||||
"submitter",
|
"submitter",
|
||||||
|
@ -1482,6 +1501,8 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
"authorizing_official",
|
"authorizing_official",
|
||||||
"other_contacts",
|
"other_contacts",
|
||||||
"no_other_contacts_rationale",
|
"no_other_contacts_rationale",
|
||||||
|
"cisa_representative_first_name",
|
||||||
|
"cisa_representative_last_name",
|
||||||
"cisa_representative_email",
|
"cisa_representative_email",
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -1557,6 +1578,8 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
"no_other_contacts_rationale",
|
"no_other_contacts_rationale",
|
||||||
"anything_else",
|
"anything_else",
|
||||||
"is_policy_acknowledged",
|
"is_policy_acknowledged",
|
||||||
|
"cisa_representative_first_name",
|
||||||
|
"cisa_representative_last_name",
|
||||||
"cisa_representative_email",
|
"cisa_representative_email",
|
||||||
]
|
]
|
||||||
autocomplete_fields = [
|
autocomplete_fields = [
|
||||||
|
@ -1668,6 +1691,8 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
# The opposite of this condition is acceptable (rejected -> other status and rejection_reason)
|
# The opposite of this condition is acceptable (rejected -> other status and rejection_reason)
|
||||||
# because we clean up the rejection reason in the transition in the model.
|
# because we clean up the rejection reason in the transition in the model.
|
||||||
error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.NO_REJECTION_REASON)
|
error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.NO_REJECTION_REASON)
|
||||||
|
elif obj.status == models.DomainRequest.DomainRequestStatus.ACTION_NEEDED and not obj.action_needed_reason:
|
||||||
|
error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.NO_ACTION_NEEDED_REASON)
|
||||||
else:
|
else:
|
||||||
# This is an fsm in model which will throw an error if the
|
# This is an fsm in model which will throw an error if the
|
||||||
# transition condition is violated, so we roll back the
|
# transition condition is violated, so we roll back the
|
||||||
|
@ -1794,10 +1819,93 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def change_view(self, request, object_id, form_url="", extra_context=None):
|
def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||||
|
"""Display restricted warning,
|
||||||
|
Setup the auditlog trail and pass it in extra context."""
|
||||||
obj = self.get_object(request, object_id)
|
obj = self.get_object(request, object_id)
|
||||||
self.display_restricted_warning(request, obj)
|
self.display_restricted_warning(request, obj)
|
||||||
|
|
||||||
|
# Initialize variables for tracking status changes and filtered entries
|
||||||
|
filtered_audit_log_entries = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Retrieve and order audit log entries by timestamp in descending order
|
||||||
|
audit_log_entries = LogEntry.objects.filter(object_id=object_id).order_by("-timestamp")
|
||||||
|
|
||||||
|
# Process each log entry to filter based on the change criteria
|
||||||
|
for log_entry in audit_log_entries:
|
||||||
|
entry = self.process_log_entry(log_entry)
|
||||||
|
if entry:
|
||||||
|
filtered_audit_log_entries.append(entry)
|
||||||
|
|
||||||
|
except ObjectDoesNotExist as e:
|
||||||
|
logger.error(f"Object with object_id {object_id} does not exist: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"An error occurred during change_view: {e}")
|
||||||
|
|
||||||
|
# Initialize extra_context and add filtered entries
|
||||||
|
extra_context = extra_context or {}
|
||||||
|
extra_context["filtered_audit_log_entries"] = filtered_audit_log_entries
|
||||||
|
|
||||||
|
# Call the superclass method with updated extra_context
|
||||||
return super().change_view(request, object_id, form_url, extra_context)
|
return super().change_view(request, object_id, form_url, extra_context)
|
||||||
|
|
||||||
|
def process_log_entry(self, log_entry):
|
||||||
|
"""Process a log entry and return filtered entry dictionary if applicable."""
|
||||||
|
changes = log_entry.changes
|
||||||
|
status_changed = "status" in changes
|
||||||
|
rejection_reason_changed = "rejection_reason" in changes
|
||||||
|
action_needed_reason_changed = "action_needed_reason" in changes
|
||||||
|
|
||||||
|
# Check if the log entry meets the filtering criteria
|
||||||
|
if status_changed or (not status_changed and (rejection_reason_changed or action_needed_reason_changed)):
|
||||||
|
entry = {}
|
||||||
|
|
||||||
|
# Handle status change
|
||||||
|
if status_changed:
|
||||||
|
_, status_value = changes.get("status")
|
||||||
|
if status_value:
|
||||||
|
entry["status"] = DomainRequest.DomainRequestStatus.get_status_label(status_value)
|
||||||
|
|
||||||
|
# Handle rejection reason change
|
||||||
|
if rejection_reason_changed:
|
||||||
|
_, rejection_reason_value = changes.get("rejection_reason")
|
||||||
|
if rejection_reason_value:
|
||||||
|
entry["rejection_reason"] = (
|
||||||
|
""
|
||||||
|
if rejection_reason_value == "None"
|
||||||
|
else DomainRequest.RejectionReasons.get_rejection_reason_label(rejection_reason_value)
|
||||||
|
)
|
||||||
|
# Handle case where rejection reason changed but not status
|
||||||
|
if not status_changed:
|
||||||
|
entry["status"] = DomainRequest.DomainRequestStatus.get_status_label(
|
||||||
|
DomainRequest.DomainRequestStatus.REJECTED
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle action needed reason change
|
||||||
|
if action_needed_reason_changed:
|
||||||
|
_, action_needed_reason_value = changes.get("action_needed_reason")
|
||||||
|
if action_needed_reason_value:
|
||||||
|
entry["action_needed_reason"] = (
|
||||||
|
""
|
||||||
|
if action_needed_reason_value == "None"
|
||||||
|
else DomainRequest.ActionNeededReasons.get_action_needed_reason_label(
|
||||||
|
action_needed_reason_value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Handle case where action needed reason changed but not status
|
||||||
|
if not status_changed:
|
||||||
|
entry["status"] = DomainRequest.DomainRequestStatus.get_status_label(
|
||||||
|
DomainRequest.DomainRequestStatus.ACTION_NEEDED
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add actor and timestamp information
|
||||||
|
entry["actor"] = log_entry.actor
|
||||||
|
entry["timestamp"] = log_entry.timestamp
|
||||||
|
|
||||||
|
return entry
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class TransitionDomainAdmin(ListHeaderAdmin):
|
class TransitionDomainAdmin(ListHeaderAdmin):
|
||||||
"""Custom transition domain admin class."""
|
"""Custom transition domain admin class."""
|
||||||
|
@ -2463,6 +2571,34 @@ class VerifiedByStaffAdmin(ListHeaderAdmin):
|
||||||
super().save_model(request, obj, form, change)
|
super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioAdmin(ListHeaderAdmin):
|
||||||
|
# NOTE: these are just placeholders. Not part of ACs (haven't been defined yet). Update in future tickets.
|
||||||
|
list_display = ("organization_name", "federal_agency", "creator")
|
||||||
|
search_fields = ["organization_name"]
|
||||||
|
search_help_text = "Search by organization name."
|
||||||
|
# readonly_fields = [
|
||||||
|
# "requestor",
|
||||||
|
# ]
|
||||||
|
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
|
||||||
|
if obj.creator is not None:
|
||||||
|
# ---- update creator ----
|
||||||
|
# Set the creator field to the current admin user
|
||||||
|
obj.creator = request.user if request.user.is_authenticated else None
|
||||||
|
|
||||||
|
# ---- update organization name ----
|
||||||
|
# org name will be the same as federal agency, if it is federal,
|
||||||
|
# otherwise it will be the actual org name. If nothing is entered for
|
||||||
|
# org name and it is a federal organization, have this field fill with
|
||||||
|
# the federal agency text name.
|
||||||
|
is_federal = obj.organization_type == DomainRequest.OrganizationChoices.FEDERAL
|
||||||
|
if is_federal and obj.organization_name is None:
|
||||||
|
obj.organization_name = obj.federal_agency.agency
|
||||||
|
|
||||||
|
super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
|
|
||||||
class FederalAgencyResource(resources.ModelResource):
|
class FederalAgencyResource(resources.ModelResource):
|
||||||
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
|
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
|
||||||
import/export file"""
|
import/export file"""
|
||||||
|
@ -2542,6 +2678,7 @@ admin.site.register(models.PublicContact, PublicContactAdmin)
|
||||||
admin.site.register(models.DomainRequest, DomainRequestAdmin)
|
admin.site.register(models.DomainRequest, DomainRequestAdmin)
|
||||||
admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
|
admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
|
||||||
admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin)
|
admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin)
|
||||||
|
admin.site.register(models.Portfolio, PortfolioAdmin)
|
||||||
|
|
||||||
# Register our custom waffle implementations
|
# Register our custom waffle implementations
|
||||||
admin.site.register(models.WaffleFlag, WaffleFlagAdmin)
|
admin.site.register(models.WaffleFlag, WaffleFlagAdmin)
|
||||||
|
|
|
@ -137,6 +137,47 @@ function openInNewTab(el, removeAttribute = false){
|
||||||
prepareDjangoAdmin();
|
prepareDjangoAdmin();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
/** An IIFE for the "Assign to me" button under the investigator field in DomainRequests.
|
||||||
|
** This field uses the "select2" selector, rather than the default.
|
||||||
|
** To perform data operations on this - we need to use jQuery rather than vanilla js.
|
||||||
|
*/
|
||||||
|
(function (){
|
||||||
|
let selector = django.jQuery("#id_investigator")
|
||||||
|
let assignSelfButton = document.querySelector("#investigator__assign_self");
|
||||||
|
if (!selector || !assignSelfButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentUserId = assignSelfButton.getAttribute("data-user-id");
|
||||||
|
let currentUserName = assignSelfButton.getAttribute("data-user-name");
|
||||||
|
if (!currentUserId || !currentUserName){
|
||||||
|
console.error("Could not assign current user: no values found.")
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook a click listener to the "Assign to me" button.
|
||||||
|
// Logic borrowed from here: https://select2.org/programmatic-control/add-select-clear-items#create-if-not-exists
|
||||||
|
assignSelfButton.addEventListener("click", function() {
|
||||||
|
if (selector.find(`option[value='${currentUserId}']`).length) {
|
||||||
|
// Select the value that is associated with the current user.
|
||||||
|
selector.val(currentUserId).trigger("change");
|
||||||
|
} else {
|
||||||
|
// Create a DOM Option that matches the desired user. Then append it and select it.
|
||||||
|
let userOption = new Option(currentUserName, currentUserId, true, true);
|
||||||
|
selector.append(userOption).trigger("change");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen to any change events, and hide the parent container if investigator has a value.
|
||||||
|
selector.on('change', function() {
|
||||||
|
// The parent container has display type flex.
|
||||||
|
assignSelfButton.parentElement.style.display = this.value === currentUserId ? "none" : "flex";
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
})();
|
||||||
/** An IIFE for pages in DjangoAdmin that use a clipboard button
|
/** An IIFE for pages in DjangoAdmin that use a clipboard button
|
||||||
*/
|
*/
|
||||||
(function (){
|
(function (){
|
||||||
|
@ -300,42 +341,90 @@ function initializeWidgetOnList(list, parentId) {
|
||||||
*/
|
*/
|
||||||
(function (){
|
(function (){
|
||||||
let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason')
|
let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason')
|
||||||
|
let actionNeededReasonFormGroup = document.querySelector('.field-action_needed_reason');
|
||||||
|
|
||||||
if (rejectionReasonFormGroup) {
|
if (rejectionReasonFormGroup && actionNeededReasonFormGroup) {
|
||||||
let statusSelect = document.getElementById('id_status')
|
let statusSelect = document.getElementById('id_status')
|
||||||
|
let isRejected = statusSelect.value == "rejected"
|
||||||
|
let isActionNeeded = statusSelect.value == "action needed"
|
||||||
|
|
||||||
// Initial handling of rejectionReasonFormGroup display
|
// Initial handling of rejectionReasonFormGroup display
|
||||||
if (statusSelect.value != 'rejected')
|
showOrHideObject(rejectionReasonFormGroup, show=isRejected)
|
||||||
rejectionReasonFormGroup.style.display = 'none';
|
showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded)
|
||||||
|
|
||||||
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
|
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
|
||||||
statusSelect.addEventListener('change', function() {
|
statusSelect.addEventListener('change', function() {
|
||||||
if (statusSelect.value == 'rejected') {
|
// Show the rejection reason field if the status is rejected.
|
||||||
rejectionReasonFormGroup.style.display = 'block';
|
// Then track if its shown or hidden in our session cache.
|
||||||
sessionStorage.removeItem('hideRejectionReason');
|
isRejected = statusSelect.value == "rejected"
|
||||||
} else {
|
showOrHideObject(rejectionReasonFormGroup, show=isRejected)
|
||||||
rejectionReasonFormGroup.style.display = 'none';
|
addOrRemoveSessionBoolean("showRejectionReason", add=isRejected)
|
||||||
sessionStorage.setItem('hideRejectionReason', 'true');
|
|
||||||
}
|
isActionNeeded = statusSelect.value == "action needed"
|
||||||
|
showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded)
|
||||||
|
addOrRemoveSessionBoolean("showActionNeededReason", add=isActionNeeded)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage
|
||||||
|
|
||||||
|
// When you navigate using forward/back after changing status but not saving, when you land back on the DA page the
|
||||||
|
// status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide
|
||||||
|
// accurately for this edge case, we use cache and test for the back/forward navigation.
|
||||||
|
const observer = new PerformanceObserver((list) => {
|
||||||
|
list.getEntries().forEach((entry) => {
|
||||||
|
if (entry.type === "back_forward") {
|
||||||
|
let showRejectionReason = sessionStorage.getItem("showRejectionReason") !== null
|
||||||
|
showOrHideObject(rejectionReasonFormGroup, show=showRejectionReason)
|
||||||
|
|
||||||
|
let showActionNeededReason = sessionStorage.getItem("showActionNeededReason") !== null
|
||||||
|
showOrHideObject(actionNeededReasonFormGroup, show=showActionNeededReason)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
observer.observe({ type: "navigation" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage
|
// Adds or removes the display-none class to object depending on the value of boolean show
|
||||||
|
function showOrHideObject(object, show){
|
||||||
|
if (show){
|
||||||
|
object.classList.remove("display-none");
|
||||||
|
}else {
|
||||||
|
object.classList.add("display-none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// When you navigate using forward/back after changing status but not saving, when you land back on the DA page the
|
// Adds or removes a boolean from our session
|
||||||
// status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide
|
function addOrRemoveSessionBoolean(name, add){
|
||||||
// accurately for this edge case, we use cache and test for the back/forward navigation.
|
if (add) {
|
||||||
const observer = new PerformanceObserver((list) => {
|
sessionStorage.setItem(name, "true");
|
||||||
list.getEntries().forEach((entry) => {
|
}else {
|
||||||
if (entry.type === "back_forward") {
|
sessionStorage.removeItem(name);
|
||||||
if (sessionStorage.getItem('hideRejectionReason'))
|
}
|
||||||
document.querySelector('.field-rejection_reason').style.display = 'none';
|
}
|
||||||
else
|
|
||||||
document.querySelector('.field-rejection_reason').style.display = 'block';
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
}
|
let statusSelect = document.getElementById('id_status');
|
||||||
});
|
|
||||||
|
function moveStatusChangelog(actionNeededReasonFormGroup, statusSelect) {
|
||||||
|
let flexContainer = actionNeededReasonFormGroup.querySelector('.flex-container');
|
||||||
|
let statusChangelog = document.getElementById('dja-status-changelog');
|
||||||
|
if (statusSelect.value === "action needed") {
|
||||||
|
flexContainer.parentNode.insertBefore(statusChangelog, flexContainer.nextSibling);
|
||||||
|
} else {
|
||||||
|
// Move the changelog back to its original location
|
||||||
|
let statusFlexContainer = statusSelect.closest('.flex-container');
|
||||||
|
statusFlexContainer.parentNode.insertBefore(statusChangelog, statusFlexContainer.nextSibling);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the function on page load
|
||||||
|
moveStatusChangelog(actionNeededReasonFormGroup, statusSelect);
|
||||||
|
|
||||||
|
// Add event listener to handle changes to the selector itself
|
||||||
|
statusSelect.addEventListener('change', function() {
|
||||||
|
moveStatusChangelog(actionNeededReasonFormGroup, statusSelect);
|
||||||
|
})
|
||||||
});
|
});
|
||||||
observer.observe({ type: "navigation" });
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/** An IIFE for toggling the submit bar on domain request forms
|
/** An IIFE for toggling the submit bar on domain request forms
|
||||||
|
|
|
@ -17,6 +17,22 @@ var SUCCESS = "success";
|
||||||
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
|
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
|
||||||
// Helper functions.
|
// Helper functions.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide element
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
const hideElement = (element) => {
|
||||||
|
element.classList.add('display-none');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show element
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
const showElement = (element) => {
|
||||||
|
element.classList.remove('display-none');
|
||||||
|
};
|
||||||
|
|
||||||
/** Makes an element invisible. */
|
/** Makes an element invisible. */
|
||||||
function makeHidden(el) {
|
function makeHidden(el) {
|
||||||
el.style.position = "absolute";
|
el.style.position = "absolute";
|
||||||
|
@ -918,8 +934,9 @@ function ScrollToElement(attributeName, attributeValue) {
|
||||||
* @param {boolean} hasPrevious - Whether there is a page before the current page.
|
* @param {boolean} hasPrevious - Whether there is a page before the current page.
|
||||||
* @param {boolean} hasNext - Whether there is a page after the current page.
|
* @param {boolean} hasNext - Whether there is a page after the current page.
|
||||||
* @param {number} totalItems - The total number of items.
|
* @param {number} totalItems - The total number of items.
|
||||||
|
* @param {string} searchTerm - The search term
|
||||||
*/
|
*/
|
||||||
function updatePagination(itemName, paginationSelector, counterSelector, headerAnchor, loadPageFunction, currentPage, numPages, hasPrevious, hasNext, totalItems) {
|
function updatePagination(itemName, paginationSelector, counterSelector, headerAnchor, loadPageFunction, currentPage, numPages, hasPrevious, hasNext, totalItems, searchTerm) {
|
||||||
const paginationContainer = document.querySelector(paginationSelector);
|
const paginationContainer = document.querySelector(paginationSelector);
|
||||||
const paginationCounter = document.querySelector(counterSelector);
|
const paginationCounter = document.querySelector(counterSelector);
|
||||||
const paginationButtons = document.querySelector(`${paginationSelector} .usa-pagination__list`);
|
const paginationButtons = document.querySelector(`${paginationSelector} .usa-pagination__list`);
|
||||||
|
@ -932,7 +949,7 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
|
||||||
// Counter should only be displayed if there is more than 1 item
|
// Counter should only be displayed if there is more than 1 item
|
||||||
paginationContainer.classList.toggle('display-none', totalItems < 1);
|
paginationContainer.classList.toggle('display-none', totalItems < 1);
|
||||||
|
|
||||||
paginationCounter.innerHTML = `${totalItems} ${itemName}${totalItems > 1 ? 's' : ''}`;
|
paginationCounter.innerHTML = `${totalItems} ${itemName}${totalItems > 1 ? 's' : ''}${searchTerm ? ' for ' + '"' + searchTerm + '"' : ''}`;
|
||||||
|
|
||||||
if (hasPrevious) {
|
if (hasPrevious) {
|
||||||
const prevPageItem = document.createElement('li');
|
const prevPageItem = document.createElement('li');
|
||||||
|
@ -1018,6 +1035,47 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper that toggles content/ no content/ no search results
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
const updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper, searchTermHolder, currentSearchTerm) => {
|
||||||
|
const { unfiltered_total, total } = data;
|
||||||
|
|
||||||
|
if (searchTermHolder)
|
||||||
|
searchTermHolder.innerHTML = '';
|
||||||
|
|
||||||
|
if (unfiltered_total) {
|
||||||
|
if (total) {
|
||||||
|
showElement(dataWrapper);
|
||||||
|
hideElement(noSearchResultsWrapper);
|
||||||
|
hideElement(noDataWrapper);
|
||||||
|
} else {
|
||||||
|
if (searchTermHolder)
|
||||||
|
searchTermHolder.innerHTML = currentSearchTerm;
|
||||||
|
hideElement(dataWrapper);
|
||||||
|
showElement(noSearchResultsWrapper);
|
||||||
|
hideElement(noDataWrapper);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hideElement(dataWrapper);
|
||||||
|
hideElement(noSearchResultsWrapper);
|
||||||
|
showElement(noDataWrapper);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper that resets sortable table headers
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
const unsetHeader = (header) => {
|
||||||
|
header.removeAttribute('aria-sort');
|
||||||
|
let headerName = header.innerText;
|
||||||
|
const headerLabel = `${headerName}, sortable column, currently unsorted"`;
|
||||||
|
const headerButtonLabel = `Click to sort by ascending order.`;
|
||||||
|
header.setAttribute("aria-label", headerLabel);
|
||||||
|
header.querySelector('.usa-table__header__button').setAttribute("title", headerButtonLabel);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An IIFE that listens for DOM Content to be loaded, then executes. This function
|
* An IIFE that listens for DOM Content to be loaded, then executes. This function
|
||||||
|
@ -1025,13 +1083,21 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
let domainsWrapper = document.querySelector('.domains-wrapper');
|
const domainsWrapper = document.querySelector('.domains__table-wrapper');
|
||||||
|
|
||||||
if (domainsWrapper) {
|
if (domainsWrapper) {
|
||||||
let currentSortBy = 'id';
|
let currentSortBy = 'id';
|
||||||
let currentOrder = 'asc';
|
let currentOrder = 'asc';
|
||||||
let noDomainsWrapper = document.querySelector('.no-domains-wrapper');
|
const noDomainsWrapper = document.querySelector('.domains__no-data');
|
||||||
|
const noSearchResultsWrapper = document.querySelector('.domains__no-search-results');
|
||||||
let hasLoaded = false;
|
let hasLoaded = false;
|
||||||
|
let currentSearchTerm = ''
|
||||||
|
const domainsSearchInput = document.getElementById('domains__search-field');
|
||||||
|
const domainsSearchSubmit = document.getElementById('domains__search-field-submit');
|
||||||
|
const tableHeaders = document.querySelectorAll('.domains__table th[data-sortable]');
|
||||||
|
const tableAnnouncementRegion = document.querySelector('.domains__table-wrapper .usa-table__announcement-region');
|
||||||
|
const searchTermHolder = document.querySelector('.domains__search-term');
|
||||||
|
const resetButton = document.querySelector('.domains__reset-button');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads rows in the domains list, as well as updates pagination around the domains list
|
* Loads rows in the domains list, as well as updates pagination around the domains list
|
||||||
|
@ -1040,10 +1106,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
* @param {*} sortBy - the sort column option
|
* @param {*} sortBy - the sort column option
|
||||||
* @param {*} order - the sort order {asc, desc}
|
* @param {*} order - the sort order {asc, desc}
|
||||||
* @param {*} loaded - control for the scrollToElement functionality
|
* @param {*} loaded - control for the scrollToElement functionality
|
||||||
|
* @param {*} searchTerm - the search term
|
||||||
*/
|
*/
|
||||||
function loadDomains(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded) {
|
function loadDomains(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded, searchTerm = currentSearchTerm) {
|
||||||
//fetch json of page of domains, given page # and sort
|
//fetch json of page of domains, given page # and sort
|
||||||
fetch(`/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}`)
|
fetch(`/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
|
@ -1051,23 +1118,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle the display of proper messaging in the event that no domains exist in the list
|
// handle the display of proper messaging in the event that no domains exist in the list or search returns no results
|
||||||
if (data.domains.length) {
|
updateDisplay(data, domainsWrapper, noDomainsWrapper, noSearchResultsWrapper, searchTermHolder, currentSearchTerm);
|
||||||
domainsWrapper.classList.remove('display-none');
|
|
||||||
noDomainsWrapper.classList.add('display-none');
|
|
||||||
} else {
|
|
||||||
domainsWrapper.classList.add('display-none');
|
|
||||||
noDomainsWrapper.classList.remove('display-none');
|
|
||||||
}
|
|
||||||
|
|
||||||
// identify the DOM element where the domain list will be inserted into the DOM
|
// identify the DOM element where the domain list will be inserted into the DOM
|
||||||
const domainList = document.querySelector('.dotgov-table__registered-domains tbody');
|
const domainList = document.querySelector('.domains__table tbody');
|
||||||
domainList.innerHTML = '';
|
domainList.innerHTML = '';
|
||||||
|
|
||||||
data.domains.forEach(domain => {
|
data.domains.forEach(domain => {
|
||||||
const options = { year: 'numeric', month: 'short', day: 'numeric' };
|
const options = { year: 'numeric', month: 'short', day: 'numeric' };
|
||||||
const expirationDate = domain.expiration_date ? new Date(domain.expiration_date) : null;
|
const expirationDate = domain.expiration_date ? new Date(domain.expiration_date) : null;
|
||||||
const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : null;
|
const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : '';
|
||||||
const expirationDateSortValue = expirationDate ? expirationDate.getTime() : '';
|
const expirationDateSortValue = expirationDate ? expirationDate.getTime() : '';
|
||||||
const actionUrl = domain.action_url;
|
const actionUrl = domain.action_url;
|
||||||
|
|
||||||
|
@ -1106,9 +1167,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
});
|
});
|
||||||
// initialize tool tips immediately after the associated DOM elements are added
|
// initialize tool tips immediately after the associated DOM elements are added
|
||||||
initializeTooltips();
|
initializeTooltips();
|
||||||
|
|
||||||
|
// Do not scroll on first page load
|
||||||
if (loaded)
|
if (loaded)
|
||||||
ScrollToElement('id', 'domains-header');
|
ScrollToElement('id', 'domains-header');
|
||||||
|
|
||||||
hasLoaded = true;
|
hasLoaded = true;
|
||||||
|
|
||||||
// update pagination
|
// update pagination
|
||||||
|
@ -1122,18 +1184,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
data.num_pages,
|
data.num_pages,
|
||||||
data.has_previous,
|
data.has_previous,
|
||||||
data.has_next,
|
data.has_next,
|
||||||
data.total
|
data.total,
|
||||||
|
currentSearchTerm
|
||||||
);
|
);
|
||||||
currentSortBy = sortBy;
|
currentSortBy = sortBy;
|
||||||
currentOrder = order;
|
currentOrder = order;
|
||||||
|
currentSearchTerm = searchTerm;
|
||||||
})
|
})
|
||||||
.catch(error => console.error('Error fetching domains:', error));
|
.catch(error => console.error('Error fetching domains:', error));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Add event listeners to table headers for sorting
|
// Add event listeners to table headers for sorting
|
||||||
document.querySelectorAll('.dotgov-table__registered-domains th[data-sortable]').forEach(header => {
|
tableHeaders.forEach(header => {
|
||||||
header.addEventListener('click', function() {
|
header.addEventListener('click', function() {
|
||||||
const sortBy = this.getAttribute('data-sortable');
|
const sortBy = this.getAttribute('data-sortable');
|
||||||
let order = 'asc';
|
let order = 'asc';
|
||||||
|
@ -1147,6 +1209,43 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
domainsSearchSubmit.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
currentSearchTerm = domainsSearchInput.value;
|
||||||
|
// If the search is blank, we match the resetSearch functionality
|
||||||
|
if (currentSearchTerm) {
|
||||||
|
showElement(resetButton);
|
||||||
|
} else {
|
||||||
|
hideElement(resetButton);
|
||||||
|
}
|
||||||
|
loadDomains(1, 'id', 'asc');
|
||||||
|
resetHeaders();
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset UI and accessibility
|
||||||
|
function resetHeaders() {
|
||||||
|
tableHeaders.forEach(header => {
|
||||||
|
// Unset sort UI in headers
|
||||||
|
unsetHeader(header);
|
||||||
|
});
|
||||||
|
// Reset the announcement region
|
||||||
|
tableAnnouncementRegion.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSearch() {
|
||||||
|
domainsSearchInput.value = '';
|
||||||
|
currentSearchTerm = '';
|
||||||
|
hideElement(resetButton);
|
||||||
|
loadDomains(1, 'id', 'asc', hasLoaded, '');
|
||||||
|
resetHeaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resetButton) {
|
||||||
|
resetButton.addEventListener('click', function() {
|
||||||
|
resetSearch();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Load the first page initially
|
// Load the first page initially
|
||||||
loadDomains(1);
|
loadDomains(1);
|
||||||
}
|
}
|
||||||
|
@ -1157,25 +1256,71 @@ const utcDateString = (dateString) => {
|
||||||
const utcYear = date.getUTCFullYear();
|
const utcYear = date.getUTCFullYear();
|
||||||
const utcMonth = date.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
|
const utcMonth = date.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
|
||||||
const utcDay = date.getUTCDate().toString().padStart(2, '0');
|
const utcDay = date.getUTCDate().toString().padStart(2, '0');
|
||||||
const utcHours = date.getUTCHours().toString().padStart(2, '0');
|
let utcHours = date.getUTCHours();
|
||||||
const utcMinutes = date.getUTCMinutes().toString().padStart(2, '0');
|
const utcMinutes = date.getUTCMinutes().toString().padStart(2, '0');
|
||||||
|
|
||||||
return `${utcMonth} ${utcDay}, ${utcYear}, ${utcHours}:${utcMinutes} UTC`;
|
const ampm = utcHours >= 12 ? 'PM' : 'AM';
|
||||||
|
utcHours = utcHours % 12 || 12; // Convert to 12-hour format, '0' hours should be '12'
|
||||||
|
|
||||||
|
return `${utcMonth} ${utcDay}, ${utcYear}, ${utcHours}:${utcMinutes} ${ampm} UTC`;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An IIFE that listens for DOM Content to be loaded, then executes. This function
|
* An IIFE that listens for DOM Content to be loaded, then executes. This function
|
||||||
* initializes the domain requests list and associated functionality on the home page of the app.
|
* initializes the domain requests list and associated functionality on the home page of the app.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
let domainRequestsWrapper = document.querySelector('.domain-requests-wrapper');
|
const domainRequestsSectionWrapper = document.querySelector('.domain-requests');
|
||||||
|
const domainRequestsWrapper = document.querySelector('.domain-requests__table-wrapper');
|
||||||
|
|
||||||
if (domainRequestsWrapper) {
|
if (domainRequestsWrapper) {
|
||||||
let currentSortBy = 'id';
|
let currentSortBy = 'id';
|
||||||
let currentOrder = 'asc';
|
let currentOrder = 'asc';
|
||||||
let noDomainRequestsWrapper = document.querySelector('.no-domain-requests-wrapper');
|
const noDomainRequestsWrapper = document.querySelector('.domain-requests__no-data');
|
||||||
|
const noSearchResultsWrapper = document.querySelector('.domain-requests__no-search-results');
|
||||||
let hasLoaded = false;
|
let hasLoaded = false;
|
||||||
|
let currentSearchTerm = ''
|
||||||
|
const domainRequestsSearchInput = document.getElementById('domain-requests__search-field');
|
||||||
|
const domainRequestsSearchSubmit = document.getElementById('domain-requests__search-field-submit');
|
||||||
|
const tableHeaders = document.querySelectorAll('.domain-requests__table th[data-sortable]');
|
||||||
|
const tableAnnouncementRegion = document.querySelector('.domain-requests__table-wrapper .usa-table__announcement-region');
|
||||||
|
const searchTermHolder = document.querySelector('.domain-requests__search-term');
|
||||||
|
const resetButton = document.querySelector('.domain-requests__reset-button');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete is actually a POST API that requires a csrf token. The token will be waiting for us in the template as a hidden input.
|
||||||
|
* @param {*} domainRequestPk - the identifier for the request that we're deleting
|
||||||
|
* @param {*} pageToDisplay - If we're deleting the last item on a page that is not page 1, we'll need to display the previous page
|
||||||
|
*/
|
||||||
|
function deleteDomainRequest(domainRequestPk,pageToDisplay) {
|
||||||
|
// Get csrf token
|
||||||
|
const csrfToken = getCsrfToken();
|
||||||
|
// Create FormData object and append the CSRF token
|
||||||
|
const formData = `csrfmiddlewaretoken=${encodeURIComponent(csrfToken)}&delete-domain-request=`;
|
||||||
|
|
||||||
|
fetch(`/domain-request/${domainRequestPk}/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
// Update data and UI
|
||||||
|
loadDomainRequests(pageToDisplay, currentSortBy, currentOrder, hasLoaded, currentSearchTerm);
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error fetching domain requests:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get the CSRF token from the cookie
|
||||||
|
function getCsrfToken() {
|
||||||
|
return document.querySelector('input[name="csrfmiddlewaretoken"]').value;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads rows in the domain requests list, as well as updates pagination around the domain requests list
|
* Loads rows in the domain requests list, as well as updates pagination around the domain requests list
|
||||||
|
@ -1184,10 +1329,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
* @param {*} sortBy - the sort column option
|
* @param {*} sortBy - the sort column option
|
||||||
* @param {*} order - the sort order {asc, desc}
|
* @param {*} order - the sort order {asc, desc}
|
||||||
* @param {*} loaded - control for the scrollToElement functionality
|
* @param {*} loaded - control for the scrollToElement functionality
|
||||||
|
* @param {*} searchTerm - the search term
|
||||||
*/
|
*/
|
||||||
function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded) {
|
function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded, searchTerm = currentSearchTerm) {
|
||||||
//fetch json of page of domain requests, given page # and sort
|
//fetch json of page of domain requests, given page # and sort
|
||||||
fetch(`/get-domain-requests-json/?page=${page}&sort_by=${sortBy}&order=${order}`)
|
fetch(`/get-domain-requests-json/?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
|
@ -1195,41 +1341,138 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle the display of proper messaging in the event that no domain requests exist in the list
|
// handle the display of proper messaging in the event that no requests exist in the list or search returns no results
|
||||||
if (data.domain_requests.length) {
|
updateDisplay(data, domainRequestsWrapper, noDomainRequestsWrapper, noSearchResultsWrapper, searchTermHolder, currentSearchTerm);
|
||||||
domainRequestsWrapper.classList.remove('display-none');
|
|
||||||
noDomainRequestsWrapper.classList.add('display-none');
|
|
||||||
} else {
|
|
||||||
domainRequestsWrapper.classList.add('display-none');
|
|
||||||
noDomainRequestsWrapper.classList.remove('display-none');
|
|
||||||
}
|
|
||||||
|
|
||||||
// identify the DOM element where the domain request list will be inserted into the DOM
|
// identify the DOM element where the domain request list will be inserted into the DOM
|
||||||
const tbody = document.querySelector('.dotgov-table__domain-requests tbody');
|
const tbody = document.querySelector('.domain-requests__table tbody');
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
// remove any existing modal elements from the DOM so they can be properly re-initialized
|
// remove any existing modal elements from the DOM so they can be properly re-initialized
|
||||||
// after the DOM content changes and there are new delete modal buttons added
|
// after the DOM content changes and there are new delete modal buttons added
|
||||||
unloadModals();
|
unloadModals();
|
||||||
|
|
||||||
|
let needsDeleteColumn = false;
|
||||||
|
|
||||||
|
needsDeleteColumn = data.domain_requests.some(request => request.is_deletable);
|
||||||
|
|
||||||
|
// Remove existing delete th and td if they exist
|
||||||
|
let existingDeleteTh = document.querySelector('.delete-header');
|
||||||
|
if (!needsDeleteColumn) {
|
||||||
|
if (existingDeleteTh)
|
||||||
|
existingDeleteTh.remove();
|
||||||
|
} else {
|
||||||
|
if (!existingDeleteTh) {
|
||||||
|
const delheader = document.createElement('th');
|
||||||
|
delheader.setAttribute('scope', 'col');
|
||||||
|
delheader.setAttribute('role', 'columnheader');
|
||||||
|
delheader.setAttribute('class', 'delete-header');
|
||||||
|
delheader.innerHTML = `
|
||||||
|
<span class="usa-sr-only">Delete Action</span>`;
|
||||||
|
let tableHeaderRow = document.querySelector('.domain-requests__table thead tr');
|
||||||
|
tableHeaderRow.appendChild(delheader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data.domain_requests.forEach(request => {
|
data.domain_requests.forEach(request => {
|
||||||
const options = { year: 'numeric', month: 'short', day: 'numeric' };
|
const options = { year: 'numeric', month: 'short', day: 'numeric' };
|
||||||
const domainName = request.requested_domain ? request.requested_domain : `New domain request <br><span class="text-base font-body-xs">(${utcDateString(request.created_at)})</span>`;
|
const domainName = request.requested_domain ? request.requested_domain : `New domain request <br><span class="text-base font-body-xs">(${utcDateString(request.created_at)})</span>`;
|
||||||
const actionUrl = request.action_url;
|
const actionUrl = request.action_url;
|
||||||
const actionLabel = request.action_label;
|
const actionLabel = request.action_label;
|
||||||
const submissionDate = request.submission_date ? new Date(request.submission_date).toLocaleDateString('en-US', options) : `<span class="text-base">Not submitted</span>`;
|
const submissionDate = request.submission_date ? new Date(request.submission_date).toLocaleDateString('en-US', options) : `<span class="text-base">Not submitted</span>`;
|
||||||
const deleteButton = request.is_deletable ? `
|
|
||||||
<a
|
// Even if the request is not deletable, we may need this empty string for the td if the deletable column is displayed
|
||||||
role="button"
|
let modalTrigger = '';
|
||||||
id="button-toggle-delete-domain-alert-${request.id}"
|
|
||||||
href="#toggle-delete-domain-alert-${request.id}"
|
// If the request is deletable, create modal body and insert it
|
||||||
class="usa-button--unstyled text-no-underline late-loading-modal-trigger"
|
if (request.is_deletable) {
|
||||||
aria-controls="toggle-delete-domain-alert-${request.id}"
|
let modalHeading = '';
|
||||||
data-open-modal
|
let modalDescription = '';
|
||||||
>
|
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
if (request.requested_domain) {
|
||||||
<use xlink:href="/public/img/sprite.svg#delete"></use>
|
modalHeading = `Are you sure you want to delete ${request.requested_domain}?`;
|
||||||
</svg> Delete <span class="usa-sr-only">${domainName}</span>
|
modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.';
|
||||||
</a>` : '';
|
} else {
|
||||||
|
if (request.created_at) {
|
||||||
|
modalHeading = 'Are you sure you want to delete this domain request?';
|
||||||
|
modalDescription = `This will remove the domain request (created ${utcDateString(request.created_at)}) from the .gov registrar. This action cannot be undone`;
|
||||||
|
} else {
|
||||||
|
modalHeading = 'Are you sure you want to delete New domain request?';
|
||||||
|
modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modalTrigger = `
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
id="button-toggle-delete-domain-alert-${request.id}"
|
||||||
|
href="#toggle-delete-domain-alert-${request.id}"
|
||||||
|
class="usa-button--unstyled text-no-underline late-loading-modal-trigger"
|
||||||
|
aria-controls="toggle-delete-domain-alert-${request.id}"
|
||||||
|
data-open-modal
|
||||||
|
>
|
||||||
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||||
|
<use xlink:href="/public/img/sprite.svg#delete"></use>
|
||||||
|
</svg> Delete <span class="usa-sr-only">${domainName}</span>
|
||||||
|
</a>`
|
||||||
|
|
||||||
|
const modalSubmit = `
|
||||||
|
<button type="button"
|
||||||
|
class="usa-button usa-button--secondary usa-modal__submit"
|
||||||
|
data-pk = ${request.id}
|
||||||
|
name="delete-domain-request">Yes, delete request</button>
|
||||||
|
`
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.setAttribute('class', 'usa-modal');
|
||||||
|
modal.setAttribute('id', `toggle-delete-domain-alert-${request.id}`);
|
||||||
|
modal.setAttribute('aria-labelledby', 'Are you sure you want to continue?');
|
||||||
|
modal.setAttribute('aria-describedby', 'Domain will be removed');
|
||||||
|
modal.setAttribute('data-force-action', '');
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="usa-modal__content">
|
||||||
|
<div class="usa-modal__main">
|
||||||
|
<h2 class="usa-modal__heading" id="modal-1-heading">
|
||||||
|
${modalHeading}
|
||||||
|
</h2>
|
||||||
|
<div class="usa-prose">
|
||||||
|
<p id="modal-1-description">
|
||||||
|
${modalDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="usa-modal__footer">
|
||||||
|
<ul class="usa-button-group">
|
||||||
|
<li class="usa-button-group__item">
|
||||||
|
${modalSubmit}
|
||||||
|
</li>
|
||||||
|
<li class="usa-button-group__item">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="usa-button usa-button--unstyled padding-105 text-center"
|
||||||
|
data-close-modal
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="usa-button usa-modal__close"
|
||||||
|
aria-label="Close this window"
|
||||||
|
data-close-modal
|
||||||
|
>
|
||||||
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
|
||||||
|
<use xlink:href="/public/img/sprite.svg#close"></use>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
domainRequestsSectionWrapper.appendChild(modal);
|
||||||
|
}
|
||||||
|
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
|
@ -1250,15 +1493,36 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
${actionLabel} <span class="usa-sr-only">${request.requested_domain ? request.requested_domain : 'New domain request'}</span>
|
${actionLabel} <span class="usa-sr-only">${request.requested_domain ? request.requested_domain : 'New domain request'}</span>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>${deleteButton}</td>
|
${needsDeleteColumn ? '<td>'+modalTrigger+'</td>' : ''}
|
||||||
`;
|
`;
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
// initialize modals immediately after the DOM content is updated
|
// initialize modals immediately after the DOM content is updated
|
||||||
initializeModals();
|
initializeModals();
|
||||||
|
|
||||||
|
// Now the DOM and modals are ready, add listeners to the submit buttons
|
||||||
|
const modals = document.querySelectorAll('.usa-modal__content');
|
||||||
|
|
||||||
|
modals.forEach(modal => {
|
||||||
|
const submitButton = modal.querySelector('.usa-modal__submit');
|
||||||
|
const closeButton = modal.querySelector('.usa-modal__close');
|
||||||
|
submitButton.addEventListener('click', function() {
|
||||||
|
pk = submitButton.getAttribute('data-pk');
|
||||||
|
// Close the modal to remove the USWDS UI local classes
|
||||||
|
closeButton.click();
|
||||||
|
// If we're deleting the last item on a page that is not page 1, we'll need to refresh the display to the previous page
|
||||||
|
let pageToDisplay = data.page;
|
||||||
|
if (data.total == 1 && data.unfiltered_total > 1) {
|
||||||
|
pageToDisplay--;
|
||||||
|
}
|
||||||
|
deleteDomainRequest(pk, pageToDisplay);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Do not scroll on first page load
|
||||||
if (loaded)
|
if (loaded)
|
||||||
ScrollToElement('id', 'domain-requests-header');
|
ScrollToElement('id', 'domain-requests-header');
|
||||||
|
|
||||||
hasLoaded = true;
|
hasLoaded = true;
|
||||||
|
|
||||||
// update the pagination after the domain requests list is updated
|
// update the pagination after the domain requests list is updated
|
||||||
|
@ -1272,16 +1536,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
data.num_pages,
|
data.num_pages,
|
||||||
data.has_previous,
|
data.has_previous,
|
||||||
data.has_next,
|
data.has_next,
|
||||||
data.total
|
data.total,
|
||||||
|
currentSearchTerm
|
||||||
);
|
);
|
||||||
currentSortBy = sortBy;
|
currentSortBy = sortBy;
|
||||||
currentOrder = order;
|
currentOrder = order;
|
||||||
|
currentSearchTerm = searchTerm;
|
||||||
})
|
})
|
||||||
.catch(error => console.error('Error fetching domain requests:', error));
|
.catch(error => console.error('Error fetching domain requests:', error));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add event listeners to table headers for sorting
|
// Add event listeners to table headers for sorting
|
||||||
document.querySelectorAll('.dotgov-table__domain-requests th[data-sortable]').forEach(header => {
|
tableHeaders.forEach(header => {
|
||||||
header.addEventListener('click', function() {
|
header.addEventListener('click', function() {
|
||||||
const sortBy = this.getAttribute('data-sortable');
|
const sortBy = this.getAttribute('data-sortable');
|
||||||
let order = 'asc';
|
let order = 'asc';
|
||||||
|
@ -1294,6 +1560,43 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
domainRequestsSearchSubmit.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
currentSearchTerm = domainRequestsSearchInput.value;
|
||||||
|
// If the search is blank, we match the resetSearch functionality
|
||||||
|
if (currentSearchTerm) {
|
||||||
|
showElement(resetButton);
|
||||||
|
} else {
|
||||||
|
hideElement(resetButton);
|
||||||
|
}
|
||||||
|
loadDomainRequests(1, 'id', 'asc');
|
||||||
|
resetHeaders();
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset UI and accessibility
|
||||||
|
function resetHeaders() {
|
||||||
|
tableHeaders.forEach(header => {
|
||||||
|
// unset sort UI in headers
|
||||||
|
unsetHeader(header);
|
||||||
|
});
|
||||||
|
// Reset the announcement region
|
||||||
|
tableAnnouncementRegion.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSearch() {
|
||||||
|
domainRequestsSearchInput.value = '';
|
||||||
|
currentSearchTerm = '';
|
||||||
|
hideElement(resetButton);
|
||||||
|
loadDomainRequests(1, 'id', 'asc', hasLoaded, '');
|
||||||
|
resetHeaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resetButton) {
|
||||||
|
resetButton.addEventListener('click', function() {
|
||||||
|
resetSearch();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Load the first page initially
|
// Load the first page initially
|
||||||
loadDomainRequests(1);
|
loadDomainRequests(1);
|
||||||
}
|
}
|
||||||
|
@ -1301,6 +1604,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An IIFE that displays confirmation modal on the user profile page
|
||||||
|
*/
|
||||||
|
(function userProfileListener() {
|
||||||
|
|
||||||
|
const showConfirmationModalTrigger = document.querySelector('.show-confirmation-modal');
|
||||||
|
if (showConfirmationModalTrigger) {
|
||||||
|
showConfirmationModalTrigger.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An IIFE that hooks up the edit buttons on the finish-user-setup page
|
* An IIFE that hooks up the edit buttons on the finish-user-setup page
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -773,3 +773,16 @@ div.dja__model-description{
|
||||||
.module caption, .inline-group h2 {
|
.module caption, .inline-group h2 {
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wrapped-button-group {
|
||||||
|
// This button group has too many items
|
||||||
|
flex-wrap: wrap;
|
||||||
|
// Fix a weird spacing issue with USWDS a buttons in DJA
|
||||||
|
a.button {
|
||||||
|
padding: 6px 8px 10px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.usa-button--dja-link-color {
|
||||||
|
color: var(--link-fg);
|
||||||
|
}
|
||||||
|
|
|
@ -70,7 +70,7 @@ body {
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 0; /* No width since it's a border */
|
width: 0; /* No width since it's a border */
|
||||||
height: 50%;
|
height: 40%;
|
||||||
border-left: solid 1px color('base-light');
|
border-left: solid 1px color('base-light');
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
}
|
}
|
||||||
|
|
|
@ -98,7 +98,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (min-width: 1040px){
|
@media (min-width: 1040px){
|
||||||
.dotgov-table__domain-requests {
|
.domain-requests__table {
|
||||||
th:nth-of-type(1) {
|
th:nth-of-type(1) {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
|
@ -122,7 +122,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1040px){
|
@media (min-width: 1040px){
|
||||||
.dotgov-table__registered-domains {
|
.domains__table {
|
||||||
th:nth-of-type(1) {
|
th:nth-of-type(1) {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -659,6 +659,7 @@ ALLOWED_HOSTS = [
|
||||||
"getgov-stable.app.cloud.gov",
|
"getgov-stable.app.cloud.gov",
|
||||||
"getgov-staging.app.cloud.gov",
|
"getgov-staging.app.cloud.gov",
|
||||||
"getgov-development.app.cloud.gov",
|
"getgov-development.app.cloud.gov",
|
||||||
|
"getgov-ag.app.cloud.gov",
|
||||||
"getgov-litterbox.app.cloud.gov",
|
"getgov-litterbox.app.cloud.gov",
|
||||||
"getgov-hotgov.app.cloud.gov",
|
"getgov-hotgov.app.cloud.gov",
|
||||||
"getgov-cb.app.cloud.gov",
|
"getgov-cb.app.cloud.gov",
|
||||||
|
|
|
@ -18,6 +18,7 @@ from registrar.views.admin_views import (
|
||||||
ExportDataType,
|
ExportDataType,
|
||||||
ExportDataUnmanagedDomains,
|
ExportDataUnmanagedDomains,
|
||||||
AnalyticsView,
|
AnalyticsView,
|
||||||
|
ExportDomainRequestDataFull,
|
||||||
)
|
)
|
||||||
|
|
||||||
from registrar.views.domain_request import Step
|
from registrar.views.domain_request import Step
|
||||||
|
@ -66,6 +67,11 @@ urlpatterns = [
|
||||||
ExportDataType.as_view(),
|
ExportDataType.as_view(),
|
||||||
name="export_data_type",
|
name="export_data_type",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"admin/analytics/export_data_domain_requests_full/",
|
||||||
|
ExportDomainRequestDataFull.as_view(),
|
||||||
|
name="export_data_domain_requests_full",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"admin/analytics/export_data_full/",
|
"admin/analytics/export_data_full/",
|
||||||
ExportDataFull.as_view(),
|
ExportDataFull.as_view(),
|
||||||
|
|
|
@ -106,6 +106,12 @@ class UserFixture:
|
||||||
"last_name": "Orr",
|
"last_name": "Orr",
|
||||||
"email": "riley+320@truss.works",
|
"email": "riley+320@truss.works",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"username": "76612d84-66b0-4ae9-9870-81e98b9858b6",
|
||||||
|
"first_name": "Anna",
|
||||||
|
"last_name": "Gingle",
|
||||||
|
"email": "annagingle@truss.works",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
STAFF = [
|
STAFF = [
|
||||||
|
@ -194,6 +200,12 @@ class UserFixture:
|
||||||
"last_name": "Orr-Analyst",
|
"last_name": "Orr-Analyst",
|
||||||
"email": "riley+321@truss.works",
|
"email": "riley+321@truss.works",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"username": "e1e350b1-cfc1-4753-a6cb-3ae6d912f99c",
|
||||||
|
"first_name": "Anna-Analyst",
|
||||||
|
"last_name": "Gingle-Analyst",
|
||||||
|
"email": "annagingle+analyst@truss.works",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
def load_users(cls, users, group_name, are_superusers=False):
|
def load_users(cls, users, group_name, are_superusers=False):
|
||||||
|
|
|
@ -16,6 +16,7 @@ from registrar.forms.utility.wizard_form_helper import (
|
||||||
from registrar.models import Contact, DomainRequest, DraftDomain, Domain, FederalAgency
|
from registrar.models import Contact, DomainRequest, DraftDomain, Domain, FederalAgency
|
||||||
from registrar.templatetags.url_helpers import public_site_url
|
from registrar.templatetags.url_helpers import public_site_url
|
||||||
from registrar.utility.enums import ValidationReturnType
|
from registrar.utility.enums import ValidationReturnType
|
||||||
|
from registrar.utility.constants import BranchChoices
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -67,7 +68,7 @@ class TribalGovernmentForm(RegistrarForm):
|
||||||
|
|
||||||
class OrganizationFederalForm(RegistrarForm):
|
class OrganizationFederalForm(RegistrarForm):
|
||||||
federal_type = forms.ChoiceField(
|
federal_type = forms.ChoiceField(
|
||||||
choices=DomainRequest.BranchChoices.choices,
|
choices=BranchChoices.choices,
|
||||||
widget=forms.RadioSelect,
|
widget=forms.RadioSelect,
|
||||||
error_messages={"required": ("Select the part of the federal government your organization is in.")},
|
error_messages={"required": ("Select the part of the federal government your organization is in.")},
|
||||||
)
|
)
|
||||||
|
@ -647,20 +648,27 @@ class NoOtherContactsForm(BaseDeletableRegistrarForm):
|
||||||
|
|
||||||
|
|
||||||
class CisaRepresentativeForm(BaseDeletableRegistrarForm):
|
class CisaRepresentativeForm(BaseDeletableRegistrarForm):
|
||||||
|
cisa_representative_first_name = forms.CharField(
|
||||||
|
label="First name / given name",
|
||||||
|
error_messages={"required": "Enter the first name / given name of the CISA regional representative."},
|
||||||
|
)
|
||||||
|
cisa_representative_last_name = forms.CharField(
|
||||||
|
label="Last name / family name",
|
||||||
|
error_messages={"required": "Enter the last name / family name of the CISA regional representative."},
|
||||||
|
)
|
||||||
cisa_representative_email = forms.EmailField(
|
cisa_representative_email = forms.EmailField(
|
||||||
required=True,
|
label="Your representative’s email (optional)",
|
||||||
max_length=None,
|
max_length=None,
|
||||||
label="Your representative’s email",
|
required=False,
|
||||||
|
error_messages={
|
||||||
|
"invalid": ("Enter your representative’s email address in the required format, like name@example.com."),
|
||||||
|
},
|
||||||
validators=[
|
validators=[
|
||||||
MaxLengthValidator(
|
MaxLengthValidator(
|
||||||
320,
|
320,
|
||||||
message="Response must be less than 320 characters.",
|
message="Response must be less than 320 characters.",
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
error_messages={
|
|
||||||
"invalid": ("Enter your email address in the required format, like name@example.com."),
|
|
||||||
"required": ("Enter the email address of your CISA regional representative."),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -47,7 +47,7 @@ class UserProfileForm(forms.ModelForm):
|
||||||
self.fields["middle_name"].label = "Middle name (optional)"
|
self.fields["middle_name"].label = "Middle name (optional)"
|
||||||
self.fields["last_name"].label = "Last name / family name"
|
self.fields["last_name"].label = "Last name / family name"
|
||||||
self.fields["title"].label = "Title or role in your organization"
|
self.fields["title"].label = "Title or role in your organization"
|
||||||
self.fields["email"].label = "Organizational email"
|
self.fields["email"].label = "Organization email"
|
||||||
|
|
||||||
# Set custom error messages
|
# Set custom error messages
|
||||||
self.fields["first_name"].error_messages = {"required": "Enter your first name / given name."}
|
self.fields["first_name"].error_messages = {"required": "Enter your first name / given name."}
|
||||||
|
|
|
@ -19,6 +19,7 @@ from registrar.models.domain_request import DomainRequest
|
||||||
from registrar.models.domain_information import DomainInformation
|
from registrar.models.domain_information import DomainInformation
|
||||||
from registrar.models.user import User
|
from registrar.models.user import User
|
||||||
from registrar.models.federal_agency import FederalAgency
|
from registrar.models.federal_agency import FederalAgency
|
||||||
|
from registrar.utility.constants import BranchChoices
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -819,7 +820,7 @@ class Command(BaseCommand):
|
||||||
invitation.save()
|
invitation.save()
|
||||||
|
|
||||||
valid_org_choices = [(name, value) for name, value in DomainRequest.OrganizationChoices.choices]
|
valid_org_choices = [(name, value) for name, value in DomainRequest.OrganizationChoices.choices]
|
||||||
valid_fed_choices = [value for name, value in DomainRequest.BranchChoices.choices]
|
valid_fed_choices = [value for name, value in BranchChoices.choices]
|
||||||
valid_agency_choices = FederalAgency.objects.all()
|
valid_agency_choices = FederalAgency.objects.all()
|
||||||
# ======================================================
|
# ======================================================
|
||||||
# ================= DOMAIN INFORMATION =================
|
# ================= DOMAIN INFORMATION =================
|
||||||
|
|
24
src/registrar/migrations/0099_federalagency_federal_type.py
Normal file
24
src/registrar/migrations/0099_federalagency_federal_type.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
# Generated by Django 4.2.10 on 2024-06-11 15:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0098_alter_domainrequest_status"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="federalagency",
|
||||||
|
name="federal_type",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[("executive", "Executive"), ("judicial", "Judicial"), ("legislative", "Legislative")],
|
||||||
|
help_text="Federal agency type (executive, judicial, legislative, etc.)",
|
||||||
|
max_length=20,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Generated by Django 4.2.10 on 2024-06-12 14:46
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0099_federalagency_federal_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domainrequest",
|
||||||
|
name="action_needed_reason",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("eligibility_unclear", "Unclear organization eligibility"),
|
||||||
|
("questionable_authorizing_official", "Questionable authorizing official"),
|
||||||
|
("already_has_domains", "Already has domains"),
|
||||||
|
("bad_name", "Doesn’t meet naming requirements"),
|
||||||
|
("other", "Other (no auto-email sent)"),
|
||||||
|
],
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,69 @@
|
||||||
|
# Generated by Django 4.2.10 on 2024-06-12 20:50
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0100_domainrequest_action_needed_reason"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domaininformation",
|
||||||
|
name="cisa_representative_first_name",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True, db_index=True, null=True, verbose_name="CISA regional representative first name"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domaininformation",
|
||||||
|
name="cisa_representative_last_name",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True, db_index=True, null=True, verbose_name="CISA regional representative last name"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domaininformation",
|
||||||
|
name="has_anything_else_text",
|
||||||
|
field=models.BooleanField(
|
||||||
|
blank=True, help_text="Determines if the user has a anything_else or not", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domaininformation",
|
||||||
|
name="has_cisa_representative",
|
||||||
|
field=models.BooleanField(
|
||||||
|
blank=True, help_text="Determines if the user has a representative email or not", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domainrequest",
|
||||||
|
name="cisa_representative_first_name",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True, db_index=True, null=True, verbose_name="CISA regional representative first name"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domainrequest",
|
||||||
|
name="cisa_representative_last_name",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True, db_index=True, null=True, verbose_name="CISA regional representative last name"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="domaininformation",
|
||||||
|
name="cisa_representative_email",
|
||||||
|
field=models.EmailField(
|
||||||
|
blank=True, max_length=320, null=True, verbose_name="CISA regional representative email"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="domainrequest",
|
||||||
|
name="cisa_representative_email",
|
||||||
|
field=models.EmailField(
|
||||||
|
blank=True, max_length=320, null=True, verbose_name="CISA regional representative email"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
18
src/registrar/migrations/0102_domain_dsdata_last_change.py
Normal file
18
src/registrar/migrations/0102_domain_dsdata_last_change.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 4.2.10 on 2024-06-14 19:26
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0101_domaininformation_cisa_representative_first_name_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domain",
|
||||||
|
name="dsdata_last_change",
|
||||||
|
field=models.TextField(blank=True, help_text="Record of the last change event for ds data", null=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,174 @@
|
||||||
|
# Generated by Django 4.2.10 on 2024-06-18 17:55
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import registrar.models.federal_agency
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0102_domain_dsdata_last_change"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Portfolio",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("notes", models.TextField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"organization_type",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("federal", "Federal"),
|
||||||
|
("interstate", "Interstate"),
|
||||||
|
("state_or_territory", "State or territory"),
|
||||||
|
("tribal", "Tribal"),
|
||||||
|
("county", "County"),
|
||||||
|
("city", "City"),
|
||||||
|
("special_district", "Special district"),
|
||||||
|
("school_district", "School district"),
|
||||||
|
],
|
||||||
|
help_text="Type of organization",
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("organization_name", models.CharField(blank=True, null=True)),
|
||||||
|
("address_line1", models.CharField(blank=True, null=True, verbose_name="address line 1")),
|
||||||
|
("address_line2", models.CharField(blank=True, null=True, verbose_name="address line 2")),
|
||||||
|
("city", models.CharField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"state_territory",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("AL", "Alabama (AL)"),
|
||||||
|
("AK", "Alaska (AK)"),
|
||||||
|
("AS", "American Samoa (AS)"),
|
||||||
|
("AZ", "Arizona (AZ)"),
|
||||||
|
("AR", "Arkansas (AR)"),
|
||||||
|
("CA", "California (CA)"),
|
||||||
|
("CO", "Colorado (CO)"),
|
||||||
|
("CT", "Connecticut (CT)"),
|
||||||
|
("DE", "Delaware (DE)"),
|
||||||
|
("DC", "District of Columbia (DC)"),
|
||||||
|
("FL", "Florida (FL)"),
|
||||||
|
("GA", "Georgia (GA)"),
|
||||||
|
("GU", "Guam (GU)"),
|
||||||
|
("HI", "Hawaii (HI)"),
|
||||||
|
("ID", "Idaho (ID)"),
|
||||||
|
("IL", "Illinois (IL)"),
|
||||||
|
("IN", "Indiana (IN)"),
|
||||||
|
("IA", "Iowa (IA)"),
|
||||||
|
("KS", "Kansas (KS)"),
|
||||||
|
("KY", "Kentucky (KY)"),
|
||||||
|
("LA", "Louisiana (LA)"),
|
||||||
|
("ME", "Maine (ME)"),
|
||||||
|
("MD", "Maryland (MD)"),
|
||||||
|
("MA", "Massachusetts (MA)"),
|
||||||
|
("MI", "Michigan (MI)"),
|
||||||
|
("MN", "Minnesota (MN)"),
|
||||||
|
("MS", "Mississippi (MS)"),
|
||||||
|
("MO", "Missouri (MO)"),
|
||||||
|
("MT", "Montana (MT)"),
|
||||||
|
("NE", "Nebraska (NE)"),
|
||||||
|
("NV", "Nevada (NV)"),
|
||||||
|
("NH", "New Hampshire (NH)"),
|
||||||
|
("NJ", "New Jersey (NJ)"),
|
||||||
|
("NM", "New Mexico (NM)"),
|
||||||
|
("NY", "New York (NY)"),
|
||||||
|
("NC", "North Carolina (NC)"),
|
||||||
|
("ND", "North Dakota (ND)"),
|
||||||
|
("MP", "Northern Mariana Islands (MP)"),
|
||||||
|
("OH", "Ohio (OH)"),
|
||||||
|
("OK", "Oklahoma (OK)"),
|
||||||
|
("OR", "Oregon (OR)"),
|
||||||
|
("PA", "Pennsylvania (PA)"),
|
||||||
|
("PR", "Puerto Rico (PR)"),
|
||||||
|
("RI", "Rhode Island (RI)"),
|
||||||
|
("SC", "South Carolina (SC)"),
|
||||||
|
("SD", "South Dakota (SD)"),
|
||||||
|
("TN", "Tennessee (TN)"),
|
||||||
|
("TX", "Texas (TX)"),
|
||||||
|
("UM", "United States Minor Outlying Islands (UM)"),
|
||||||
|
("UT", "Utah (UT)"),
|
||||||
|
("VT", "Vermont (VT)"),
|
||||||
|
("VI", "Virgin Islands (VI)"),
|
||||||
|
("VA", "Virginia (VA)"),
|
||||||
|
("WA", "Washington (WA)"),
|
||||||
|
("WV", "West Virginia (WV)"),
|
||||||
|
("WI", "Wisconsin (WI)"),
|
||||||
|
("WY", "Wyoming (WY)"),
|
||||||
|
("AA", "Armed Forces Americas (AA)"),
|
||||||
|
("AE", "Armed Forces Africa, Canada, Europe, Middle East (AE)"),
|
||||||
|
("AP", "Armed Forces Pacific (AP)"),
|
||||||
|
],
|
||||||
|
max_length=2,
|
||||||
|
null=True,
|
||||||
|
verbose_name="state / territory",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("zipcode", models.CharField(blank=True, max_length=10, null=True, verbose_name="zip code")),
|
||||||
|
(
|
||||||
|
"urbanization",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, help_text="Required for Puerto Rico only", null=True, verbose_name="urbanization"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"security_contact_email",
|
||||||
|
models.EmailField(blank=True, max_length=320, null=True, verbose_name="security contact e-mail"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"creator",
|
||||||
|
models.ForeignKey(
|
||||||
|
help_text="Associated user",
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"federal_agency",
|
||||||
|
models.ForeignKey(
|
||||||
|
default=registrar.models.federal_agency.FederalAgency.get_non_federal_agency,
|
||||||
|
help_text="Associated federal agency",
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to="registrar.federalagency",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domaininformation",
|
||||||
|
name="portfolio",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="Portfolio associated with this domain",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="DomainRequest_portfolio",
|
||||||
|
to="registrar.portfolio",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domainrequest",
|
||||||
|
name="portfolio",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="Portfolio associated with this domain request",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="DomainInformation_portfolio",
|
||||||
|
to="registrar.portfolio",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
37
src/registrar/migrations/0104_create_groups_v13.py
Normal file
37
src/registrar/migrations/0104_create_groups_v13.py
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
# This migration creates the create_full_access_group and create_cisa_analyst_group groups
|
||||||
|
# It is dependent on 0079 (which populates federal agencies)
|
||||||
|
# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS
|
||||||
|
# in the user_group model then:
|
||||||
|
# [NOT RECOMMENDED]
|
||||||
|
# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions
|
||||||
|
# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups
|
||||||
|
# step 3: fake run the latest migration in the migrations list
|
||||||
|
# [RECOMMENDED]
|
||||||
|
# Alternatively:
|
||||||
|
# step 1: duplicate the migration that loads data
|
||||||
|
# step 2: docker-compose exec app ./manage.py migrate
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from registrar.models import UserGroup
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# For linting: RunPython expects a function reference,
|
||||||
|
# so let's give it one
|
||||||
|
def create_groups(apps, schema_editor) -> Any:
|
||||||
|
UserGroup.create_cisa_analyst_group(apps, schema_editor)
|
||||||
|
UserGroup.create_full_access_group(apps, schema_editor)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0103_portfolio_domaininformation_portfolio_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
create_groups,
|
||||||
|
reverse_code=migrations.RunPython.noop,
|
||||||
|
atomic=True,
|
||||||
|
),
|
||||||
|
]
|
|
@ -16,6 +16,7 @@ from .website import Website
|
||||||
from .transition_domain import TransitionDomain
|
from .transition_domain import TransitionDomain
|
||||||
from .verified_by_staff import VerifiedByStaff
|
from .verified_by_staff import VerifiedByStaff
|
||||||
from .waffle_flag import WaffleFlag
|
from .waffle_flag import WaffleFlag
|
||||||
|
from .portfolio import Portfolio
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
@ -36,6 +37,7 @@ __all__ = [
|
||||||
"TransitionDomain",
|
"TransitionDomain",
|
||||||
"VerifiedByStaff",
|
"VerifiedByStaff",
|
||||||
"WaffleFlag",
|
"WaffleFlag",
|
||||||
|
"Portfolio",
|
||||||
]
|
]
|
||||||
|
|
||||||
auditlog.register(Contact)
|
auditlog.register(Contact)
|
||||||
|
@ -55,3 +57,4 @@ auditlog.register(Website)
|
||||||
auditlog.register(TransitionDomain)
|
auditlog.register(TransitionDomain)
|
||||||
auditlog.register(VerifiedByStaff)
|
auditlog.register(VerifiedByStaff)
|
||||||
auditlog.register(WaffleFlag)
|
auditlog.register(WaffleFlag)
|
||||||
|
auditlog.register(Portfolio)
|
||||||
|
|
|
@ -40,6 +40,8 @@ from .utility.time_stamped_model import TimeStampedModel
|
||||||
|
|
||||||
from .public_contact import PublicContact
|
from .public_contact import PublicContact
|
||||||
|
|
||||||
|
from .user_domain_role import UserDomainRole
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@ -672,11 +674,29 @@ class Domain(TimeStampedModel, DomainHelper):
|
||||||
remRequest = commands.UpdateDomain(name=self.name)
|
remRequest = commands.UpdateDomain(name=self.name)
|
||||||
remExtension = commands.UpdateDomainDNSSECExtension(**remParams)
|
remExtension = commands.UpdateDomainDNSSECExtension(**remParams)
|
||||||
remRequest.add_extension(remExtension)
|
remRequest.add_extension(remExtension)
|
||||||
|
dsdata_change_log = ""
|
||||||
|
|
||||||
|
# Get the user's email
|
||||||
|
user_domain_role = UserDomainRole.objects.filter(domain=self).first()
|
||||||
|
user_email = user_domain_role.user.email if user_domain_role else "unknown user"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if "dsData" in _addDnssecdata and _addDnssecdata["dsData"] is not None:
|
added_record = "dsData" in _addDnssecdata and _addDnssecdata["dsData"] is not None
|
||||||
|
deleted_record = "dsData" in _remDnssecdata and _remDnssecdata["dsData"] is not None
|
||||||
|
|
||||||
|
if added_record:
|
||||||
registry.send(addRequest, cleaned=True)
|
registry.send(addRequest, cleaned=True)
|
||||||
if "dsData" in _remDnssecdata and _remDnssecdata["dsData"] is not None:
|
dsdata_change_log = f"{user_email} added a DS data record"
|
||||||
|
if deleted_record:
|
||||||
registry.send(remRequest, cleaned=True)
|
registry.send(remRequest, cleaned=True)
|
||||||
|
if dsdata_change_log != "": # if they add and remove a record at same time
|
||||||
|
dsdata_change_log = f"{user_email} added and deleted a DS data record"
|
||||||
|
else:
|
||||||
|
dsdata_change_log = f"{user_email} deleted a DS data record"
|
||||||
|
if dsdata_change_log != "":
|
||||||
|
self.dsdata_last_change = dsdata_change_log
|
||||||
|
self.save() # audit log will now record this as a change
|
||||||
|
|
||||||
except RegistryError as e:
|
except RegistryError as e:
|
||||||
logger.error("Error updating DNSSEC, code was %s error was %s" % (e.code, e))
|
logger.error("Error updating DNSSEC, code was %s error was %s" % (e.code, e))
|
||||||
raise e
|
raise e
|
||||||
|
@ -1057,6 +1077,12 @@ class Domain(TimeStampedModel, DomainHelper):
|
||||||
verbose_name="first ready on",
|
verbose_name="first ready on",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
dsdata_last_change = TextField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Record of the last change event for ds data",
|
||||||
|
)
|
||||||
|
|
||||||
def isActive(self):
|
def isActive(self):
|
||||||
return self.state == Domain.State.CREATED
|
return self.state == Domain.State.CREATED
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,8 @@ from django.db import transaction
|
||||||
|
|
||||||
from registrar.models.utility.domain_helper import DomainHelper
|
from registrar.models.utility.domain_helper import DomainHelper
|
||||||
from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
|
from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
|
||||||
|
from registrar.utility.constants import BranchChoices
|
||||||
|
|
||||||
from .domain_request import DomainRequest
|
from .domain_request import DomainRequest
|
||||||
from .utility.time_stamped_model import TimeStampedModel
|
from .utility.time_stamped_model import TimeStampedModel
|
||||||
|
|
||||||
|
@ -37,8 +39,6 @@ class DomainInformation(TimeStampedModel):
|
||||||
# use the short names in Django admin
|
# use the short names in Django admin
|
||||||
OrganizationChoices = DomainRequest.OrganizationChoices
|
OrganizationChoices = DomainRequest.OrganizationChoices
|
||||||
|
|
||||||
BranchChoices = DomainRequest.BranchChoices
|
|
||||||
|
|
||||||
federal_agency = models.ForeignKey(
|
federal_agency = models.ForeignKey(
|
||||||
"registrar.FederalAgency",
|
"registrar.FederalAgency",
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
|
@ -57,6 +57,16 @@ class DomainInformation(TimeStampedModel):
|
||||||
help_text="Person who submitted the domain request",
|
help_text="Person who submitted the domain request",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# portfolio
|
||||||
|
portfolio = models.ForeignKey(
|
||||||
|
"registrar.Portfolio",
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="DomainRequest_portfolio",
|
||||||
|
help_text="Portfolio associated with this domain",
|
||||||
|
)
|
||||||
|
|
||||||
domain_request = models.OneToOneField(
|
domain_request = models.OneToOneField(
|
||||||
"registrar.DomainRequest",
|
"registrar.DomainRequest",
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
|
@ -214,13 +224,45 @@ class DomainInformation(TimeStampedModel):
|
||||||
verbose_name="Additional details",
|
verbose_name="Additional details",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# This is a drop-in replacement for a has_anything_else_text() function.
|
||||||
|
# In order to track if the user has clicked the yes/no field (while keeping a none default), we need
|
||||||
|
# a tertiary state. We should not display this in /admin.
|
||||||
|
has_anything_else_text = models.BooleanField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Determines if the user has a anything_else or not",
|
||||||
|
)
|
||||||
|
|
||||||
cisa_representative_email = models.EmailField(
|
cisa_representative_email = models.EmailField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
verbose_name="CISA regional representative",
|
verbose_name="CISA regional representative email",
|
||||||
max_length=320,
|
max_length=320,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cisa_representative_first_name = models.CharField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="CISA regional representative first name",
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
cisa_representative_last_name = models.CharField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="CISA regional representative last name",
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# This is a drop-in replacement for an has_cisa_representative() function.
|
||||||
|
# In order to track if the user has clicked the yes/no field (while keeping a none default), we need
|
||||||
|
# a tertiary state. We should not display this in /admin.
|
||||||
|
has_cisa_representative = models.BooleanField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Determines if the user has a representative email or not",
|
||||||
|
)
|
||||||
|
|
||||||
is_policy_acknowledged = models.BooleanField(
|
is_policy_acknowledged = models.BooleanField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
|
@ -241,6 +283,30 @@ class DomainInformation(TimeStampedModel):
|
||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
def sync_yes_no_form_fields(self):
|
||||||
|
"""Some yes/no forms use a db field to track whether it was checked or not.
|
||||||
|
We handle that here for def save().
|
||||||
|
"""
|
||||||
|
# This ensures that if we have prefilled data, the form is prepopulated
|
||||||
|
if self.cisa_representative_first_name is not None or self.cisa_representative_last_name is not None:
|
||||||
|
self.has_cisa_representative = (
|
||||||
|
self.cisa_representative_first_name != "" and self.cisa_representative_last_name != ""
|
||||||
|
)
|
||||||
|
|
||||||
|
# This check is required to ensure that the form doesn't start out checked
|
||||||
|
if self.has_cisa_representative is not None:
|
||||||
|
self.has_cisa_representative = (
|
||||||
|
self.cisa_representative_first_name != "" and self.cisa_representative_first_name is not None
|
||||||
|
) and (self.cisa_representative_last_name != "" and self.cisa_representative_last_name is not None)
|
||||||
|
|
||||||
|
# This ensures that if we have prefilled data, the form is prepopulated
|
||||||
|
if self.anything_else is not None:
|
||||||
|
self.has_anything_else_text = self.anything_else != ""
|
||||||
|
|
||||||
|
# This check is required to ensure that the form doesn't start out checked.
|
||||||
|
if self.has_anything_else_text is not None:
|
||||||
|
self.has_anything_else_text = self.anything_else != "" and self.anything_else is not None
|
||||||
|
|
||||||
def sync_organization_type(self):
|
def sync_organization_type(self):
|
||||||
"""
|
"""
|
||||||
Updates the organization_type (without saving) to match
|
Updates the organization_type (without saving) to match
|
||||||
|
@ -275,6 +341,7 @@ class DomainInformation(TimeStampedModel):
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Save override for custom properties"""
|
"""Save override for custom properties"""
|
||||||
|
self.sync_yes_no_form_fields()
|
||||||
self.sync_organization_type()
|
self.sync_organization_type()
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import Union
|
from typing import Union
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
|
@ -12,13 +11,12 @@ from registrar.models.domain import Domain
|
||||||
from registrar.models.federal_agency import FederalAgency
|
from registrar.models.federal_agency import FederalAgency
|
||||||
from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
|
from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
|
||||||
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
|
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
|
||||||
|
from registrar.utility.constants import BranchChoices
|
||||||
|
|
||||||
from .utility.time_stamped_model import TimeStampedModel
|
from .utility.time_stamped_model import TimeStampedModel
|
||||||
from ..utility.email import send_templated_email, EmailSendingError
|
from ..utility.email import send_templated_email, EmailSendingError
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
|
|
||||||
from auditlog.models import AuditlogHistoryField # type: ignore
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@ -35,11 +33,7 @@ class DomainRequest(TimeStampedModel):
|
||||||
]
|
]
|
||||||
|
|
||||||
# https://django-auditlog.readthedocs.io/en/latest/usage.html#object-history
|
# https://django-auditlog.readthedocs.io/en/latest/usage.html#object-history
|
||||||
# If we note any performace degradation due to this addition,
|
# history = AuditlogHistoryField()
|
||||||
# we can query the auditlogs table in admin.py and add the results to
|
|
||||||
# extra_context in the change_view method for DomainRequestAdmin.
|
|
||||||
# This is the more straightforward way so trying it first.
|
|
||||||
history = AuditlogHistoryField()
|
|
||||||
|
|
||||||
# Constants for choice fields
|
# Constants for choice fields
|
||||||
class DomainRequestStatus(models.TextChoices):
|
class DomainRequestStatus(models.TextChoices):
|
||||||
|
@ -52,6 +46,11 @@ class DomainRequest(TimeStampedModel):
|
||||||
WITHDRAWN = "withdrawn", "Withdrawn"
|
WITHDRAWN = "withdrawn", "Withdrawn"
|
||||||
STARTED = "started", "Started"
|
STARTED = "started", "Started"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_status_label(cls, status_name: str):
|
||||||
|
"""Returns the associated label for a given status name"""
|
||||||
|
return cls(status_name).label if status_name else None
|
||||||
|
|
||||||
class StateTerritoryChoices(models.TextChoices):
|
class StateTerritoryChoices(models.TextChoices):
|
||||||
ALABAMA = "AL", "Alabama (AL)"
|
ALABAMA = "AL", "Alabama (AL)"
|
||||||
ALASKA = "AK", "Alaska (AK)"
|
ALASKA = "AK", "Alaska (AK)"
|
||||||
|
@ -133,6 +132,14 @@ class DomainRequest(TimeStampedModel):
|
||||||
SPECIAL_DISTRICT = "special_district", "Special district"
|
SPECIAL_DISTRICT = "special_district", "Special district"
|
||||||
SCHOOL_DISTRICT = "school_district", "School district"
|
SCHOOL_DISTRICT = "school_district", "School district"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_org_label(cls, org_name: str):
|
||||||
|
"""Returns the associated label for a given org name"""
|
||||||
|
org_names = org_name.split("_election")
|
||||||
|
if len(org_names) > 0:
|
||||||
|
org_name = org_names[0]
|
||||||
|
return cls(org_name).label if org_name else None
|
||||||
|
|
||||||
class OrgChoicesElectionOffice(models.TextChoices):
|
class OrgChoicesElectionOffice(models.TextChoices):
|
||||||
"""
|
"""
|
||||||
Primary organization choices for Django admin:
|
Primary organization choices for Django admin:
|
||||||
|
@ -234,11 +241,6 @@ class DomainRequest(TimeStampedModel):
|
||||||
"School district: a school district that is not part of a local government",
|
"School district: a school district that is not part of a local government",
|
||||||
)
|
)
|
||||||
|
|
||||||
class BranchChoices(models.TextChoices):
|
|
||||||
EXECUTIVE = "executive", "Executive"
|
|
||||||
JUDICIAL = "judicial", "Judicial"
|
|
||||||
LEGISLATIVE = "legislative", "Legislative"
|
|
||||||
|
|
||||||
class RejectionReasons(models.TextChoices):
|
class RejectionReasons(models.TextChoices):
|
||||||
DOMAIN_PURPOSE = "purpose_not_met", "Purpose requirements not met"
|
DOMAIN_PURPOSE = "purpose_not_met", "Purpose requirements not met"
|
||||||
REQUESTOR = "requestor_not_eligible", "Requestor not eligible to make request"
|
REQUESTOR = "requestor_not_eligible", "Requestor not eligible to make request"
|
||||||
|
@ -254,6 +256,25 @@ class DomainRequest(TimeStampedModel):
|
||||||
NAMING_REQUIREMENTS = "naming_not_met", "Naming requirements not met"
|
NAMING_REQUIREMENTS = "naming_not_met", "Naming requirements not met"
|
||||||
OTHER = "other", "Other/Unspecified"
|
OTHER = "other", "Other/Unspecified"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_rejection_reason_label(cls, rejection_reason: str):
|
||||||
|
"""Returns the associated label for a given rejection reason"""
|
||||||
|
return cls(rejection_reason).label if rejection_reason else None
|
||||||
|
|
||||||
|
class ActionNeededReasons(models.TextChoices):
|
||||||
|
"""Defines common action needed reasons for domain requests"""
|
||||||
|
|
||||||
|
ELIGIBILITY_UNCLEAR = ("eligibility_unclear", "Unclear organization eligibility")
|
||||||
|
QUESTIONABLE_AUTHORIZING_OFFICIAL = ("questionable_authorizing_official", "Questionable authorizing official")
|
||||||
|
ALREADY_HAS_DOMAINS = ("already_has_domains", "Already has domains")
|
||||||
|
BAD_NAME = ("bad_name", "Doesn’t meet naming requirements")
|
||||||
|
OTHER = ("other", "Other (no auto-email sent)")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_action_needed_reason_label(cls, action_needed_reason: str):
|
||||||
|
"""Returns the associated label for a given action needed reason"""
|
||||||
|
return cls(action_needed_reason).label if action_needed_reason else None
|
||||||
|
|
||||||
# #### Internal fields about the domain request #####
|
# #### Internal fields about the domain request #####
|
||||||
status = FSMField(
|
status = FSMField(
|
||||||
choices=DomainRequestStatus.choices, # possible states as an array of constants
|
choices=DomainRequestStatus.choices, # possible states as an array of constants
|
||||||
|
@ -267,6 +288,12 @@ class DomainRequest(TimeStampedModel):
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
action_needed_reason = models.TextField(
|
||||||
|
choices=ActionNeededReasons.choices,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
federal_agency = models.ForeignKey(
|
federal_agency = models.ForeignKey(
|
||||||
"registrar.FederalAgency",
|
"registrar.FederalAgency",
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
|
@ -276,6 +303,16 @@ class DomainRequest(TimeStampedModel):
|
||||||
null=True,
|
null=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# portfolio
|
||||||
|
portfolio = models.ForeignKey(
|
||||||
|
"registrar.Portfolio",
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="DomainInformation_portfolio",
|
||||||
|
help_text="Portfolio associated with this domain request",
|
||||||
|
)
|
||||||
|
|
||||||
# This is the domain request user who created this domain request. The contact
|
# This is the domain request user who created this domain request. The contact
|
||||||
# information that they gave is in the `submitter` field
|
# information that they gave is in the `submitter` field
|
||||||
creator = models.ForeignKey(
|
creator = models.ForeignKey(
|
||||||
|
@ -467,10 +504,24 @@ class DomainRequest(TimeStampedModel):
|
||||||
cisa_representative_email = models.EmailField(
|
cisa_representative_email = models.EmailField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
verbose_name="CISA regional representative",
|
verbose_name="CISA regional representative email",
|
||||||
max_length=320,
|
max_length=320,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cisa_representative_first_name = models.CharField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="CISA regional representative first name",
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
cisa_representative_last_name = models.CharField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="CISA regional representative last name",
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
|
||||||
# This is a drop-in replacement for an has_cisa_representative() function.
|
# This is a drop-in replacement for an has_cisa_representative() function.
|
||||||
# In order to track if the user has clicked the yes/no field (while keeping a none default), we need
|
# In order to track if the user has clicked the yes/no field (while keeping a none default), we need
|
||||||
# a tertiary state. We should not display this in /admin.
|
# a tertiary state. We should not display this in /admin.
|
||||||
|
@ -529,6 +580,16 @@ class DomainRequest(TimeStampedModel):
|
||||||
# Actually updates the organization_type field
|
# Actually updates the organization_type field
|
||||||
org_type_helper.create_or_update_organization_type()
|
org_type_helper.create_or_update_organization_type()
|
||||||
|
|
||||||
|
def _cache_status_and_action_needed_reason(self):
|
||||||
|
"""Maintains a cache of properties so we can avoid a DB call"""
|
||||||
|
self._cached_action_needed_reason = self.action_needed_reason
|
||||||
|
self._cached_status = self.status
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
# Store original values for caching purposes. Used to compare them on save.
|
||||||
|
self._cache_status_and_action_needed_reason()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Save override for custom properties"""
|
"""Save override for custom properties"""
|
||||||
self.sync_organization_type()
|
self.sync_organization_type()
|
||||||
|
@ -536,20 +597,38 @@ class DomainRequest(TimeStampedModel):
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# Handle the action needed email. We send one when moving to action_needed,
|
||||||
|
# but we don't send one when we are _already_ in the state and change the reason.
|
||||||
|
self.sync_action_needed_reason()
|
||||||
|
|
||||||
|
# Update the cached values after saving
|
||||||
|
self._cache_status_and_action_needed_reason()
|
||||||
|
|
||||||
|
def sync_action_needed_reason(self):
|
||||||
|
"""Checks if we need to send another action needed email"""
|
||||||
|
was_already_action_needed = self._cached_status == self.DomainRequestStatus.ACTION_NEEDED
|
||||||
|
reason_exists = self._cached_action_needed_reason is not None and self.action_needed_reason is not None
|
||||||
|
reason_changed = self._cached_action_needed_reason != self.action_needed_reason
|
||||||
|
if was_already_action_needed and (reason_exists and reason_changed):
|
||||||
|
# We don't send emails out in state "other"
|
||||||
|
if self.action_needed_reason != self.ActionNeededReasons.OTHER:
|
||||||
|
self._send_action_needed_reason_email()
|
||||||
|
|
||||||
def sync_yes_no_form_fields(self):
|
def sync_yes_no_form_fields(self):
|
||||||
"""Some yes/no forms use a db field to track whether it was checked or not.
|
"""Some yes/no forms use a db field to track whether it was checked or not.
|
||||||
We handle that here for def save().
|
We handle that here for def save().
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# This ensures that if we have prefilled data, the form is prepopulated
|
# This ensures that if we have prefilled data, the form is prepopulated
|
||||||
if self.cisa_representative_email is not None:
|
if self.cisa_representative_first_name is not None or self.cisa_representative_last_name is not None:
|
||||||
self.has_cisa_representative = self.cisa_representative_email != ""
|
self.has_cisa_representative = (
|
||||||
|
self.cisa_representative_first_name != "" and self.cisa_representative_last_name != ""
|
||||||
|
)
|
||||||
|
|
||||||
# This check is required to ensure that the form doesn't start out checked
|
# This check is required to ensure that the form doesn't start out checked
|
||||||
if self.has_cisa_representative is not None:
|
if self.has_cisa_representative is not None:
|
||||||
self.has_cisa_representative = (
|
self.has_cisa_representative = (
|
||||||
self.cisa_representative_email != "" and self.cisa_representative_email is not None
|
self.cisa_representative_first_name != "" and self.cisa_representative_first_name is not None
|
||||||
)
|
) and (self.cisa_representative_last_name != "" and self.cisa_representative_last_name is not None)
|
||||||
|
|
||||||
# This ensures that if we have prefilled data, the form is prepopulated
|
# This ensures that if we have prefilled data, the form is prepopulated
|
||||||
if self.anything_else is not None:
|
if self.anything_else is not None:
|
||||||
|
@ -587,7 +666,7 @@ class DomainRequest(TimeStampedModel):
|
||||||
logger.error(f"Can't query an approved domain while attempting {called_from}")
|
logger.error(f"Can't query an approved domain while attempting {called_from}")
|
||||||
|
|
||||||
def _send_status_update_email(
|
def _send_status_update_email(
|
||||||
self, new_status, email_template, email_template_subject, send_email=True, bcc_address=""
|
self, new_status, email_template, email_template_subject, send_email=True, bcc_address="", wrap_email=False
|
||||||
):
|
):
|
||||||
"""Send a status update email to the submitter.
|
"""Send a status update email to the submitter.
|
||||||
|
|
||||||
|
@ -614,6 +693,7 @@ class DomainRequest(TimeStampedModel):
|
||||||
self.submitter.email,
|
self.submitter.email,
|
||||||
context={"domain_request": self},
|
context={"domain_request": self},
|
||||||
bcc_address=bcc_address,
|
bcc_address=bcc_address,
|
||||||
|
wrap_email=wrap_email,
|
||||||
)
|
)
|
||||||
logger.info(f"The {new_status} email sent to: {self.submitter.email}")
|
logger.info(f"The {new_status} email sent to: {self.submitter.email}")
|
||||||
except EmailSendingError:
|
except EmailSendingError:
|
||||||
|
@ -697,9 +777,10 @@ class DomainRequest(TimeStampedModel):
|
||||||
|
|
||||||
if self.status == self.DomainRequestStatus.APPROVED:
|
if self.status == self.DomainRequestStatus.APPROVED:
|
||||||
self.delete_and_clean_up_domain("in_review")
|
self.delete_and_clean_up_domain("in_review")
|
||||||
|
elif self.status == self.DomainRequestStatus.REJECTED:
|
||||||
if self.status == self.DomainRequestStatus.REJECTED:
|
|
||||||
self.rejection_reason = None
|
self.rejection_reason = None
|
||||||
|
elif self.status == self.DomainRequestStatus.ACTION_NEEDED:
|
||||||
|
self.action_needed_reason = None
|
||||||
|
|
||||||
literal = DomainRequest.DomainRequestStatus.IN_REVIEW
|
literal = DomainRequest.DomainRequestStatus.IN_REVIEW
|
||||||
# Check if the tuple exists, then grab its value
|
# Check if the tuple exists, then grab its value
|
||||||
|
@ -717,7 +798,7 @@ class DomainRequest(TimeStampedModel):
|
||||||
target=DomainRequestStatus.ACTION_NEEDED,
|
target=DomainRequestStatus.ACTION_NEEDED,
|
||||||
conditions=[domain_is_not_active, investigator_exists_and_is_staff],
|
conditions=[domain_is_not_active, investigator_exists_and_is_staff],
|
||||||
)
|
)
|
||||||
def action_needed(self):
|
def action_needed(self, send_email=True):
|
||||||
"""Send back an domain request that is under investigation or rejected.
|
"""Send back an domain request that is under investigation or rejected.
|
||||||
|
|
||||||
This action is logged.
|
This action is logged.
|
||||||
|
@ -729,8 +810,7 @@ class DomainRequest(TimeStampedModel):
|
||||||
|
|
||||||
if self.status == self.DomainRequestStatus.APPROVED:
|
if self.status == self.DomainRequestStatus.APPROVED:
|
||||||
self.delete_and_clean_up_domain("reject_with_prejudice")
|
self.delete_and_clean_up_domain("reject_with_prejudice")
|
||||||
|
elif self.status == self.DomainRequestStatus.REJECTED:
|
||||||
if self.status == self.DomainRequestStatus.REJECTED:
|
|
||||||
self.rejection_reason = None
|
self.rejection_reason = None
|
||||||
|
|
||||||
literal = DomainRequest.DomainRequestStatus.ACTION_NEEDED
|
literal = DomainRequest.DomainRequestStatus.ACTION_NEEDED
|
||||||
|
@ -738,6 +818,46 @@ class DomainRequest(TimeStampedModel):
|
||||||
action_needed = literal if literal is not None else "Action Needed"
|
action_needed = literal if literal is not None else "Action Needed"
|
||||||
logger.info(f"A status change occurred. {self} was changed to '{action_needed}'")
|
logger.info(f"A status change occurred. {self} was changed to '{action_needed}'")
|
||||||
|
|
||||||
|
# Send out an email if an action needed reason exists
|
||||||
|
if self.action_needed_reason and self.action_needed_reason != self.ActionNeededReasons.OTHER:
|
||||||
|
self._send_action_needed_reason_email(send_email)
|
||||||
|
|
||||||
|
def _send_action_needed_reason_email(self, send_email=True):
|
||||||
|
"""Sends out an automatic email for each valid action needed reason provided"""
|
||||||
|
|
||||||
|
# Store the filenames of the template and template subject
|
||||||
|
email_template_name: str = ""
|
||||||
|
email_template_subject_name: str = ""
|
||||||
|
|
||||||
|
# Check for the "type" of action needed reason.
|
||||||
|
can_send_email = True
|
||||||
|
match self.action_needed_reason:
|
||||||
|
# Add to this match if you need to pass in a custom filename for these templates.
|
||||||
|
case self.ActionNeededReasons.OTHER, _:
|
||||||
|
# Unknown and other are default cases - do nothing
|
||||||
|
can_send_email = False
|
||||||
|
|
||||||
|
# Assumes that the template name matches the action needed reason if nothing is specified.
|
||||||
|
# This is so you can override if you need, or have this taken care of for you.
|
||||||
|
if not email_template_name and not email_template_subject_name:
|
||||||
|
email_template_name = f"{self.action_needed_reason}.txt"
|
||||||
|
email_template_subject_name = f"{self.action_needed_reason}_subject.txt"
|
||||||
|
|
||||||
|
bcc_address = ""
|
||||||
|
if settings.IS_PRODUCTION:
|
||||||
|
bcc_address = settings.DEFAULT_FROM_EMAIL
|
||||||
|
|
||||||
|
# If we can, try to send out an email as long as send_email=True
|
||||||
|
if can_send_email:
|
||||||
|
self._send_status_update_email(
|
||||||
|
new_status="action needed",
|
||||||
|
email_template=f"emails/action_needed_reasons/{email_template_name}",
|
||||||
|
email_template_subject=f"emails/action_needed_reasons/{email_template_subject_name}",
|
||||||
|
send_email=send_email,
|
||||||
|
bcc_address=bcc_address,
|
||||||
|
wrap_email=True,
|
||||||
|
)
|
||||||
|
|
||||||
@transition(
|
@transition(
|
||||||
field="status",
|
field="status",
|
||||||
source=[
|
source=[
|
||||||
|
@ -786,6 +906,8 @@ class DomainRequest(TimeStampedModel):
|
||||||
|
|
||||||
if self.status == self.DomainRequestStatus.REJECTED:
|
if self.status == self.DomainRequestStatus.REJECTED:
|
||||||
self.rejection_reason = None
|
self.rejection_reason = None
|
||||||
|
elif self.status == self.DomainRequestStatus.ACTION_NEEDED:
|
||||||
|
self.action_needed_reason = None
|
||||||
|
|
||||||
# == Send out an email == #
|
# == Send out an email == #
|
||||||
self._send_status_update_email(
|
self._send_status_update_email(
|
||||||
|
@ -904,11 +1026,12 @@ class DomainRequest(TimeStampedModel):
|
||||||
def has_additional_details(self) -> bool:
|
def has_additional_details(self) -> bool:
|
||||||
"""Combines the has_anything_else_text and has_cisa_representative fields,
|
"""Combines the has_anything_else_text and has_cisa_representative fields,
|
||||||
then returns if this domain request has either of them."""
|
then returns if this domain request has either of them."""
|
||||||
# Split out for linter
|
|
||||||
has_details = False
|
|
||||||
if self.has_anything_else_text or self.has_cisa_representative:
|
|
||||||
has_details = True
|
|
||||||
|
|
||||||
|
# Split out for linter
|
||||||
|
has_details = True
|
||||||
|
|
||||||
|
if self.has_anything_else_text is None or self.has_cisa_representative is None:
|
||||||
|
has_details = False
|
||||||
return has_details
|
return has_details
|
||||||
|
|
||||||
def is_federal(self) -> Union[bool, None]:
|
def is_federal(self) -> Union[bool, None]:
|
||||||
|
@ -1017,14 +1140,19 @@ class DomainRequest(TimeStampedModel):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _cisa_rep_and_email_check(self):
|
def _cisa_rep_check(self):
|
||||||
# Has a CISA rep + email is NOT empty or NOT an empty string OR doesn't have CISA rep
|
# Either does not have a CISA rep, OR has a CISA rep + both first name
|
||||||
return (
|
# and last name are NOT empty and are NOT an empty string
|
||||||
|
to_return = (
|
||||||
self.has_cisa_representative is True
|
self.has_cisa_representative is True
|
||||||
and self.cisa_representative_email is not None
|
and self.cisa_representative_first_name is not None
|
||||||
and self.cisa_representative_email != ""
|
and self.cisa_representative_first_name != ""
|
||||||
|
and self.cisa_representative_last_name is not None
|
||||||
|
and self.cisa_representative_last_name != ""
|
||||||
) or self.has_cisa_representative is False
|
) or self.has_cisa_representative is False
|
||||||
|
|
||||||
|
return to_return
|
||||||
|
|
||||||
def _anything_else_radio_button_and_text_field_check(self):
|
def _anything_else_radio_button_and_text_field_check(self):
|
||||||
# Anything else boolean is True + filled text field and it's not an empty string OR the boolean is No
|
# Anything else boolean is True + filled text field and it's not an empty string OR the boolean is No
|
||||||
return (
|
return (
|
||||||
|
@ -1032,7 +1160,7 @@ class DomainRequest(TimeStampedModel):
|
||||||
) or self.has_anything_else_text is False
|
) or self.has_anything_else_text is False
|
||||||
|
|
||||||
def _is_additional_details_complete(self):
|
def _is_additional_details_complete(self):
|
||||||
return self._cisa_rep_and_email_check() and self._anything_else_radio_button_and_text_field_check()
|
return self._cisa_rep_check() and self._anything_else_radio_button_and_text_field_check()
|
||||||
|
|
||||||
def _is_policy_acknowledgement_complete(self):
|
def _is_policy_acknowledgement_complete(self):
|
||||||
return self.is_policy_acknowledged is not None
|
return self.is_policy_acknowledged is not None
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
from .utility.time_stamped_model import TimeStampedModel
|
from .utility.time_stamped_model import TimeStampedModel
|
||||||
from django.db import models
|
from django.db import models
|
||||||
import logging
|
import logging
|
||||||
|
from registrar.utility.constants import BranchChoices
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -16,6 +17,14 @@ class FederalAgency(TimeStampedModel):
|
||||||
help_text="Federal agency",
|
help_text="Federal agency",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
federal_type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=BranchChoices.choices,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Federal agency type (executive, judicial, legislative, etc.)",
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.agency}"
|
return f"{self.agency}"
|
||||||
|
|
||||||
|
@ -221,3 +230,8 @@ class FederalAgency(TimeStampedModel):
|
||||||
FederalAgency.objects.bulk_create(agencies)
|
FederalAgency.objects.bulk_create(agencies)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating federal agencies: {e}")
|
logger.error(f"Error creating federal agencies: {e}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_non_federal_agency(cls):
|
||||||
|
"""Returns the non-federal agency."""
|
||||||
|
return FederalAgency.objects.filter(agency="Non-Federal Agency").first()
|
||||||
|
|
99
src/registrar/models/portfolio.py
Normal file
99
src/registrar/models/portfolio.py
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from registrar.models.domain_request import DomainRequest
|
||||||
|
from registrar.models.federal_agency import FederalAgency
|
||||||
|
|
||||||
|
from .utility.time_stamped_model import TimeStampedModel
|
||||||
|
|
||||||
|
|
||||||
|
# def get_default_federal_agency():
|
||||||
|
# """returns non-federal agency"""
|
||||||
|
# return FederalAgency.objects.filter(agency="Non-Federal Agency").first()
|
||||||
|
|
||||||
|
|
||||||
|
class Portfolio(TimeStampedModel):
|
||||||
|
"""
|
||||||
|
Portfolio is used for organizing domains/domain-requests into
|
||||||
|
manageable groups.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# use the short names in Django admin
|
||||||
|
OrganizationChoices = DomainRequest.OrganizationChoices
|
||||||
|
StateTerritoryChoices = DomainRequest.StateTerritoryChoices
|
||||||
|
|
||||||
|
# Stores who created this model. If no creator is specified in DJA,
|
||||||
|
# then the creator will default to the current request user"""
|
||||||
|
creator = models.ForeignKey("registrar.User", on_delete=models.PROTECT, help_text="Associated user", unique=False)
|
||||||
|
|
||||||
|
notes = models.TextField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
federal_agency = models.ForeignKey(
|
||||||
|
"registrar.FederalAgency",
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
help_text="Associated federal agency",
|
||||||
|
unique=False,
|
||||||
|
default=FederalAgency.get_non_federal_agency,
|
||||||
|
)
|
||||||
|
|
||||||
|
organization_type = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
choices=OrganizationChoices.choices,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Type of organization",
|
||||||
|
)
|
||||||
|
|
||||||
|
organization_name = models.CharField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
address_line1 = models.CharField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="address line 1",
|
||||||
|
)
|
||||||
|
|
||||||
|
address_line2 = models.CharField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="address line 2",
|
||||||
|
)
|
||||||
|
|
||||||
|
city = models.CharField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# (imports enums from domain_request.py)
|
||||||
|
state_territory = models.CharField(
|
||||||
|
max_length=2,
|
||||||
|
choices=StateTerritoryChoices.choices,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="state / territory",
|
||||||
|
)
|
||||||
|
|
||||||
|
zipcode = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="zip code",
|
||||||
|
)
|
||||||
|
|
||||||
|
urbanization = models.CharField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Required for Puerto Rico only",
|
||||||
|
verbose_name="urbanization",
|
||||||
|
)
|
||||||
|
|
||||||
|
security_contact_email = models.EmailField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="security contact e-mail",
|
||||||
|
max_length=320,
|
||||||
|
)
|
|
@ -298,3 +298,26 @@ def replace_url_queryparams(url_to_modify: str, query_params, convert_list_to_cs
|
||||||
new_url = urlunparse(url_parts)
|
new_url = urlunparse(url_parts)
|
||||||
|
|
||||||
return new_url
|
return new_url
|
||||||
|
|
||||||
|
|
||||||
|
def convert_queryset_to_dict(queryset, is_model=True, key="id"):
|
||||||
|
"""
|
||||||
|
Transforms a queryset into a dictionary keyed by a specified key (like "id").
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
requests (QuerySet or list of dicts): Input data.
|
||||||
|
is_model (bool): Indicates if each item in 'queryset' are model instances (True) or dictionaries (False).
|
||||||
|
key (str): Key or attribute to use for the resulting dictionary's keys.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Dictionary with keys derived from 'key' and values corresponding to items in 'queryset'.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if is_model:
|
||||||
|
request_dict = {getattr(value, key): value for value in queryset}
|
||||||
|
else:
|
||||||
|
# Querysets sometimes contain sets of dictionaries.
|
||||||
|
# Calling .values is an example of this.
|
||||||
|
request_dict = {value[key]: value for value in queryset}
|
||||||
|
|
||||||
|
return request_dict
|
||||||
|
|
|
@ -5,6 +5,7 @@ Contains middleware used in settings.py
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
|
from registrar.models.user import User
|
||||||
from waffle.decorators import flag_is_active
|
from waffle.decorators import flag_is_active
|
||||||
|
|
||||||
from registrar.models.utility.generic_helper import replace_url_queryparams
|
from registrar.models.utility.generic_helper import replace_url_queryparams
|
||||||
|
@ -38,10 +39,17 @@ class CheckUserProfileMiddleware:
|
||||||
self.get_response = get_response
|
self.get_response = get_response
|
||||||
|
|
||||||
self.setup_page = reverse("finish-user-profile-setup")
|
self.setup_page = reverse("finish-user-profile-setup")
|
||||||
|
self.profile_page = reverse("user-profile")
|
||||||
self.logout_page = reverse("logout")
|
self.logout_page = reverse("logout")
|
||||||
self.excluded_pages = [
|
self.regular_excluded_pages = [
|
||||||
self.setup_page,
|
self.setup_page,
|
||||||
self.logout_page,
|
self.logout_page,
|
||||||
|
"/admin",
|
||||||
|
]
|
||||||
|
self.other_excluded_pages = [
|
||||||
|
self.profile_page,
|
||||||
|
self.logout_page,
|
||||||
|
"/admin",
|
||||||
]
|
]
|
||||||
|
|
||||||
def __call__(self, request):
|
def __call__(self, request):
|
||||||
|
@ -61,12 +69,15 @@ class CheckUserProfileMiddleware:
|
||||||
|
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
if hasattr(request.user, "finished_setup") and not request.user.finished_setup:
|
if hasattr(request.user, "finished_setup") and not request.user.finished_setup:
|
||||||
return self._handle_setup_not_finished(request)
|
if request.user.verification_type == User.VerificationTypeChoices.REGULAR:
|
||||||
|
return self._handle_regular_user_setup_not_finished(request)
|
||||||
|
else:
|
||||||
|
return self._handle_other_user_setup_not_finished(request)
|
||||||
|
|
||||||
# Continue processing the view
|
# Continue processing the view
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _handle_setup_not_finished(self, request):
|
def _handle_regular_user_setup_not_finished(self, request):
|
||||||
"""Redirects the given user to the finish setup page.
|
"""Redirects the given user to the finish setup page.
|
||||||
|
|
||||||
We set the "redirect" query param equal to where the user wants to go.
|
We set the "redirect" query param equal to where the user wants to go.
|
||||||
|
@ -82,7 +93,7 @@ class CheckUserProfileMiddleware:
|
||||||
custom_redirect = "domain-request:" if request.path == "/request/" else None
|
custom_redirect = "domain-request:" if request.path == "/request/" else None
|
||||||
|
|
||||||
# Don't redirect on excluded pages (such as the setup page itself)
|
# Don't redirect on excluded pages (such as the setup page itself)
|
||||||
if not any(request.path.startswith(page) for page in self.excluded_pages):
|
if not any(request.path.startswith(page) for page in self.regular_excluded_pages):
|
||||||
|
|
||||||
# Preserve the original query parameters, and coerce them into a dict
|
# Preserve the original query parameters, and coerce them into a dict
|
||||||
query_params = parse_qs(request.META["QUERY_STRING"])
|
query_params = parse_qs(request.META["QUERY_STRING"])
|
||||||
|
@ -98,3 +109,13 @@ class CheckUserProfileMiddleware:
|
||||||
else:
|
else:
|
||||||
# Process the view as normal
|
# Process the view as normal
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _handle_other_user_setup_not_finished(self, request):
|
||||||
|
"""Redirects the given user to the profile page to finish setup."""
|
||||||
|
|
||||||
|
# Don't redirect on excluded pages (such as the setup page itself)
|
||||||
|
if not any(request.path.startswith(page) for page in self.other_excluded_pages):
|
||||||
|
return HttpResponseRedirect(self.profile_page)
|
||||||
|
else:
|
||||||
|
# Process the view as normal
|
||||||
|
return None
|
||||||
|
|
|
@ -27,28 +27,35 @@
|
||||||
<div class="module height-full">
|
<div class="module height-full">
|
||||||
<h2>Current domains</h2>
|
<h2>Current domains</h2>
|
||||||
<div class="padding-top-2 padding-x-2">
|
<div class="padding-top-2 padding-x-2">
|
||||||
<ul class="usa-button-group">
|
<ul class="usa-button-group wrapped-button-group">
|
||||||
<li class="usa-button-group__item">
|
<li class="usa-button-group__item">
|
||||||
<a href="{% url 'export_data_type' %}" class="button" role="button">
|
<a href="{% url 'export_data_type' %}" class="button text-no-wrap" role="button">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||||
</svg><span class="margin-left-05">All domain metadata</span>
|
</svg><span class="margin-left-05">All domain metadata</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="usa-button-group__item">
|
<li class="usa-button-group__item">
|
||||||
<a href="{% url 'export_data_full' %}" class="button" role="button">
|
<a href="{% url 'export_data_full' %}" class="button text-no-wrap" role="button">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||||
</svg><span class="margin-left-05">Current full</span>
|
</svg><span class="margin-left-05">Current full</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="usa-button-group__item">
|
<li class="usa-button-group__item">
|
||||||
<a href="{% url 'export_data_federal' %}" class="button" role="button">
|
<a href="{% url 'export_data_federal' %}" class="button text-no-wrap" role="button">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||||
</svg><span class="margin-left-05">Current federal</span>
|
</svg><span class="margin-left-05">Current federal</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="usa-button-group__item">
|
||||||
|
<a href="{% url 'export_data_domain_requests_full' %}" class="button text-no-wrap" role="button">
|
||||||
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
|
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||||
|
</svg><span class="margin-left-05">All domain requests metadata</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -68,42 +68,52 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
||||||
{% endblock field_readonly %}
|
{% endblock field_readonly %}
|
||||||
|
|
||||||
{% block after_help_text %}
|
{% block after_help_text %}
|
||||||
{% if field.field.name == "status" and original_object.history.count > 0 %}
|
{% if field.field.name == "status" and filtered_audit_log_entries %}
|
||||||
<div class="flex-container">
|
<div class="flex-container" id="dja-status-changelog">
|
||||||
<label aria-label="Submitter contact details"></label>
|
<label aria-label="Status changelog"></label>
|
||||||
<div>
|
<div>
|
||||||
<div class="usa-table-container--scrollable collapse--dgsimple" tabindex="0">
|
<div class="usa-table-container--scrollable collapse--dgsimple collapsed" tabindex="0">
|
||||||
<table class="usa-table usa-table--borderless">
|
<table class="usa-table usa-table--borderless">
|
||||||
<thead>
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Changed at</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in filtered_audit_log_entries %}
|
||||||
<tr>
|
<tr>
|
||||||
<th>Status</th>
|
<td>
|
||||||
<th>User</th>
|
{% if entry.status %}
|
||||||
<th>Changed at</th>
|
{{ entry.status|default:"Error" }}
|
||||||
</tr>
|
{% else %}
|
||||||
</thead>
|
Error
|
||||||
<tbody>
|
|
||||||
{% for log_entry in original_object.history.all %}
|
|
||||||
{% for key, value in log_entry.changes_display_dict.items %}
|
|
||||||
{% if key == "status" %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ value.1|default:"None" }}</td>
|
|
||||||
<td>{{ log_entry.actor|default:"None" }}</td>
|
|
||||||
<td>{{ log_entry.timestamp|default:"None" }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
|
||||||
{% endfor %}
|
{% if entry.rejection_reason %}
|
||||||
</tbody>
|
- {{ entry.rejection_reason|default:"Error" }}
|
||||||
</table>
|
{% endif %}
|
||||||
</div>
|
|
||||||
<button type="button" class="collapse-toggle--dgsimple usa-button usa-button--unstyled margin-top-2 margin-bottom-1 margin-left-1">
|
{% if entry.action_needed_reason %}
|
||||||
<span>Hide details</span>
|
- {{ entry.action_needed_reason|default:"Error" }}
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
{% endif %}
|
||||||
<use xlink:href="/public/img/sprite.svg#expand_less"></use>
|
</td>
|
||||||
</svg>
|
<td>{{ entry.actor|default:"Error" }}</td>
|
||||||
</button>
|
<td>{{ entry.timestamp|date:"Y-m-d H:i:s" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="collapse-toggle--dgsimple usa-button usa-button--unstyled margin-top-2 margin-bottom-1 margin-left-1">
|
||||||
|
<span>Show details</span>
|
||||||
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
|
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% elif field.field.name == "creator" %}
|
{% elif field.field.name == "creator" %}
|
||||||
<div class="flex-container tablet:margin-top-2">
|
<div class="flex-container tablet:margin-top-2">
|
||||||
<label aria-label="Creator contact details"></label>
|
<label aria-label="Creator contact details"></label>
|
||||||
|
@ -174,5 +184,19 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{% elif field.field.name == "investigator" and not field.is_readonly %}
|
||||||
|
<div class="flex-container">
|
||||||
|
<label aria-label="Assign yourself as the investigator"></label>
|
||||||
|
<button id="investigator__assign_self"
|
||||||
|
data-user-name="{{ request.user }}"
|
||||||
|
data-user-id="{{ request.user.id }}"
|
||||||
|
type="button"
|
||||||
|
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text text-no-underline margin-top-2 margin-bottom-1 margin-left-1">
|
||||||
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
|
<use xlink:href="/public/img/sprite.svg#group_add"></use>
|
||||||
|
</svg>
|
||||||
|
<span>Assign to me</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock after_help_text %}
|
{% endblock after_help_text %}
|
||||||
|
|
|
@ -1,16 +1,14 @@
|
||||||
{% extends 'domain_request_form.html' %}
|
{% extends 'domain_request_form.html' %}
|
||||||
{% load static field_helpers %}
|
{% load static field_helpers %}
|
||||||
|
|
||||||
{% block form_instructions %}
|
|
||||||
<em>These questions are required (<abbr class="usa-hint usa-hint--required" title="required">*</abbr>).</em>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block form_required_fields_help_text %}
|
{% block form_required_fields_help_text %}
|
||||||
{# commented out so it does not appear at this point on this page #}
|
{% include "includes/required_fields.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
<!-- TODO-NL: (refactor) Breakup into two separate components-->
|
|
||||||
{% block form_fields %}
|
{% block form_fields %}
|
||||||
|
|
||||||
<fieldset class="usa-fieldset margin-top-2">
|
<fieldset class="usa-fieldset margin-top-2">
|
||||||
<legend>
|
<legend>
|
||||||
<h2>Are you working with a CISA regional representative on your domain request?</h2>
|
<h2>Are you working with a CISA regional representative on your domain request?</h2>
|
||||||
|
@ -18,38 +16,38 @@
|
||||||
</legend>
|
</legend>
|
||||||
|
|
||||||
<!-- Toggle -->
|
<!-- Toggle -->
|
||||||
|
<em>Select one (<abbr class="usa-hint usa-hint--required" title="required">*</abbr>).</em>
|
||||||
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
|
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
|
||||||
{% input_with_errors forms.0.has_cisa_representative %}
|
{% input_with_errors forms.0.has_cisa_representative %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{# forms.0 is a small yes/no form that toggles the visibility of "cisa representative" formset #}
|
{# forms.0 is a small yes/no form that toggles the visibility of "cisa representative" formset #}
|
||||||
<!-- TODO-NL: Hookup forms.0 to yes/no form for cisa representative (backend def)-->
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div id="cisa-representative" class="cisa-representative-form">
|
<div id="cisa-representative" class="cisa-representative-form margin-top-3">
|
||||||
|
{% input_with_errors forms.1.cisa_representative_first_name %}
|
||||||
|
{% input_with_errors forms.1.cisa_representative_last_name %}
|
||||||
{% input_with_errors forms.1.cisa_representative_email %}
|
{% input_with_errors forms.1.cisa_representative_email %}
|
||||||
{# forms.1 is a form for inputting the e-mail of a cisa representative #}
|
{# forms.1 is a form for inputting the e-mail of a cisa representative #}
|
||||||
<!-- TODO-NL: Hookup forms.1 to cisa representative form (backend def) -->
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<fieldset class="usa-fieldset margin-top-2">
|
<fieldset class="usa-fieldset margin-top-2">
|
||||||
<legend>
|
<legend>
|
||||||
<h2>Is there anything else you’d like us to know about your domain request?</h2>
|
<h2>Is there anything else you’d like us to know about your domain request?</h2>
|
||||||
</legend>
|
</legend>
|
||||||
|
|
||||||
<!-- Toggle -->
|
<!-- Toggle -->
|
||||||
|
<em>Select one (<abbr class="usa-hint usa-hint--required" title="required">*</abbr>).</em>
|
||||||
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
|
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
|
||||||
{% input_with_errors forms.2.has_anything_else_text %}
|
{% input_with_errors forms.2.has_anything_else_text %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{# forms.2 is a small yes/no form that toggles the visibility of "cisa representative" formset #}
|
{# forms.2 is a small yes/no form that toggles the visibility of "cisa representative" formset #}
|
||||||
<!-- TODO-NL: Hookup forms.2 to yes/no form for anything else form (backend def)-->
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div id="anything-else">
|
<div class="margin-top-3" id="anything-else">
|
||||||
|
<p>Provide details below (<abbr class="usa-hint usa-hint--required" title="required">*</abbr>).</p>
|
||||||
{% with attr_maxlength=2000 add_label_class="usa-sr-only" %}
|
{% with attr_maxlength=2000 add_label_class="usa-sr-only" %}
|
||||||
{% input_with_errors forms.3.anything_else %}
|
{% input_with_errors forms.3.anything_else %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{# forms.3 is a form for inputting the e-mail of a cisa representative #}
|
{# forms.3 is a form for inputting the e-mail of a cisa representative #}
|
||||||
<!-- TODO-NL: Hookup forms.3 to anything else form (backend def) -->
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -157,18 +157,33 @@
|
||||||
|
|
||||||
{% if step == Step.ADDITIONAL_DETAILS %}
|
{% if step == Step.ADDITIONAL_DETAILS %}
|
||||||
{% namespaced_url 'domain-request' step as domain_request_url %}
|
{% namespaced_url 'domain-request' step as domain_request_url %}
|
||||||
{% with title=form_titles|get_item:step value=domain_request.requested_domain.name|default:"<span class='text-bold text-secondary-dark'>Incomplete</span>"|safe %}
|
{% with title=form_titles|get_item:step %}
|
||||||
{% include "includes/summary_item.html" with title=title sub_header_text='CISA regional representative' value=domain_request.cisa_representative_email heading_level=heading_level editable=True edit_link=domain_request_url custom_text_for_value_none='No' %}
|
{% if domain_request.has_additional_details %}
|
||||||
{% endwith %}
|
{% include "includes/summary_item.html" with title="Additional Details" value=" " heading_level=heading_level editable=True edit_link=domain_request_url %}
|
||||||
|
<h3 class="register-form-review-header">CISA Regional Representative</h3>
|
||||||
|
<ul class="usa-list usa-list--unstyled margin-top-0">
|
||||||
|
{% if domain_request.cisa_representative_first_name %}
|
||||||
|
<li>{{domain_request.cisa_representative_first_name}} {{domain_request.cisa_representative_last_name}}</li>
|
||||||
|
{% if domain_request.cisa_representative_email %}
|
||||||
|
<li>{{domain_request.cisa_representative_email}}</li>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
No
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
<h3 class="register-form-review-header">Anything else</h3>
|
<h3 class="register-form-review-header">Anything else</h3>
|
||||||
<ul class="usa-list usa-list--unstyled margin-top-0">
|
<ul class="usa-list usa-list--unstyled margin-top-0">
|
||||||
{% if domain_request.anything_else %}
|
{% if domain_request.anything_else %}
|
||||||
{{domain_request.anything_else}}
|
{{domain_request.anything_else}}
|
||||||
{% else %}
|
{% else %}
|
||||||
No
|
No
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
{% include "includes/summary_item.html" with title="Additional Details" value="<span class='text-bold text-secondary-dark'>Incomplete</span>"|safe heading_level=heading_level editable=True edit_link=domain_request_url %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -118,7 +118,15 @@
|
||||||
|
|
||||||
{# We always show this field even if None #}
|
{# We always show this field even if None #}
|
||||||
{% if DomainRequest %}
|
{% if DomainRequest %}
|
||||||
{% include "includes/summary_item.html" with title='Additional details' sub_header_text='CISA regional representative' value=DomainRequest.cisa_representative_email custom_text_for_value_none='No' heading_level=heading_level %}
|
<h3 class="register-form-review-header">CISA Regional Representative</h3>
|
||||||
|
<ul class="usa-list usa-list--unstyled margin-top-0">
|
||||||
|
{% if domain_request.cisa_representative_first_name %}
|
||||||
|
{{domain_request.cisa_representative_first_name}} {{domain_request.cisa_representative_last_name}}
|
||||||
|
{% else %}
|
||||||
|
No
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
<h3 class="register-form-review-header">Anything else</h3>
|
<h3 class="register-form-review-header">Anything else</h3>
|
||||||
<ul class="usa-list usa-list--unstyled margin-top-0">
|
<ul class="usa-list usa-list--unstyled margin-top-0">
|
||||||
{% if DomainRequest.anything_else %}
|
{% if DomainRequest.anything_else %}
|
||||||
|
@ -128,7 +136,6 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
|
||||||
|
Hi, {{ domain_request.submitter.first_name }}.
|
||||||
|
|
||||||
|
We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request.
|
||||||
|
|
||||||
|
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
||||||
|
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
|
||||||
|
STATUS: Action needed
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
ORGANIZATION ALREADY HAS A .GOV DOMAIN
|
||||||
|
We've reviewed your domain request, but your organization already has at least one other .gov domain. We need more information about your rationale for registering another .gov domain.
|
||||||
|
In general, there are two reasons we will approve an additional domain:
|
||||||
|
- You determine a current .gov domain name will be replaced
|
||||||
|
- We determine an additional domain name is appropriate
|
||||||
|
|
||||||
|
|
||||||
|
WE LIMIT ADDITIONAL DOMAIN NAMES
|
||||||
|
Our practice is to only approve one domain per online service per government organization, evaluating additional requests on a case-by-case basis.
|
||||||
|
There are two core reasons we limit additional domains:
|
||||||
|
- We want to minimize your operational and security load, which increases with each additional domain.
|
||||||
|
- Fewer domains allow us to take protective, namespace-wide security actions faster and without undue dependencies.
|
||||||
|
|
||||||
|
If you’re attempting to claim an additional domain to prevent others from obtaining it, that’s not necessary. .Gov domains are only available to U.S.-based government organizations, and we don’t operate on a first come, first served basis. We'll only assign a domain to the organization whose real name or services actually correspond to the domain name.
|
||||||
|
|
||||||
|
|
||||||
|
CONSIDER USING A SUBDOMAIN
|
||||||
|
Using a subdomain of an existing domain (e.g., service.domain.gov) is a common approach to logically divide your namespace while still maintaining an association with your existing domain name. Subdomains can also be delegated to allow an affiliated entity to manage their own DNS settings.
|
||||||
|
|
||||||
|
|
||||||
|
ACTION NEEDED
|
||||||
|
FOR A REPLACEMENT DOMAIN: If you’re requesting a new domain that will replace your current domain name, we can allow for a transition period where both are registered to your organization. Afterwards, we will reclaim and retire the legacy name.
|
||||||
|
|
||||||
|
Reply to this email. Tell us how many months your organization needs to maintain your current .gov domain and conduct a transition to a new one. Detail why that period of time is needed.
|
||||||
|
|
||||||
|
FOR AN ADDITIONAL DOMAIN: If you’re requesting an additional domain and not replacing your existing one, we’ll need more information to support that request.
|
||||||
|
|
||||||
|
Reply to this email. Detail why you believe another domain is necessary for your organization, and why a subdomain won’t meet your needs.
|
||||||
|
|
||||||
|
|
||||||
|
If you have questions or comments, include those in your reply.
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
The .gov team
|
||||||
|
Contact us: <https://get.gov/contact/>
|
||||||
|
Learn about .gov <https://get.gov>
|
||||||
|
|
||||||
|
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <http://cisa.gov/>
|
||||||
|
{% endautoescape %}
|
|
@ -0,0 +1 @@
|
||||||
|
Update on your .gov request: {{ domain_request.requested_domain.name }}
|
|
@ -0,0 +1,34 @@
|
||||||
|
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
|
||||||
|
Hi, {{ domain_request.submitter.first_name }}.
|
||||||
|
|
||||||
|
We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request.
|
||||||
|
|
||||||
|
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
||||||
|
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
|
||||||
|
STATUS: Action needed
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS
|
||||||
|
We've reviewed your domain request and, unfortunately, it does not meet our naming requirements.
|
||||||
|
|
||||||
|
Domains should uniquely identify a government organization and be clear to the general public. Read more about naming requirements for your type of organization <https://get.gov/domains/choosing/>.
|
||||||
|
|
||||||
|
|
||||||
|
ACTION NEEDED
|
||||||
|
First, we need you to identify a new domain name that meets our naming requirements for your type of organization. Then, log in to the registrar and update the name in your domain request. <https://manage.get.gov/> Once you submit your updated request, we’ll resume the adjudication process.
|
||||||
|
|
||||||
|
If you have questions or want to discuss potential domain names, reply to this email.
|
||||||
|
|
||||||
|
|
||||||
|
THANK YOU
|
||||||
|
.Gov helps the public identify official, trusted information. Thank you for requesting a .gov domain.
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
The .gov team
|
||||||
|
Contact us: <https://get.gov/contact/>
|
||||||
|
Learn about .gov <https://get.gov>
|
||||||
|
|
||||||
|
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <http://cisa.gov/>
|
||||||
|
{% endautoescape %}
|
|
@ -0,0 +1 @@
|
||||||
|
Update on your .gov request: {{ domain_request.requested_domain.name }}
|
|
@ -0,0 +1,35 @@
|
||||||
|
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
|
||||||
|
Hi, {{ domain_request.submitter.first_name }}.
|
||||||
|
|
||||||
|
We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request.
|
||||||
|
|
||||||
|
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
||||||
|
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
|
||||||
|
STATUS: Action needed
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS
|
||||||
|
We've reviewed your domain request, but we need more information about the organization you represent:
|
||||||
|
- {{ domain_request.organization_name }}
|
||||||
|
|
||||||
|
.Gov domains are only available to official US-based government organizations, not simply those that provide a public benefit. We lack clear documentation that demonstrates your organization is eligible for a .gov domain.
|
||||||
|
|
||||||
|
|
||||||
|
ACTION NEEDED
|
||||||
|
Reply to this email with links to (or copies of) your authorizing legislation, your founding charter or bylaws, recent election results, or other similar documentation. Without this, we can’t continue our review and your request will likely be rejected.
|
||||||
|
|
||||||
|
If you have questions or comments, include those in your reply.
|
||||||
|
|
||||||
|
|
||||||
|
THANK YOU
|
||||||
|
.Gov helps the public identify official, trusted information. Thank you for requesting a .gov domain.
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
The .gov team
|
||||||
|
Contact us: <https://get.gov/contact/>
|
||||||
|
Learn about .gov <https://get.gov>
|
||||||
|
|
||||||
|
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <http://cisa.gov/>
|
||||||
|
{% endautoescape %}
|
|
@ -0,0 +1 @@
|
||||||
|
Update on your .gov request: {{ domain_request.requested_domain.name }}
|
|
@ -0,0 +1,36 @@
|
||||||
|
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
|
||||||
|
Hi, {{ domain_request.submitter.first_name }}.
|
||||||
|
|
||||||
|
We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request.
|
||||||
|
|
||||||
|
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
||||||
|
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
|
||||||
|
STATUS: Action needed
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
AUTHORIZING OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS
|
||||||
|
We've reviewed your domain request, but we need more information about the authorizing official listed on the request:
|
||||||
|
- {{ domain_request.authorizing_official.get_formatted_name }}
|
||||||
|
- {{ domain_request.authorizing_official.title }}
|
||||||
|
|
||||||
|
We expect an authorizing official to be someone in a role of significant, executive responsibility within the organization. Our guidelines are open-ended to accommodate the wide variety of government organizations that are eligible for .gov domains, but the person you listed does not meet our expectations for your type of organization. Read more about our guidelines for authorizing officials. <https://get.gov/domains/eligibility/>
|
||||||
|
|
||||||
|
|
||||||
|
ACTION NEEDED
|
||||||
|
Reply to this email with a justification for naming {{ domain_request.authorizing_official.get_formatted_name }} as the authorizing official. If you have questions or comments, include those in your reply.
|
||||||
|
|
||||||
|
Alternatively, you can log in to the registrar and enter a different authorizing official for this domain request. <https://manage.get.gov/> Once you submit your updated request, we’ll resume the adjudication process.
|
||||||
|
|
||||||
|
|
||||||
|
THANK YOU
|
||||||
|
.Gov helps the public identify official, trusted information. Thank you for requesting a .gov domain.
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
The .gov team
|
||||||
|
Contact us: <https://get.gov/contact/>
|
||||||
|
Learn about .gov <https://get.gov>
|
||||||
|
|
||||||
|
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <http://cisa.gov/>
|
||||||
|
{% endautoescape %}
|
|
@ -0,0 +1 @@
|
||||||
|
Update on your .gov request: {{ domain_request.requested_domain.name }}
|
|
@ -15,7 +15,11 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<h1>Manage your domains</h2>
|
<h1>Manage your domains</h2>
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
IMPORTANT:
|
||||||
|
If this button is added on any other page, make sure to update the
|
||||||
|
relevant view to reset request.session["new_request"] = True
|
||||||
|
{% endcomment %}
|
||||||
<p class="margin-top-4">
|
<p class="margin-top-4">
|
||||||
<a href="{% url 'domain-request:' %}" class="usa-button"
|
<a href="{% url 'domain-request:' %}" class="usa-button"
|
||||||
>
|
>
|
||||||
|
@ -23,10 +27,39 @@
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<section class="section--outlined">
|
<section class="section--outlined domains">
|
||||||
<h2 id="domains-header">Domains</h2>
|
<div class="grid-row">
|
||||||
<div class="domains-wrapper display-none">
|
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked dotgov-table__registered-domains">
|
<h2 id="domains-header" class="flex-6">Domains</h2>
|
||||||
|
</div>
|
||||||
|
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||||
|
<section aria-label="Domains search component" class="flex-6 margin-y-2">
|
||||||
|
<form class="usa-search usa-search--small" method="POST" role="search">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="usa-button usa-button--unstyled margin-right-2 domains__reset-button display-none" type="button">
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<label class="usa-sr-only" for="domains__search-field">Search</label>
|
||||||
|
<input
|
||||||
|
class="usa-input"
|
||||||
|
id="domains__search-field"
|
||||||
|
type="search"
|
||||||
|
name="search"
|
||||||
|
placeholder="Search by domain name"
|
||||||
|
/>
|
||||||
|
<button class="usa-button" type="submit" id="domains__search-field-submit">
|
||||||
|
<img
|
||||||
|
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
|
||||||
|
class="usa-search__submit-icon"
|
||||||
|
alt="Search"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="domains__table-wrapper display-none">
|
||||||
|
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domains__table">
|
||||||
<caption class="sr-only">Your registered domains</caption>
|
<caption class="sr-only">Your registered domains</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -50,7 +83,7 @@
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="no-domains-wrapper display-none">
|
<div class="domains__no-data display-none">
|
||||||
<p>You don't have any registered domains.</p>
|
<p>You don't have any registered domains.</p>
|
||||||
<p class="maxw-none clearfix">
|
<p class="maxw-none clearfix">
|
||||||
<a href="https://get.gov/help/faq/#do-not-see-my-domain" class="float-right-tablet display-flex flex-align-start usa-link" target="_blank">
|
<a href="https://get.gov/help/faq/#do-not-see-my-domain" class="float-right-tablet display-flex flex-align-start usa-link" target="_blank">
|
||||||
|
@ -61,6 +94,9 @@
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="domains__no-search-results display-none">
|
||||||
|
<p>No results found for "<span class="domains__search-term"></span>"</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domains-pagination">
|
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domains-pagination">
|
||||||
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
|
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
|
||||||
|
@ -71,10 +107,39 @@
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<section class="section--outlined">
|
<section class="section--outlined domain-requests">
|
||||||
<h2 id="domain-requests-header">Domain requests</h2>
|
<div class="grid-row">
|
||||||
<div class="domain-requests-wrapper display-none">
|
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked dotgov-table__domain-requests">
|
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
|
||||||
|
</div>
|
||||||
|
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||||
|
<section aria-label="Domain requests search component" class="flex-6 margin-y-2">
|
||||||
|
<form class="usa-search usa-search--small" method="POST" role="search">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="usa-button usa-button--unstyled margin-right-2 domain-requests__reset-button display-none" type="button">
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<label class="usa-sr-only" for="domain-requests__search-field">Search</label>
|
||||||
|
<input
|
||||||
|
class="usa-input"
|
||||||
|
id="domain-requests__search-field"
|
||||||
|
type="search"
|
||||||
|
name="search"
|
||||||
|
placeholder="Search by domain name"
|
||||||
|
/>
|
||||||
|
<button class="usa-button" type="submit" id="domain-requests__search-field-submit">
|
||||||
|
<img
|
||||||
|
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
|
||||||
|
class="usa-search__submit-icon"
|
||||||
|
alt="Search"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="domain-requests__table-wrapper display-none">
|
||||||
|
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domain-requests__table">
|
||||||
<caption class="sr-only">Your domain requests</caption>
|
<caption class="sr-only">Your domain requests</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -82,7 +147,7 @@
|
||||||
<th data-sortable="submission_date" scope="col" role="columnheader">Date submitted</th>
|
<th data-sortable="submission_date" scope="col" role="columnheader">Date submitted</th>
|
||||||
<th data-sortable="status" scope="col" role="columnheader">Status</th>
|
<th data-sortable="status" scope="col" role="columnheader">Status</th>
|
||||||
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th>
|
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th>
|
||||||
<th scope="col" role="columnheader"><span class="usa-sr-only">Delete Action</span></th>
|
<!-- AJAX will conditionally add a th for delete actions -->
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="domain-requests-tbody">
|
<tbody id="domain-requests-tbody">
|
||||||
|
@ -93,45 +158,13 @@
|
||||||
class="usa-sr-only usa-table__announcement-region"
|
class="usa-sr-only usa-table__announcement-region"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
{% for domain_request in domain_requests %}
|
|
||||||
{% if has_deletable_domain_requests %}
|
|
||||||
{% if domain_request.status == domain_request.DomainRequestStatus.STARTED or domain_request.status == domain_request.DomainRequestStatus.WITHDRAWN %}
|
|
||||||
<div
|
|
||||||
class="usa-modal"
|
|
||||||
id="toggle-delete-domain-alert-{{ domain_request.id }}"
|
|
||||||
aria-labelledby="Are you sure you want to continue?"
|
|
||||||
aria-describedby="Domain will be removed"
|
|
||||||
data-force-action
|
|
||||||
>
|
|
||||||
<form method="POST" action="{% url "domain-request-delete" pk=domain_request.id %}">
|
|
||||||
{% if domain_request.requested_domain is None %}
|
|
||||||
{% if domain_request.created_at %}
|
|
||||||
{% with prefix="(created " %}
|
|
||||||
{% with formatted_date=domain_request.created_at|date:"DATETIME_FORMAT" %}
|
|
||||||
{% with modal_content=prefix|add:formatted_date|add:" UTC)" %}
|
|
||||||
{% include 'includes/modal.html' with modal_heading="Are you sure you want to delete this domain request?" modal_description="This will remove the domain request "|add:modal_content|add:" from the .gov registrar. This action cannot be undone." modal_button=modal_button|safe %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endwith %}
|
|
||||||
{% else %}
|
|
||||||
{% include 'includes/modal.html' with modal_heading="Are you sure you want to delete New domain request?" modal_description="This will remove the domain request from the .gov registrar. This action cannot be undone." modal_button=modal_button|safe %}
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
{% with modal_heading_value=domain_request.requested_domain.name|add:"?" %}
|
|
||||||
{% include 'includes/modal.html' with modal_heading="Are you sure you want to delete" heading_value=modal_heading_value modal_description="This will remove the domain request from the .gov registrar. This action cannot be undone." modal_button=modal_button|safe %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endif %}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="no-domain-requests-wrapper display-none">
|
<div class="domain-requests__no-data display-none">
|
||||||
<p>You haven't requested any domains.</p>
|
<p>You haven't requested any domains.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="domain-requests__no-search-results display-none">
|
||||||
|
<p>No results found for "<span class="domain-requests__search-term"></span>"</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination">
|
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination">
|
||||||
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
|
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
|
||||||
|
|
|
@ -5,6 +5,11 @@ Edit your User Profile |
|
||||||
{% endblock title %}
|
{% endblock title %}
|
||||||
{% load static url_helpers %}
|
{% load static url_helpers %}
|
||||||
|
|
||||||
|
{# Disable the redirect #}
|
||||||
|
{% block logo %}
|
||||||
|
{% include "includes/gov_extended_logo.html" with logo_clickable=user_finished_setup %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main id="main-content" class="grid-container">
|
<main id="main-content" class="grid-container">
|
||||||
<div class="grid-col desktop:grid-offset-2 desktop:grid-col-8">
|
<div class="grid-col desktop:grid-offset-2 desktop:grid-col-8">
|
||||||
|
@ -36,6 +41,61 @@ Edit your User Profile |
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if show_confirmation_modal %}
|
||||||
|
<a
|
||||||
|
href="#toggle-confirmation-modal"
|
||||||
|
class="usa-button display-none show-confirmation-modal"
|
||||||
|
aria-controls="toggle-confirmation-modal"
|
||||||
|
data-open-modal
|
||||||
|
>Open confirmation modal</a>
|
||||||
|
<div
|
||||||
|
class="usa-modal usa-modal--lg is-visible"
|
||||||
|
id="toggle-confirmation-modal"
|
||||||
|
aria-labelledby="Add contact information"
|
||||||
|
aria-describedby="Add contact information"
|
||||||
|
data-force-action
|
||||||
|
>
|
||||||
|
<div class="usa-modal__content">
|
||||||
|
<div class="usa-modal__main">
|
||||||
|
<h2 class="usa-modal__heading" id="modal-1-heading">
|
||||||
|
Add contact information
|
||||||
|
</h2>
|
||||||
|
<div class="usa-prose">
|
||||||
|
<p id="modal-1-description">
|
||||||
|
.Gov domain registrants must maintain accurate contact information in the .gov registrar.
|
||||||
|
Before you can manage your domain, we need you to add your contact information.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="usa-modal__footer">
|
||||||
|
<ul class="usa-button-group">
|
||||||
|
<li class="usa-button-group__item">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="usa-button padding-105 text-center"
|
||||||
|
data-close-modal
|
||||||
|
>
|
||||||
|
Add contact information
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="usa-button usa-modal__close"
|
||||||
|
aria-label="Close this window"
|
||||||
|
data-close-modal
|
||||||
|
>
|
||||||
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
|
||||||
|
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
||||||
{% block content_bottom %}
|
{% block content_bottom %}
|
||||||
|
@ -43,3 +103,7 @@ Edit your User Profile |
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{% endblock content_bottom %}
|
{% endblock content_bottom %}
|
||||||
|
|
||||||
|
{% block footer %}
|
||||||
|
{% include "includes/footer.html" with show_manage_your_domains=user_finished_setup %}
|
||||||
|
{% endblock footer %}
|
||||||
|
|
|
@ -735,19 +735,53 @@ class MockDb(TestCase):
|
||||||
self.domain_request_4 = completed_domain_request(
|
self.domain_request_4 = completed_domain_request(
|
||||||
status=DomainRequest.DomainRequestStatus.STARTED,
|
status=DomainRequest.DomainRequestStatus.STARTED,
|
||||||
name="city4.gov",
|
name="city4.gov",
|
||||||
|
is_election_board=True,
|
||||||
|
generic_org_type="city",
|
||||||
)
|
)
|
||||||
self.domain_request_5 = completed_domain_request(
|
self.domain_request_5 = completed_domain_request(
|
||||||
status=DomainRequest.DomainRequestStatus.APPROVED,
|
status=DomainRequest.DomainRequestStatus.APPROVED,
|
||||||
name="city5.gov",
|
name="city5.gov",
|
||||||
)
|
)
|
||||||
|
self.domain_request_6 = completed_domain_request(
|
||||||
|
status=DomainRequest.DomainRequestStatus.STARTED,
|
||||||
|
name="city6.gov",
|
||||||
|
)
|
||||||
self.domain_request_3.submit()
|
self.domain_request_3.submit()
|
||||||
self.domain_request_4.submit()
|
self.domain_request_4.submit()
|
||||||
|
self.domain_request_6.submit()
|
||||||
|
|
||||||
|
other, _ = Contact.objects.get_or_create(
|
||||||
|
first_name="Testy1232",
|
||||||
|
last_name="Tester24",
|
||||||
|
title="Another Tester",
|
||||||
|
email="te2@town.com",
|
||||||
|
phone="(555) 555 5557",
|
||||||
|
)
|
||||||
|
other_2, _ = Contact.objects.get_or_create(
|
||||||
|
first_name="Meow",
|
||||||
|
last_name="Tester24",
|
||||||
|
title="Another Tester",
|
||||||
|
email="te2@town.com",
|
||||||
|
phone="(555) 555 5557",
|
||||||
|
)
|
||||||
|
website, _ = Website.objects.get_or_create(website="igorville.gov")
|
||||||
|
website_2, _ = Website.objects.get_or_create(website="cheeseville.gov")
|
||||||
|
website_3, _ = Website.objects.get_or_create(website="https://www.example.com")
|
||||||
|
website_4, _ = Website.objects.get_or_create(website="https://www.example2.com")
|
||||||
|
|
||||||
|
self.domain_request_3.other_contacts.add(other, other_2)
|
||||||
|
self.domain_request_3.alternative_domains.add(website, website_2)
|
||||||
|
self.domain_request_3.current_websites.add(website_3, website_4)
|
||||||
|
self.domain_request_3.cisa_representative_email = "test@igorville.com"
|
||||||
self.domain_request_3.submission_date = get_time_aware_date(datetime(2024, 4, 2))
|
self.domain_request_3.submission_date = get_time_aware_date(datetime(2024, 4, 2))
|
||||||
self.domain_request_4.submission_date = get_time_aware_date(datetime(2024, 4, 2))
|
|
||||||
self.domain_request_3.save()
|
self.domain_request_3.save()
|
||||||
|
|
||||||
|
self.domain_request_4.submission_date = get_time_aware_date(datetime(2024, 4, 2))
|
||||||
self.domain_request_4.save()
|
self.domain_request_4.save()
|
||||||
|
|
||||||
|
self.domain_request_6.submission_date = get_time_aware_date(datetime(2024, 4, 2))
|
||||||
|
self.domain_request_6.save()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
super().tearDown()
|
super().tearDown()
|
||||||
PublicContact.objects.all().delete()
|
PublicContact.objects.all().delete()
|
||||||
|
@ -808,12 +842,13 @@ def create_ready_domain():
|
||||||
|
|
||||||
|
|
||||||
# TODO in 1793: Remove the federal agency/updated federal agency fields
|
# TODO in 1793: Remove the federal agency/updated federal agency fields
|
||||||
def completed_domain_request(
|
def completed_domain_request( # noqa
|
||||||
has_other_contacts=True,
|
has_other_contacts=True,
|
||||||
has_current_website=True,
|
has_current_website=True,
|
||||||
has_alternative_gov_domain=True,
|
has_alternative_gov_domain=True,
|
||||||
has_about_your_organization=True,
|
has_about_your_organization=True,
|
||||||
has_anything_else=True,
|
has_anything_else=True,
|
||||||
|
has_cisa_representative=True,
|
||||||
status=DomainRequest.DomainRequestStatus.STARTED,
|
status=DomainRequest.DomainRequestStatus.STARTED,
|
||||||
user=False,
|
user=False,
|
||||||
submitter=False,
|
submitter=False,
|
||||||
|
@ -895,6 +930,10 @@ def completed_domain_request(
|
||||||
domain_request.current_websites.add(current)
|
domain_request.current_websites.add(current)
|
||||||
if has_alternative_gov_domain:
|
if has_alternative_gov_domain:
|
||||||
domain_request.alternative_domains.add(alt)
|
domain_request.alternative_domains.add(alt)
|
||||||
|
if has_cisa_representative:
|
||||||
|
domain_request.cisa_representative_first_name = "CISA-first-name"
|
||||||
|
domain_request.cisa_representative_last_name = "CISA-last-name"
|
||||||
|
domain_request.cisa_representative_email = "cisaRep@igorville.gov"
|
||||||
|
|
||||||
return domain_request
|
return domain_request
|
||||||
|
|
||||||
|
|
|
@ -1055,6 +1055,18 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
accurately and in chronological order.
|
accurately and in chronological order.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def assert_status_count(normalized_content, status, count):
|
||||||
|
"""Helper function to assert the count of a status in the HTML content."""
|
||||||
|
self.assertEqual(normalized_content.count(f"<td> {status} </td>"), count)
|
||||||
|
|
||||||
|
def assert_status_order(normalized_content, statuses):
|
||||||
|
"""Helper function to assert the order of statuses in the HTML content."""
|
||||||
|
start_index = 0
|
||||||
|
for status in statuses:
|
||||||
|
index = normalized_content.find(f"<td> {status} </td>", start_index)
|
||||||
|
self.assertNotEqual(index, -1, f"Status '{status}' not found in the expected order.")
|
||||||
|
start_index = index + len(status)
|
||||||
|
|
||||||
# Create a fake domain request and domain
|
# Create a fake domain request and domain
|
||||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.STARTED)
|
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.STARTED)
|
||||||
|
|
||||||
|
@ -1069,48 +1081,23 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, domain_request.requested_domain.name)
|
self.assertContains(response, domain_request.requested_domain.name)
|
||||||
|
|
||||||
# Table will contain one row for Started
|
|
||||||
self.assertContains(response, "<td>Started</td>", count=1)
|
|
||||||
self.assertNotContains(response, "<td>Submitted</td>")
|
|
||||||
|
|
||||||
domain_request.submit()
|
domain_request.submit()
|
||||||
domain_request.save()
|
domain_request.save()
|
||||||
|
|
||||||
response = self.client.get(
|
|
||||||
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
|
|
||||||
follow=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Table will contain and extra row for Submitted
|
|
||||||
self.assertContains(response, "<td>Started</td>", count=1)
|
|
||||||
self.assertContains(response, "<td>Submitted</td>", count=1)
|
|
||||||
|
|
||||||
domain_request.in_review()
|
domain_request.in_review()
|
||||||
domain_request.save()
|
domain_request.save()
|
||||||
|
|
||||||
response = self.client.get(
|
|
||||||
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
|
|
||||||
follow=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Table will contain and extra row for In review
|
|
||||||
self.assertContains(response, "<td>Started</td>", count=1)
|
|
||||||
self.assertContains(response, "<td>Submitted</td>", count=1)
|
|
||||||
self.assertContains(response, "<td>In review</td>", count=1)
|
|
||||||
|
|
||||||
domain_request.action_needed()
|
domain_request.action_needed()
|
||||||
|
domain_request.action_needed_reason = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS
|
||||||
domain_request.save()
|
domain_request.save()
|
||||||
|
|
||||||
response = self.client.get(
|
# Let's just change the action needed reason
|
||||||
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
|
domain_request.action_needed_reason = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR
|
||||||
follow=True,
|
domain_request.save()
|
||||||
)
|
|
||||||
|
|
||||||
# Table will contain and extra row for Action needed
|
domain_request.reject()
|
||||||
self.assertContains(response, "<td>Started</td>", count=1)
|
domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE
|
||||||
self.assertContains(response, "<td>Submitted</td>", count=1)
|
domain_request.save()
|
||||||
self.assertContains(response, "<td>In review</td>", count=1)
|
|
||||||
self.assertContains(response, "<td>Action needed</td>", count=1)
|
|
||||||
|
|
||||||
domain_request.in_review()
|
domain_request.in_review()
|
||||||
domain_request.save()
|
domain_request.save()
|
||||||
|
@ -1120,24 +1107,28 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
follow=True,
|
follow=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Normalize the HTML response content
|
||||||
|
normalized_content = " ".join(response.content.decode("utf-8").split())
|
||||||
|
|
||||||
# Define the expected sequence of status changes
|
# Define the expected sequence of status changes
|
||||||
expected_status_changes = [
|
expected_status_changes = [
|
||||||
"<td>In review</td>",
|
"In review",
|
||||||
"<td>Action needed</td>",
|
"Rejected - Purpose requirements not met",
|
||||||
"<td>In review</td>",
|
"Action needed - Unclear organization eligibility",
|
||||||
"<td>Submitted</td>",
|
"Action needed - Already has domains",
|
||||||
"<td>Started</td>",
|
"In review",
|
||||||
|
"Submitted",
|
||||||
|
"Started",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Test for the order of status changes
|
assert_status_order(normalized_content, expected_status_changes)
|
||||||
for status_change in expected_status_changes:
|
|
||||||
self.assertContains(response, status_change, html=True)
|
|
||||||
|
|
||||||
# Table now contains 2 rows for Approved
|
assert_status_count(normalized_content, "Started", 1)
|
||||||
self.assertContains(response, "<td>Started</td>", count=1)
|
assert_status_count(normalized_content, "Submitted", 1)
|
||||||
self.assertContains(response, "<td>Submitted</td>", count=1)
|
assert_status_count(normalized_content, "In review", 2)
|
||||||
self.assertContains(response, "<td>In review</td>", count=2)
|
assert_status_count(normalized_content, "Action needed - Already has domains", 1)
|
||||||
self.assertContains(response, "<td>Action needed</td>", count=1)
|
assert_status_count(normalized_content, "Action needed - Unclear organization eligibility", 1)
|
||||||
|
assert_status_count(normalized_content, "Rejected - Purpose requirements not met", 1)
|
||||||
|
|
||||||
def test_collaspe_toggle_button_markup(self):
|
def test_collaspe_toggle_button_markup(self):
|
||||||
"""
|
"""
|
||||||
|
@ -1445,20 +1436,25 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
# The results are filtered by "status in [submitted,in review,action needed]"
|
# The results are filtered by "status in [submitted,in review,action needed]"
|
||||||
self.assertContains(response, "status in [submitted,in review,action needed]", count=1)
|
self.assertContains(response, "status in [submitted,in review,action needed]", count=1)
|
||||||
|
|
||||||
def transition_state_and_send_email(self, domain_request, status, rejection_reason=None):
|
@less_console_noise_decorator
|
||||||
|
def transition_state_and_send_email(self, domain_request, status, rejection_reason=None, action_needed_reason=None):
|
||||||
"""Helper method for the email test cases."""
|
"""Helper method for the email test cases."""
|
||||||
|
|
||||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
||||||
with less_console_noise():
|
# Create a mock request
|
||||||
# Create a mock request
|
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
|
||||||
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
|
|
||||||
|
|
||||||
# Modify the domain request's properties
|
# Modify the domain request's properties
|
||||||
domain_request.status = status
|
domain_request.status = status
|
||||||
|
|
||||||
|
if rejection_reason:
|
||||||
domain_request.rejection_reason = rejection_reason
|
domain_request.rejection_reason = rejection_reason
|
||||||
|
|
||||||
# Use the model admin's save_model method
|
if action_needed_reason:
|
||||||
self.admin.save_model(request, domain_request, form=None, change=True)
|
domain_request.action_needed_reason = action_needed_reason
|
||||||
|
|
||||||
|
# Use the model admin's save_model method
|
||||||
|
self.admin.save_model(request, domain_request, form=None, change=True)
|
||||||
|
|
||||||
def assert_email_is_accurate(
|
def assert_email_is_accurate(
|
||||||
self, expected_string, email_index, email_address, test_that_no_bcc=False, bcc_email_address=""
|
self, expected_string, email_index, email_address, test_that_no_bcc=False, bcc_email_address=""
|
||||||
|
@ -1493,6 +1489,57 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
bcc_email = kwargs["Destination"]["BccAddresses"][0]
|
bcc_email = kwargs["Destination"]["BccAddresses"][0]
|
||||||
self.assertEqual(bcc_email, bcc_email_address)
|
self.assertEqual(bcc_email, bcc_email_address)
|
||||||
|
|
||||||
|
@override_settings(IS_PRODUCTION=True)
|
||||||
|
def test_action_needed_sends_reason_email_prod_bcc(self):
|
||||||
|
"""When an action needed reason is set, an email is sent out and help@get.gov
|
||||||
|
is BCC'd in production"""
|
||||||
|
# Ensure there is no user with this email
|
||||||
|
EMAIL = "mayor@igorville.gov"
|
||||||
|
BCC_EMAIL = settings.DEFAULT_FROM_EMAIL
|
||||||
|
User.objects.filter(email=EMAIL).delete()
|
||||||
|
in_review = DomainRequest.DomainRequestStatus.IN_REVIEW
|
||||||
|
action_needed = DomainRequest.DomainRequestStatus.ACTION_NEEDED
|
||||||
|
|
||||||
|
# Create a sample domain request
|
||||||
|
domain_request = completed_domain_request(status=in_review)
|
||||||
|
|
||||||
|
# Test the email sent out for already_has_domains
|
||||||
|
already_has_domains = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS
|
||||||
|
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_domains)
|
||||||
|
self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL)
|
||||||
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||||
|
|
||||||
|
# Test the email sent out for bad_name
|
||||||
|
bad_name = DomainRequest.ActionNeededReasons.BAD_NAME
|
||||||
|
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=bad_name)
|
||||||
|
self.assert_email_is_accurate(
|
||||||
|
"DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS", 1, EMAIL, bcc_email_address=BCC_EMAIL
|
||||||
|
)
|
||||||
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||||
|
|
||||||
|
# Test the email sent out for eligibility_unclear
|
||||||
|
eligibility_unclear = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR
|
||||||
|
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=eligibility_unclear)
|
||||||
|
self.assert_email_is_accurate(
|
||||||
|
"ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", 2, EMAIL, bcc_email_address=BCC_EMAIL
|
||||||
|
)
|
||||||
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||||
|
|
||||||
|
# Test the email sent out for questionable_ao
|
||||||
|
questionable_ao = DomainRequest.ActionNeededReasons.QUESTIONABLE_AUTHORIZING_OFFICIAL
|
||||||
|
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=questionable_ao)
|
||||||
|
self.assert_email_is_accurate(
|
||||||
|
"AUTHORIZING OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, EMAIL, bcc_email_address=BCC_EMAIL
|
||||||
|
)
|
||||||
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
|
||||||
|
|
||||||
|
# Assert that no other emails are sent on OTHER
|
||||||
|
other = DomainRequest.ActionNeededReasons.OTHER
|
||||||
|
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=other)
|
||||||
|
|
||||||
|
# Should be unchanged from before
|
||||||
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
|
||||||
|
|
||||||
def test_save_model_sends_submitted_email(self):
|
def test_save_model_sends_submitted_email(self):
|
||||||
"""When transitioning to submitted from started or withdrawn on a domain request,
|
"""When transitioning to submitted from started or withdrawn on a domain request,
|
||||||
an email is sent out.
|
an email is sent out.
|
||||||
|
@ -1528,7 +1575,9 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||||
|
|
||||||
# Move it to IN_REVIEW
|
# Move it to IN_REVIEW
|
||||||
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.IN_REVIEW)
|
other = DomainRequest.ActionNeededReasons.OTHER
|
||||||
|
in_review = DomainRequest.DomainRequestStatus.IN_REVIEW
|
||||||
|
self.transition_state_and_send_email(domain_request, in_review, action_needed_reason=other)
|
||||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||||
|
|
||||||
# Test Submitted Status Again from in IN_REVIEW, no new email should be sent
|
# Test Submitted Status Again from in IN_REVIEW, no new email should be sent
|
||||||
|
@ -1536,7 +1585,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||||
|
|
||||||
# Move it to IN_REVIEW
|
# Move it to IN_REVIEW
|
||||||
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.IN_REVIEW)
|
self.transition_state_and_send_email(domain_request, in_review, action_needed_reason=other)
|
||||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||||
|
|
||||||
# Move it to ACTION_NEEDED
|
# Move it to ACTION_NEEDED
|
||||||
|
@ -1586,7 +1635,9 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||||
|
|
||||||
# Move it to IN_REVIEW
|
# Move it to IN_REVIEW
|
||||||
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.IN_REVIEW)
|
other = domain_request.ActionNeededReasons.OTHER
|
||||||
|
in_review = DomainRequest.DomainRequestStatus.IN_REVIEW
|
||||||
|
self.transition_state_and_send_email(domain_request, in_review, action_needed_reason=other)
|
||||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||||
|
|
||||||
# Test Submitted Status Again from in IN_REVIEW, no new email should be sent
|
# Test Submitted Status Again from in IN_REVIEW, no new email should be sent
|
||||||
|
@ -1594,7 +1645,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||||
|
|
||||||
# Move it to IN_REVIEW
|
# Move it to IN_REVIEW
|
||||||
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.IN_REVIEW)
|
self.transition_state_and_send_email(domain_request, in_review, action_needed_reason=other)
|
||||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||||
|
|
||||||
# Move it to ACTION_NEEDED
|
# Move it to ACTION_NEEDED
|
||||||
|
@ -2238,7 +2289,9 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"status",
|
"status",
|
||||||
"rejection_reason",
|
"rejection_reason",
|
||||||
|
"action_needed_reason",
|
||||||
"federal_agency",
|
"federal_agency",
|
||||||
|
"portfolio",
|
||||||
"creator",
|
"creator",
|
||||||
"investigator",
|
"investigator",
|
||||||
"generic_org_type",
|
"generic_org_type",
|
||||||
|
@ -2265,6 +2318,8 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
"anything_else",
|
"anything_else",
|
||||||
"has_anything_else_text",
|
"has_anything_else_text",
|
||||||
"cisa_representative_email",
|
"cisa_representative_email",
|
||||||
|
"cisa_representative_first_name",
|
||||||
|
"cisa_representative_last_name",
|
||||||
"has_cisa_representative",
|
"has_cisa_representative",
|
||||||
"is_policy_acknowledged",
|
"is_policy_acknowledged",
|
||||||
"submission_date",
|
"submission_date",
|
||||||
|
@ -2297,6 +2352,8 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
"no_other_contacts_rationale",
|
"no_other_contacts_rationale",
|
||||||
"anything_else",
|
"anything_else",
|
||||||
"is_policy_acknowledged",
|
"is_policy_acknowledged",
|
||||||
|
"cisa_representative_first_name",
|
||||||
|
"cisa_representative_last_name",
|
||||||
"cisa_representative_email",
|
"cisa_representative_email",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -2395,6 +2452,10 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
stack.enter_context(patch.object(messages, "error"))
|
stack.enter_context(patch.object(messages, "error"))
|
||||||
|
|
||||||
domain_request.status = another_state
|
domain_request.status = another_state
|
||||||
|
|
||||||
|
if another_state == DomainRequest.DomainRequestStatus.ACTION_NEEDED:
|
||||||
|
domain_request.action_needed_reason = domain_request.ActionNeededReasons.OTHER
|
||||||
|
|
||||||
domain_request.rejection_reason = rejection_reason
|
domain_request.rejection_reason = rejection_reason
|
||||||
|
|
||||||
self.admin.save_model(request, domain_request, None, True)
|
self.admin.save_model(request, domain_request, None, True)
|
||||||
|
|
|
@ -19,6 +19,8 @@ from registrar.models import (
|
||||||
import boto3_mocking
|
import boto3_mocking
|
||||||
from registrar.models.transition_domain import TransitionDomain
|
from registrar.models.transition_domain import TransitionDomain
|
||||||
from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore
|
from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore
|
||||||
|
from registrar.utility.constants import BranchChoices
|
||||||
|
|
||||||
from .common import MockSESClient, less_console_noise, completed_domain_request, set_domain_request_investigators
|
from .common import MockSESClient, less_console_noise, completed_domain_request, set_domain_request_investigators
|
||||||
from django_fsm import TransitionNotAllowed
|
from django_fsm import TransitionNotAllowed
|
||||||
|
|
||||||
|
@ -124,7 +126,7 @@ class TestDomainRequest(TestCase):
|
||||||
creator=user,
|
creator=user,
|
||||||
investigator=user,
|
investigator=user,
|
||||||
generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
|
generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
|
||||||
federal_type=DomainRequest.BranchChoices.EXECUTIVE,
|
federal_type=BranchChoices.EXECUTIVE,
|
||||||
is_election_board=False,
|
is_election_board=False,
|
||||||
organization_name="Test",
|
organization_name="Test",
|
||||||
address_line1="100 Main St.",
|
address_line1="100 Main St.",
|
||||||
|
@ -152,7 +154,7 @@ class TestDomainRequest(TestCase):
|
||||||
information = DomainInformation.objects.create(
|
information = DomainInformation.objects.create(
|
||||||
creator=user,
|
creator=user,
|
||||||
generic_org_type=DomainInformation.OrganizationChoices.FEDERAL,
|
generic_org_type=DomainInformation.OrganizationChoices.FEDERAL,
|
||||||
federal_type=DomainInformation.BranchChoices.EXECUTIVE,
|
federal_type=BranchChoices.EXECUTIVE,
|
||||||
is_election_board=False,
|
is_election_board=False,
|
||||||
organization_name="Test",
|
organization_name="Test",
|
||||||
address_line1="100 Main St.",
|
address_line1="100 Main St.",
|
||||||
|
@ -1800,93 +1802,129 @@ class TestDomainRequestIncomplete(TestCase):
|
||||||
def test_is_additional_details_complete(self):
|
def test_is_additional_details_complete(self):
|
||||||
test_cases = [
|
test_cases = [
|
||||||
# CISA Rep - Yes
|
# CISA Rep - Yes
|
||||||
|
# Firstname - Yes
|
||||||
|
# Lastname - Yes
|
||||||
# Email - Yes
|
# Email - Yes
|
||||||
# Anything Else Radio - Yes
|
# Anything Else Radio - Yes
|
||||||
# Anything Else Text - Yes
|
# Anything Else Text - Yes
|
||||||
{
|
{
|
||||||
"has_cisa_representative": True,
|
"has_cisa_representative": True,
|
||||||
|
"cisa_representative_first_name": "cisa-first-name",
|
||||||
|
"cisa_representative_last_name": "cisa-last-name",
|
||||||
"cisa_representative_email": "some@cisarepemail.com",
|
"cisa_representative_email": "some@cisarepemail.com",
|
||||||
"has_anything_else_text": True,
|
"has_anything_else_text": True,
|
||||||
"anything_else": "Some text",
|
"anything_else": "Some text",
|
||||||
"expected": True,
|
"expected": True,
|
||||||
},
|
},
|
||||||
# CISA Rep - Yes
|
# CISA Rep - Yes
|
||||||
|
# Firstname - Yes
|
||||||
|
# Lastname - Yes
|
||||||
# Email - Yes
|
# Email - Yes
|
||||||
# Anything Else Radio - Yes
|
# Anything Else Radio - Yes
|
||||||
# Anything Else Text - None
|
# Anything Else Text - None
|
||||||
{
|
{
|
||||||
"has_cisa_representative": True,
|
"has_cisa_representative": True,
|
||||||
|
"cisa_representative_first_name": "cisa-first-name",
|
||||||
|
"cisa_representative_last_name": "cisa-last-name",
|
||||||
"cisa_representative_email": "some@cisarepemail.com",
|
"cisa_representative_email": "some@cisarepemail.com",
|
||||||
"has_anything_else_text": True,
|
"has_anything_else_text": True,
|
||||||
"anything_else": None,
|
"anything_else": None,
|
||||||
"expected": True,
|
"expected": True,
|
||||||
},
|
},
|
||||||
# CISA Rep - Yes
|
# CISA Rep - Yes
|
||||||
# Email - Yes
|
# Firstname - Yes
|
||||||
|
# Lastname - Yes
|
||||||
|
# Email - None >> e-mail is optional so it should not change anything setting this to None
|
||||||
# Anything Else Radio - No
|
# Anything Else Radio - No
|
||||||
# Anything Else Text - No
|
# Anything Else Text - No
|
||||||
{
|
{
|
||||||
"has_cisa_representative": True,
|
"has_cisa_representative": True,
|
||||||
"cisa_representative_email": "some@cisarepemail.com",
|
"cisa_representative_first_name": "cisa-first-name",
|
||||||
|
"cisa_representative_last_name": "cisa-last-name",
|
||||||
|
"cisa_representative_email": None,
|
||||||
"has_anything_else_text": False,
|
"has_anything_else_text": False,
|
||||||
"anything_else": None,
|
"anything_else": None,
|
||||||
"expected": True,
|
"expected": True,
|
||||||
},
|
},
|
||||||
# CISA Rep - Yes
|
# CISA Rep - Yes
|
||||||
# Email - Yes
|
# Firstname - Yes
|
||||||
# Anything Else Radio - None
|
# Lastname - Yes
|
||||||
# Anything Else Text - None
|
|
||||||
{
|
|
||||||
"has_cisa_representative": True,
|
|
||||||
"cisa_representative_email": "some@cisarepemail.com",
|
|
||||||
"has_anything_else_text": None,
|
|
||||||
"anything_else": None,
|
|
||||||
"expected": False,
|
|
||||||
},
|
|
||||||
# CISA Rep - Yes
|
|
||||||
# Email - None
|
# Email - None
|
||||||
# Anything Else Radio - None
|
# Anything Else Radio - None
|
||||||
# Anything Else Text - None
|
# Anything Else Text - None
|
||||||
{
|
{
|
||||||
"has_cisa_representative": True,
|
"has_cisa_representative": True,
|
||||||
|
"cisa_representative_first_name": "cisa-first-name",
|
||||||
|
"cisa_representative_last_name": "cisa-last-name",
|
||||||
"cisa_representative_email": None,
|
"cisa_representative_email": None,
|
||||||
"has_anything_else_text": None,
|
"has_anything_else_text": None,
|
||||||
"anything_else": None,
|
"anything_else": None,
|
||||||
"expected": False,
|
"expected": False,
|
||||||
},
|
},
|
||||||
# CISA Rep - Yes
|
# CISA Rep - Yes
|
||||||
|
# Firstname - None
|
||||||
|
# Lastname - None
|
||||||
|
# Email - None
|
||||||
|
# Anything Else Radio - None
|
||||||
|
# Anything Else Text - None
|
||||||
|
{
|
||||||
|
"has_cisa_representative": True,
|
||||||
|
"cisa_representative_first_name": None,
|
||||||
|
"cisa_representative_last_name": None,
|
||||||
|
"cisa_representative_email": None,
|
||||||
|
"has_anything_else_text": None,
|
||||||
|
"anything_else": None,
|
||||||
|
"expected": False,
|
||||||
|
},
|
||||||
|
# CISA Rep - Yes
|
||||||
|
# Firstname - None
|
||||||
|
# Lastname - None
|
||||||
# Email - None
|
# Email - None
|
||||||
# Anything Else Radio - No
|
# Anything Else Radio - No
|
||||||
# Anything Else Text - No
|
# Anything Else Text - No
|
||||||
# sync_yes_no will override has_cisa_representative to be False if cisa_representative_email is None
|
# sync_yes_no will override has_cisa_representative to be False if cisa_representative_first_name is None
|
||||||
# therefore, our expected will be True
|
# therefore, our expected will be True
|
||||||
{
|
{
|
||||||
"has_cisa_representative": True,
|
"has_cisa_representative": True,
|
||||||
# Above will be overridden to False if cisa_rep_email is None bc of sync_yes_no_form_fields
|
# Above will be overridden to False if cisa_representative_first_name is None
|
||||||
|
"cisa_representative_first_name": None,
|
||||||
|
"cisa_representative_last_name": None,
|
||||||
"cisa_representative_email": None,
|
"cisa_representative_email": None,
|
||||||
"has_anything_else_text": False,
|
"has_anything_else_text": False,
|
||||||
"anything_else": None,
|
"anything_else": None,
|
||||||
"expected": True,
|
"expected": True,
|
||||||
},
|
},
|
||||||
# CISA Rep - Yes
|
# CISA Rep - Yes
|
||||||
|
# Firstname - None
|
||||||
|
# Lastname - None
|
||||||
# Email - None
|
# Email - None
|
||||||
# Anything Else Radio - Yes
|
# Anything Else Radio - Yes
|
||||||
# Anything Else Text - None
|
# Anything Else Text - None
|
||||||
|
# NOTE: We should never have an instance where only firstname or only lastname are populated
|
||||||
|
# (they are both required)
|
||||||
{
|
{
|
||||||
"has_cisa_representative": True,
|
"has_cisa_representative": True,
|
||||||
# Above will be overridden to False if cisa_rep_email is None bc of sync_yes_no_form_fields
|
# Above will be overridden to False if cisa_representative_first_name is None or
|
||||||
|
# cisa_representative_last_name is None bc of sync_yes_no_form_fields
|
||||||
|
"cisa_representative_first_name": None,
|
||||||
|
"cisa_representative_last_name": None,
|
||||||
"cisa_representative_email": None,
|
"cisa_representative_email": None,
|
||||||
"has_anything_else_text": True,
|
"has_anything_else_text": True,
|
||||||
"anything_else": None,
|
"anything_else": None,
|
||||||
"expected": True,
|
"expected": True,
|
||||||
},
|
},
|
||||||
# CISA Rep - Yes
|
# CISA Rep - Yes
|
||||||
|
# Firstname - None
|
||||||
|
# Lastname - None
|
||||||
# Email - None
|
# Email - None
|
||||||
# Anything Else Radio - Yes
|
# Anything Else Radio - Yes
|
||||||
# Anything Else Text - Yes
|
# Anything Else Text - Yes
|
||||||
{
|
{
|
||||||
"has_cisa_representative": True,
|
"has_cisa_representative": True,
|
||||||
# Above will be overridden to False if cisa_rep_email is None bc of sync_yes_no_form_fields
|
# Above will be overridden to False if cisa_representative_first_name is None or
|
||||||
|
# cisa_representative_last_name is None bc of sync_yes_no_form_fields
|
||||||
|
"cisa_representative_first_name": None,
|
||||||
|
"cisa_representative_last_name": None,
|
||||||
"cisa_representative_email": None,
|
"cisa_representative_email": None,
|
||||||
"has_anything_else_text": True,
|
"has_anything_else_text": True,
|
||||||
"anything_else": "Some text",
|
"anything_else": "Some text",
|
||||||
|
@ -1897,6 +1935,8 @@ class TestDomainRequestIncomplete(TestCase):
|
||||||
# Anything Else Text - Yes
|
# Anything Else Text - Yes
|
||||||
{
|
{
|
||||||
"has_cisa_representative": False,
|
"has_cisa_representative": False,
|
||||||
|
"cisa_representative_first_name": None,
|
||||||
|
"cisa_representative_last_name": None,
|
||||||
"cisa_representative_email": None,
|
"cisa_representative_email": None,
|
||||||
"has_anything_else_text": True,
|
"has_anything_else_text": True,
|
||||||
"anything_else": "Some text",
|
"anything_else": "Some text",
|
||||||
|
@ -1907,6 +1947,8 @@ class TestDomainRequestIncomplete(TestCase):
|
||||||
# Anything Else Text - None
|
# Anything Else Text - None
|
||||||
{
|
{
|
||||||
"has_cisa_representative": False,
|
"has_cisa_representative": False,
|
||||||
|
"cisa_representative_first_name": None,
|
||||||
|
"cisa_representative_last_name": None,
|
||||||
"cisa_representative_email": None,
|
"cisa_representative_email": None,
|
||||||
"has_anything_else_text": True,
|
"has_anything_else_text": True,
|
||||||
"anything_else": None,
|
"anything_else": None,
|
||||||
|
@ -1917,6 +1959,8 @@ class TestDomainRequestIncomplete(TestCase):
|
||||||
# Anything Else Text - None
|
# Anything Else Text - None
|
||||||
{
|
{
|
||||||
"has_cisa_representative": False,
|
"has_cisa_representative": False,
|
||||||
|
"cisa_representative_first_name": None,
|
||||||
|
"cisa_representative_last_name": None,
|
||||||
"cisa_representative_email": None,
|
"cisa_representative_email": None,
|
||||||
"has_anything_else_text": None,
|
"has_anything_else_text": None,
|
||||||
"anything_else": None,
|
"anything_else": None,
|
||||||
|
@ -1928,6 +1972,8 @@ class TestDomainRequestIncomplete(TestCase):
|
||||||
# Anything Else Text - No
|
# Anything Else Text - No
|
||||||
{
|
{
|
||||||
"has_cisa_representative": False,
|
"has_cisa_representative": False,
|
||||||
|
"cisa_representative_first_name": None,
|
||||||
|
"cisa_representative_last_name": None,
|
||||||
"cisa_representative_email": None,
|
"cisa_representative_email": None,
|
||||||
"has_anything_else_text": False,
|
"has_anything_else_text": False,
|
||||||
"anything_else": None,
|
"anything_else": None,
|
||||||
|
@ -1937,6 +1983,8 @@ class TestDomainRequestIncomplete(TestCase):
|
||||||
# Anything Else Radio - None
|
# Anything Else Radio - None
|
||||||
{
|
{
|
||||||
"has_cisa_representative": None,
|
"has_cisa_representative": None,
|
||||||
|
"cisa_representative_first_name": None,
|
||||||
|
"cisa_representative_last_name": None,
|
||||||
"cisa_representative_email": None,
|
"cisa_representative_email": None,
|
||||||
"has_anything_else_text": None,
|
"has_anything_else_text": None,
|
||||||
"anything_else": None,
|
"anything_else": None,
|
||||||
|
|
|
@ -1901,12 +1901,8 @@ class TestRegistrantDNSSEC(MockEppLib):
|
||||||
3 - setter adds the UpdateDNSSECExtension extension to the command
|
3 - setter adds the UpdateDNSSECExtension extension to the command
|
||||||
4 - setter causes the getter to call info domain on next get from cache
|
4 - setter causes the getter to call info domain on next get from cache
|
||||||
5 - getter properly parses dnssecdata from InfoDomain response and sets to cache
|
5 - getter properly parses dnssecdata from InfoDomain response and sets to cache
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# need to use a separate patcher and side_effect for this test, as
|
|
||||||
# response from InfoDomain must be different for different iterations
|
|
||||||
# of the same command
|
|
||||||
def side_effect(_request, cleaned):
|
def side_effect(_request, cleaned):
|
||||||
if isinstance(_request, commands.InfoDomain):
|
if isinstance(_request, commands.InfoDomain):
|
||||||
if mocked_send.call_count == 1:
|
if mocked_send.call_count == 1:
|
||||||
|
@ -1924,17 +1920,30 @@ class TestRegistrantDNSSEC(MockEppLib):
|
||||||
mocked_send = patcher.start()
|
mocked_send = patcher.start()
|
||||||
mocked_send.side_effect = side_effect
|
mocked_send.side_effect = side_effect
|
||||||
domain, _ = Domain.objects.get_or_create(name="dnssec-dsdata.gov")
|
domain, _ = Domain.objects.get_or_create(name="dnssec-dsdata.gov")
|
||||||
|
|
||||||
|
# Check initial dsdata_last_change value (should be None)
|
||||||
|
initial_change = domain.dsdata_last_change
|
||||||
|
|
||||||
|
# Adding dnssec data
|
||||||
domain.dnssecdata = self.dnssecExtensionWithDsData
|
domain.dnssecdata = self.dnssecExtensionWithDsData
|
||||||
# get the DNS SEC extension added to the UpdateDomain command and
|
|
||||||
|
# Check dsdata_last_change is updated after adding data
|
||||||
|
domain = Domain.objects.get(name="dnssec-dsdata.gov")
|
||||||
|
self.assertIsNotNone(domain.dsdata_last_change)
|
||||||
|
|
||||||
|
self.assertNotEqual(domain.dsdata_last_change, initial_change)
|
||||||
|
|
||||||
|
# Get the DNS SEC extension added to the UpdateDomain command and
|
||||||
# verify that it is properly sent
|
# verify that it is properly sent
|
||||||
# args[0] is the _request sent to registry
|
# args[0] is the _request sent to registry
|
||||||
args, _ = mocked_send.call_args
|
args, _ = mocked_send.call_args
|
||||||
# assert that the extension on the update matches
|
# Assert that the extension on the update matches
|
||||||
self.assertEquals(
|
self.assertEquals(
|
||||||
args[0].extensions[0],
|
args[0].extensions[0],
|
||||||
self.createUpdateExtension(self.dnssecExtensionWithDsData),
|
self.createUpdateExtension(self.dnssecExtensionWithDsData),
|
||||||
)
|
)
|
||||||
# test that the dnssecdata getter is functioning properly
|
|
||||||
|
# Test that the dnssecdata getter is functioning properly
|
||||||
dnssecdata_get = domain.dnssecdata
|
dnssecdata_get = domain.dnssecdata
|
||||||
mocked_send.assert_has_calls(
|
mocked_send.assert_has_calls(
|
||||||
[
|
[
|
||||||
|
@ -2129,13 +2138,9 @@ class TestRegistrantDNSSEC(MockEppLib):
|
||||||
2 - first setter calls UpdateDomain command
|
2 - first setter calls UpdateDomain command
|
||||||
3 - second setter calls InfoDomain command again
|
3 - second setter calls InfoDomain command again
|
||||||
3 - setter then calls UpdateDomain command
|
3 - setter then calls UpdateDomain command
|
||||||
4 - setter adds the UpdateDNSSECExtension extension to the command with rem
|
4 - setter adds the UpdateDNSSExtension extension to the command with rem
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# need to use a separate patcher and side_effect for this test, as
|
|
||||||
# response from InfoDomain must be different for different iterations
|
|
||||||
# of the same command
|
|
||||||
def side_effect(_request, cleaned):
|
def side_effect(_request, cleaned):
|
||||||
if isinstance(_request, commands.InfoDomain):
|
if isinstance(_request, commands.InfoDomain):
|
||||||
if mocked_send.call_count == 1:
|
if mocked_send.call_count == 1:
|
||||||
|
@ -2153,10 +2158,25 @@ class TestRegistrantDNSSEC(MockEppLib):
|
||||||
mocked_send = patcher.start()
|
mocked_send = patcher.start()
|
||||||
mocked_send.side_effect = side_effect
|
mocked_send.side_effect = side_effect
|
||||||
domain, _ = Domain.objects.get_or_create(name="dnssec-dsdata.gov")
|
domain, _ = Domain.objects.get_or_create(name="dnssec-dsdata.gov")
|
||||||
# dnssecdata_get_initial = domain.dnssecdata # call to force initial mock
|
|
||||||
# domain._invalidate_cache()
|
# Initial setting of dnssec data
|
||||||
domain.dnssecdata = self.dnssecExtensionWithDsData
|
domain.dnssecdata = self.dnssecExtensionWithDsData
|
||||||
|
|
||||||
|
# Check dsdata_last_change is updated
|
||||||
|
domain = Domain.objects.get(name="dnssec-dsdata.gov")
|
||||||
|
self.assertIsNotNone(domain.dsdata_last_change)
|
||||||
|
|
||||||
|
initial_change = domain.dsdata_last_change
|
||||||
|
|
||||||
|
# Remove dnssec data
|
||||||
domain.dnssecdata = self.dnssecExtensionRemovingDsData
|
domain.dnssecdata = self.dnssecExtensionRemovingDsData
|
||||||
|
|
||||||
|
# Check that dsdata_last_change is updated again
|
||||||
|
domain = Domain.objects.get(name="dnssec-dsdata.gov")
|
||||||
|
self.assertIsNotNone(domain.dsdata_last_change)
|
||||||
|
|
||||||
|
self.assertNotEqual(domain.dsdata_last_change, initial_change)
|
||||||
|
|
||||||
# get the DNS SEC extension added to the UpdateDomain command and
|
# get the DNS SEC extension added to the UpdateDomain command and
|
||||||
# verify that it is properly sent
|
# verify that it is properly sent
|
||||||
# args[0] is the _request sent to registry
|
# args[0] is the _request sent to registry
|
||||||
|
|
|
@ -4,6 +4,7 @@ from django.test import Client, RequestFactory
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from registrar.models.domain_request import DomainRequest
|
from registrar.models.domain_request import DomainRequest
|
||||||
from registrar.models.domain import Domain
|
from registrar.models.domain import Domain
|
||||||
|
from registrar.models.utility.generic_helper import convert_queryset_to_dict
|
||||||
from registrar.utility.csv_export import (
|
from registrar.utility.csv_export import (
|
||||||
export_data_managed_domains_to_csv,
|
export_data_managed_domains_to_csv,
|
||||||
export_data_unmanaged_domains_to_csv,
|
export_data_unmanaged_domains_to_csv,
|
||||||
|
@ -12,7 +13,7 @@ from registrar.utility.csv_export import (
|
||||||
write_csv_for_domains,
|
write_csv_for_domains,
|
||||||
get_default_start_date,
|
get_default_start_date,
|
||||||
get_default_end_date,
|
get_default_end_date,
|
||||||
write_csv_for_requests,
|
DomainRequestExport,
|
||||||
)
|
)
|
||||||
|
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
|
@ -23,6 +24,7 @@ from botocore.exceptions import ClientError
|
||||||
import boto3_mocking
|
import boto3_mocking
|
||||||
from registrar.utility.s3_bucket import S3ClientError, S3ClientErrorCodes # type: ignore
|
from registrar.utility.s3_bucket import S3ClientError, S3ClientErrorCodes # type: ignore
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from api.tests.common import less_console_noise_decorator
|
||||||
from .common import MockDb, MockEppLib, less_console_noise, get_time_aware_date
|
from .common import MockDb, MockEppLib, less_console_noise, get_time_aware_date
|
||||||
|
|
||||||
|
|
||||||
|
@ -667,10 +669,7 @@ class ExportDataTest(MockDb, MockEppLib):
|
||||||
# Define columns, sort fields, and filter condition
|
# Define columns, sort fields, and filter condition
|
||||||
# We'll skip submission date because it's dynamic and therefore
|
# We'll skip submission date because it's dynamic and therefore
|
||||||
# impossible to set in expected_content
|
# impossible to set in expected_content
|
||||||
columns = [
|
columns = ["Domain request", "Domain type", "Federal type"]
|
||||||
"Requested domain",
|
|
||||||
"Organization type",
|
|
||||||
]
|
|
||||||
sort_fields = [
|
sort_fields = [
|
||||||
"requested_domain__name",
|
"requested_domain__name",
|
||||||
]
|
]
|
||||||
|
@ -679,7 +678,12 @@ class ExportDataTest(MockDb, MockEppLib):
|
||||||
"submission_date__lte": self.end_date,
|
"submission_date__lte": self.end_date,
|
||||||
"submission_date__gte": self.start_date,
|
"submission_date__gte": self.start_date,
|
||||||
}
|
}
|
||||||
write_csv_for_requests(writer, columns, sort_fields, filter_condition, should_write_header=True)
|
|
||||||
|
additional_values = ["requested_domain__name"]
|
||||||
|
all_requests = DomainRequest.objects.filter(**filter_condition).order_by(*sort_fields).distinct()
|
||||||
|
annotated_requests = DomainRequestExport.annotate_and_retrieve_fields(all_requests, {}, additional_values)
|
||||||
|
requests_dict = convert_queryset_to_dict(annotated_requests, is_model=False)
|
||||||
|
DomainRequestExport.write_csv_for_requests(writer, columns, requests_dict)
|
||||||
# Reset the CSV file's position to the beginning
|
# Reset the CSV file's position to the beginning
|
||||||
csv_file.seek(0)
|
csv_file.seek(0)
|
||||||
# Read the content into a variable
|
# Read the content into a variable
|
||||||
|
@ -687,9 +691,10 @@ class ExportDataTest(MockDb, MockEppLib):
|
||||||
# We expect READY domains first, created between today-2 and today+2, sorted by created_at then name
|
# We expect READY domains first, created between today-2 and today+2, sorted by created_at then name
|
||||||
# and DELETED domains deleted between today-2 and today+2, sorted by deleted then name
|
# and DELETED domains deleted between today-2 and today+2, sorted by deleted then name
|
||||||
expected_content = (
|
expected_content = (
|
||||||
"Requested domain,Organization type\n"
|
"Domain request,Domain type,Federal type\n"
|
||||||
"city3.gov,Federal - Executive\n"
|
"city3.gov,Federal,Executive\n"
|
||||||
"city4.gov,Federal - Executive\n"
|
"city4.gov,City,Executive\n"
|
||||||
|
"city6.gov,Federal,Executive\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Normalize line endings and remove commas,
|
# Normalize line endings and remove commas,
|
||||||
|
@ -699,6 +704,72 @@ class ExportDataTest(MockDb, MockEppLib):
|
||||||
|
|
||||||
self.assertEqual(csv_content, expected_content)
|
self.assertEqual(csv_content, expected_content)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_full_domain_request_report(self):
|
||||||
|
"""Tests the full domain request report."""
|
||||||
|
|
||||||
|
# Create a CSV file in memory
|
||||||
|
csv_file = StringIO()
|
||||||
|
writer = csv.writer(csv_file)
|
||||||
|
|
||||||
|
# Call the report. Get existing fields from the report itself.
|
||||||
|
annotations = DomainRequestExport._full_domain_request_annotations()
|
||||||
|
additional_values = [
|
||||||
|
"requested_domain__name",
|
||||||
|
"federal_agency__agency",
|
||||||
|
"authorizing_official__first_name",
|
||||||
|
"authorizing_official__last_name",
|
||||||
|
"authorizing_official__email",
|
||||||
|
"authorizing_official__title",
|
||||||
|
"creator__first_name",
|
||||||
|
"creator__last_name",
|
||||||
|
"creator__email",
|
||||||
|
"investigator__email",
|
||||||
|
]
|
||||||
|
requests = DomainRequest.objects.exclude(status=DomainRequest.DomainRequestStatus.STARTED)
|
||||||
|
annotated_requests = DomainRequestExport.annotate_and_retrieve_fields(requests, annotations, additional_values)
|
||||||
|
requests_dict = convert_queryset_to_dict(annotated_requests, is_model=False)
|
||||||
|
DomainRequestExport.write_csv_for_requests(writer, DomainRequestExport.all_columns, requests_dict)
|
||||||
|
|
||||||
|
# Reset the CSV file's position to the beginning
|
||||||
|
csv_file.seek(0)
|
||||||
|
# Read the content into a variable
|
||||||
|
csv_content = csv_file.read()
|
||||||
|
print(csv_content)
|
||||||
|
self.maxDiff = None
|
||||||
|
expected_content = (
|
||||||
|
# Header
|
||||||
|
"Domain request,Submitted at,Status,Domain type,Federal type,"
|
||||||
|
"Federal agency,Organization name,Election office,City,State/territory,"
|
||||||
|
"Region,Creator first name,Creator last name,Creator email,Creator approved domains count,"
|
||||||
|
"Creator active requests count,Alternative domains,AO first name,AO last name,AO email,"
|
||||||
|
"AO title/role,Request purpose,Request additional details,Other contacts,"
|
||||||
|
"CISA regional representative,Current websites,Investigator\n"
|
||||||
|
# Content
|
||||||
|
"city2.gov,,In review,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,city1.gov,Testy,Tester,testy@town.com,"
|
||||||
|
"Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n"
|
||||||
|
"city3.gov,2024-04-02,Submitted,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,"
|
||||||
|
"cheeseville.gov | city1.gov | igorville.gov,Testy,Tester,testy@town.com,Chief Tester,"
|
||||||
|
"Purpose of the site,CISA-first-name CISA-last-name | There is more,Meow Tester24 te2@town.com | "
|
||||||
|
"Testy1232 Tester24 te2@town.com | Testy Tester testy2@town.com,test@igorville.com,"
|
||||||
|
"city.com | https://www.example2.com | https://www.example.com,\n"
|
||||||
|
"city4.gov,2024-04-02,Submitted,City,Executive,,Testorg,Yes,,NY,2,,,,0,1,city1.gov,Testy,Tester,"
|
||||||
|
"testy@town.com,Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,"
|
||||||
|
"Testy Tester testy2@town.com,cisaRep@igorville.gov,city.com,\n"
|
||||||
|
"city5.gov,,Approved,Federal,Executive,,Testorg,N/A,,NY,2,,,,1,0,city1.gov,Testy,Tester,testy@town.com,"
|
||||||
|
"Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n"
|
||||||
|
"city6.gov,2024-04-02,Submitted,Federal,Executive,,Testorg,N/A,,NY,2,,,,0,1,city1.gov,Testy,Tester,"
|
||||||
|
"testy@town.com,Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,"
|
||||||
|
"Testy Tester testy2@town.com,cisaRep@igorville.gov,city.com,"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Normalize line endings and remove commas,
|
||||||
|
# spaces and leading/trailing whitespace
|
||||||
|
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
|
||||||
|
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
|
||||||
|
|
||||||
|
self.assertEqual(csv_content, expected_content)
|
||||||
|
|
||||||
|
|
||||||
class HelperFunctions(MockDb):
|
class HelperFunctions(MockDb):
|
||||||
"""This asserts that 1=1. Its limited usefulness lies in making sure the helper methods stay healthy."""
|
"""This asserts that 1=1. Its limited usefulness lies in making sure the helper methods stay healthy."""
|
||||||
|
@ -741,5 +812,5 @@ class HelperFunctions(MockDb):
|
||||||
"submission_date__lte": self.end_date,
|
"submission_date__lte": self.end_date,
|
||||||
}
|
}
|
||||||
submitted_requests_sliced_at_end_date = get_sliced_requests(filter_condition)
|
submitted_requests_sliced_at_end_date = get_sliced_requests(filter_condition)
|
||||||
expected_content = [2, 2, 0, 0, 0, 0, 0, 0, 0, 0]
|
expected_content = [3, 2, 0, 0, 0, 0, 1, 0, 0, 1]
|
||||||
self.assertEqual(submitted_requests_sliced_at_end_date, expected_content)
|
self.assertEqual(submitted_requests_sliced_at_end_date, expected_content)
|
||||||
|
|
|
@ -63,11 +63,24 @@ class TestWithUser(MockEppLib):
|
||||||
self.user.contact.title = title
|
self.user.contact.title = title
|
||||||
self.user.contact.save()
|
self.user.contact.save()
|
||||||
|
|
||||||
username_incomplete = "test_user_incomplete"
|
username_regular_incomplete = "test_regular_user_incomplete"
|
||||||
|
username_other_incomplete = "test_other_user_incomplete"
|
||||||
first_name_2 = "Incomplete"
|
first_name_2 = "Incomplete"
|
||||||
email_2 = "unicorn@igorville.com"
|
email_2 = "unicorn@igorville.com"
|
||||||
self.incomplete_user = get_user_model().objects.create(
|
# in the case below, REGULAR user is 'Verified by Login.gov, ie. IAL2
|
||||||
username=username_incomplete, first_name=first_name_2, email=email_2
|
self.incomplete_regular_user = get_user_model().objects.create(
|
||||||
|
username=username_regular_incomplete,
|
||||||
|
first_name=first_name_2,
|
||||||
|
email=email_2,
|
||||||
|
verification_type=User.VerificationTypeChoices.REGULAR,
|
||||||
|
)
|
||||||
|
# in the case below, other user is representative of GRANDFATHERED,
|
||||||
|
# VERIFIED_BY_STAFF, INVITED, FIXTURE_USER, ie. IAL1
|
||||||
|
self.incomplete_other_user = get_user_model().objects.create(
|
||||||
|
username=username_other_incomplete,
|
||||||
|
first_name=first_name_2,
|
||||||
|
email=email_2,
|
||||||
|
verification_type=User.VerificationTypeChoices.VERIFIED_BY_STAFF,
|
||||||
)
|
)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
|
@ -75,8 +88,7 @@ class TestWithUser(MockEppLib):
|
||||||
super().tearDown()
|
super().tearDown()
|
||||||
DomainRequest.objects.all().delete()
|
DomainRequest.objects.all().delete()
|
||||||
DomainInformation.objects.all().delete()
|
DomainInformation.objects.all().delete()
|
||||||
self.user.delete()
|
User.objects.all().delete()
|
||||||
self.incomplete_user.delete()
|
|
||||||
|
|
||||||
|
|
||||||
class TestEnvironmentVariablesEffects(TestCase):
|
class TestEnvironmentVariablesEffects(TestCase):
|
||||||
|
@ -384,15 +396,15 @@ class HomeTests(TestWithUser):
|
||||||
)
|
)
|
||||||
domain_request_2.other_contacts.set([contact_shared])
|
domain_request_2.other_contacts.set([contact_shared])
|
||||||
|
|
||||||
# Ensure that igorville.gov exists on the page
|
igorville = DomainRequest.objects.filter(requested_domain__name="igorville.gov")
|
||||||
home_page = self.client.get("/")
|
self.assertTrue(igorville.exists())
|
||||||
self.assertContains(home_page, "igorville.gov")
|
|
||||||
|
|
||||||
# Trigger the delete logic
|
# Trigger the delete logic
|
||||||
response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True)
|
self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}))
|
||||||
|
|
||||||
# igorville is now deleted
|
# igorville is now deleted
|
||||||
self.assertNotContains(response, "igorville.gov")
|
igorville = DomainRequest.objects.filter(requested_domain__name="igorville.gov")
|
||||||
|
self.assertFalse(igorville.exists())
|
||||||
|
|
||||||
# Check if the orphaned contact was deleted
|
# Check if the orphaned contact was deleted
|
||||||
orphan = Contact.objects.filter(id=contact.id)
|
orphan = Contact.objects.filter(id=contact.id)
|
||||||
|
@ -456,13 +468,14 @@ class HomeTests(TestWithUser):
|
||||||
)
|
)
|
||||||
domain_request_2.other_contacts.set([contact_shared])
|
domain_request_2.other_contacts.set([contact_shared])
|
||||||
|
|
||||||
home_page = self.client.get("/")
|
teaville = DomainRequest.objects.filter(requested_domain__name="teaville.gov")
|
||||||
self.assertContains(home_page, "teaville.gov")
|
self.assertTrue(teaville.exists())
|
||||||
|
|
||||||
# Trigger the delete logic
|
# Trigger the delete logic
|
||||||
response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request_2.pk}), follow=True)
|
self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request_2.pk}))
|
||||||
|
|
||||||
self.assertNotContains(response, "teaville.gov")
|
teaville = DomainRequest.objects.filter(requested_domain__name="teaville.gov")
|
||||||
|
self.assertFalse(teaville.exists())
|
||||||
|
|
||||||
# Check if the orphaned contact was deleted
|
# Check if the orphaned contact was deleted
|
||||||
orphan = Contact.objects.filter(id=contact_shared.id)
|
orphan = Contact.objects.filter(id=contact_shared.id)
|
||||||
|
@ -525,7 +538,7 @@ class FinishUserProfileTests(TestWithUser, WebTest):
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def test_new_user_with_profile_feature_on(self):
|
def test_new_user_with_profile_feature_on(self):
|
||||||
"""Tests that a new user is redirected to the profile setup page when profile_feature is on"""
|
"""Tests that a new user is redirected to the profile setup page when profile_feature is on"""
|
||||||
self.app.set_user(self.incomplete_user.username)
|
self.app.set_user(self.incomplete_regular_user.username)
|
||||||
with override_flag("profile_feature", active=True):
|
with override_flag("profile_feature", active=True):
|
||||||
# This will redirect the user to the setup page.
|
# This will redirect the user to the setup page.
|
||||||
# Follow implicity checks if our redirect is working.
|
# Follow implicity checks if our redirect is working.
|
||||||
|
@ -564,7 +577,7 @@ class FinishUserProfileTests(TestWithUser, WebTest):
|
||||||
def test_new_user_goes_to_domain_request_with_profile_feature_on(self):
|
def test_new_user_goes_to_domain_request_with_profile_feature_on(self):
|
||||||
"""Tests that a new user is redirected to the domain request page when profile_feature is on"""
|
"""Tests that a new user is redirected to the domain request page when profile_feature is on"""
|
||||||
|
|
||||||
self.app.set_user(self.incomplete_user.username)
|
self.app.set_user(self.incomplete_regular_user.username)
|
||||||
with override_flag("profile_feature", active=True):
|
with override_flag("profile_feature", active=True):
|
||||||
# This will redirect the user to the setup page
|
# This will redirect the user to the setup page
|
||||||
finish_setup_page = self.app.get(reverse("domain-request:")).follow()
|
finish_setup_page = self.app.get(reverse("domain-request:")).follow()
|
||||||
|
@ -618,6 +631,106 @@ class FinishUserProfileTests(TestWithUser, WebTest):
|
||||||
self.assertContains(response, "You’re about to start your .gov domain request")
|
self.assertContains(response, "You’re about to start your .gov domain request")
|
||||||
|
|
||||||
|
|
||||||
|
class FinishUserProfileForOtherUsersTests(TestWithUser, WebTest):
|
||||||
|
"""A series of tests that target the user profile page intercept for incomplete IAL1 user profiles."""
|
||||||
|
|
||||||
|
# csrf checks do not work well with WebTest.
|
||||||
|
# We disable them here.
|
||||||
|
csrf_checks = False
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.user.title = None
|
||||||
|
self.user.save()
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.domain, _ = Domain.objects.get_or_create(name="sampledomain.gov", state=Domain.State.READY)
|
||||||
|
self.role, _ = UserDomainRole.objects.get_or_create(
|
||||||
|
user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
super().tearDown()
|
||||||
|
PublicContact.objects.filter(domain=self.domain).delete()
|
||||||
|
self.role.delete()
|
||||||
|
self.domain.delete()
|
||||||
|
Domain.objects.all().delete()
|
||||||
|
Website.objects.all().delete()
|
||||||
|
Contact.objects.all().delete()
|
||||||
|
|
||||||
|
def _set_session_cookie(self):
|
||||||
|
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||||
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
|
||||||
|
def _submit_form_webtest(self, form, follow=False):
|
||||||
|
page = form.submit()
|
||||||
|
self._set_session_cookie()
|
||||||
|
return page.follow() if follow else page
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_new_user_with_profile_feature_on(self):
|
||||||
|
"""Tests that a new user is redirected to the profile setup page when profile_feature is on,
|
||||||
|
and testing that the confirmation modal is present"""
|
||||||
|
self.app.set_user(self.incomplete_other_user.username)
|
||||||
|
with override_flag("profile_feature", active=True):
|
||||||
|
# This will redirect the user to the user profile page.
|
||||||
|
# Follow implicity checks if our redirect is working.
|
||||||
|
user_profile_page = self.app.get(reverse("home")).follow()
|
||||||
|
self._set_session_cookie()
|
||||||
|
|
||||||
|
# Assert that we're on the right page by testing for the modal
|
||||||
|
self.assertContains(user_profile_page, "domain registrants must maintain accurate contact information")
|
||||||
|
|
||||||
|
user_profile_page = self._submit_form_webtest(user_profile_page.form)
|
||||||
|
|
||||||
|
self.assertEqual(user_profile_page.status_code, 200)
|
||||||
|
|
||||||
|
# Assert that modal does not appear on subsequent submits
|
||||||
|
self.assertNotContains(user_profile_page, "domain registrants must maintain accurate contact information")
|
||||||
|
# Assert that unique error message appears by testing the message in a specific div
|
||||||
|
html_content = user_profile_page.content.decode("utf-8")
|
||||||
|
# Normalize spaces and line breaks in the HTML content
|
||||||
|
normalized_html_content = " ".join(html_content.split())
|
||||||
|
# Expected string without extra spaces and line breaks
|
||||||
|
expected_string = "Before you can manage your domain, we need you to add contact information."
|
||||||
|
# Check for the presence of the <div> element with the specific text
|
||||||
|
self.assertIn(f'<div class="usa-alert__body"> {expected_string} </div>', normalized_html_content)
|
||||||
|
|
||||||
|
# We're missing a phone number, so the page should tell us that
|
||||||
|
self.assertContains(user_profile_page, "Enter your phone number.")
|
||||||
|
|
||||||
|
# We need to assert that links to manage your domain are not present (in both body and footer)
|
||||||
|
self.assertNotContains(user_profile_page, "Manage your domains")
|
||||||
|
# Assert the tooltip on the logo, indicating that the logo is not clickable
|
||||||
|
self.assertContains(
|
||||||
|
user_profile_page, 'title="Before you can manage your domains, we need you to add contact information."'
|
||||||
|
)
|
||||||
|
# Assert that modal does not appear on subsequent submits
|
||||||
|
self.assertNotContains(user_profile_page, "domain registrants must maintain accurate contact information")
|
||||||
|
|
||||||
|
# Add a phone number
|
||||||
|
finish_setup_form = user_profile_page.form
|
||||||
|
finish_setup_form["phone"] = "(201) 555-0123"
|
||||||
|
finish_setup_form["title"] = "CEO"
|
||||||
|
finish_setup_form["last_name"] = "example"
|
||||||
|
save_page = self._submit_form_webtest(finish_setup_form, follow=True)
|
||||||
|
|
||||||
|
self.assertEqual(save_page.status_code, 200)
|
||||||
|
self.assertContains(save_page, "Your profile has been updated.")
|
||||||
|
|
||||||
|
# We need to assert that logo is not clickable and links to manage your domain are not present
|
||||||
|
self.assertContains(save_page, "anage your domains", count=2)
|
||||||
|
self.assertNotContains(
|
||||||
|
save_page, "Before you can manage your domains, we need you to add contact information"
|
||||||
|
)
|
||||||
|
# Assert that modal does not appear on subsequent submits
|
||||||
|
self.assertNotContains(save_page, "domain registrants must maintain accurate contact information")
|
||||||
|
|
||||||
|
# Try to navigate back to the home page.
|
||||||
|
# This is the same as clicking the back button.
|
||||||
|
completed_setup_page = self.app.get(reverse("home"))
|
||||||
|
self.assertContains(completed_setup_page, "Manage your domain")
|
||||||
|
|
||||||
|
|
||||||
class UserProfileTests(TestWithUser, WebTest):
|
class UserProfileTests(TestWithUser, WebTest):
|
||||||
"""A series of tests that target your profile functionality"""
|
"""A series of tests that target your profile functionality"""
|
||||||
|
|
||||||
|
|
|
@ -102,6 +102,35 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
|
||||||
)
|
)
|
||||||
self.assertEqual(svg_icon_expected, svg_icons[i])
|
self.assertEqual(svg_icon_expected, svg_icons[i])
|
||||||
|
|
||||||
|
def test_get_domains_json_search(self):
|
||||||
|
"""Test search."""
|
||||||
|
# Define your URL variables as a dictionary
|
||||||
|
url_vars = {"search_term": "e2"}
|
||||||
|
|
||||||
|
# Use the params parameter to include URL variables
|
||||||
|
response = self.app.get(reverse("get_domains_json"), params=url_vars)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json
|
||||||
|
|
||||||
|
# Check pagination info
|
||||||
|
self.assertEqual(data["page"], 1)
|
||||||
|
self.assertFalse(data["has_next"])
|
||||||
|
self.assertFalse(data["has_previous"])
|
||||||
|
self.assertEqual(data["num_pages"], 1)
|
||||||
|
self.assertEqual(data["total"], 1)
|
||||||
|
self.assertEqual(data["unfiltered_total"], 3)
|
||||||
|
|
||||||
|
# Check the number of domain requests
|
||||||
|
self.assertEqual(len(data["domains"]), 1)
|
||||||
|
|
||||||
|
# Extract fields from response
|
||||||
|
domains = [request["name"] for request in data["domains"]]
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
self.domain2.name,
|
||||||
|
domains[0],
|
||||||
|
)
|
||||||
|
|
||||||
def test_pagination(self):
|
def test_pagination(self):
|
||||||
"""Test that pagination is correct in the response"""
|
"""Test that pagination is correct in the response"""
|
||||||
response = self.app.get(reverse("get_domains_json"), {"page": 1})
|
response = self.app.get(reverse("get_domains_json"), {"page": 1})
|
||||||
|
|
|
@ -102,6 +102,58 @@ class DomainRequestTests(TestWithUser, WebTest):
|
||||||
|
|
||||||
self.assertContains(type_page, "You cannot submit this request yet")
|
self.assertContains(type_page, "You cannot submit this request yet")
|
||||||
|
|
||||||
|
def test_domain_request_into_acknowledgement_creates_new_request(self):
|
||||||
|
"""
|
||||||
|
We had to solve a bug where the wizard was creating 2 requests on first intro acknowledgement ('continue')
|
||||||
|
The wizard was also creating multiiple requests on 'continue' -> back button -> 'continue' etc.
|
||||||
|
|
||||||
|
This tests that the domain requests get created only when they should.
|
||||||
|
"""
|
||||||
|
# Get the intro page
|
||||||
|
self.app.get(reverse("home"))
|
||||||
|
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||||
|
|
||||||
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
intro_page = self.app.get(reverse("domain-request:"))
|
||||||
|
|
||||||
|
# Select the form
|
||||||
|
intro_form = intro_page.forms[0]
|
||||||
|
|
||||||
|
# Submit the form, this creates 1 Request
|
||||||
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
response = intro_form.submit(name="submit_button", value="intro_acknowledge")
|
||||||
|
|
||||||
|
# Landing on the next page used to create another 1 request
|
||||||
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
response.follow()
|
||||||
|
|
||||||
|
# Check if a new DomainRequest object has been created
|
||||||
|
domain_request_count = DomainRequest.objects.count()
|
||||||
|
self.assertEqual(domain_request_count, 1)
|
||||||
|
|
||||||
|
# Let's go back to intro and submit again, this should not create a new request
|
||||||
|
# This is the equivalent of a back button nav from step 1 to intro -> continue
|
||||||
|
intro_form = intro_page.forms[0]
|
||||||
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
type_form = intro_form.submit(name="submit_button", value="intro_acknowledge")
|
||||||
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
type_form.follow()
|
||||||
|
domain_request_count = DomainRequest.objects.count()
|
||||||
|
self.assertEqual(domain_request_count, 1)
|
||||||
|
|
||||||
|
# Go home, which will reset the session flag for new request
|
||||||
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
self.app.get(reverse("home"))
|
||||||
|
|
||||||
|
# This time, clicking continue will create a new request
|
||||||
|
intro_form = intro_page.forms[0]
|
||||||
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
intro_result = intro_form.submit(name="submit_button", value="intro_acknowledge")
|
||||||
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
intro_result.follow()
|
||||||
|
domain_request_count = DomainRequest.objects.count()
|
||||||
|
self.assertEqual(domain_request_count, 2)
|
||||||
|
|
||||||
@boto3_mocking.patching
|
@boto3_mocking.patching
|
||||||
def test_domain_request_form_submission(self):
|
def test_domain_request_form_submission(self):
|
||||||
"""
|
"""
|
||||||
|
@ -366,6 +418,8 @@ class DomainRequestTests(TestWithUser, WebTest):
|
||||||
|
|
||||||
additional_details_form["additional_details-has_cisa_representative"] = "True"
|
additional_details_form["additional_details-has_cisa_representative"] = "True"
|
||||||
additional_details_form["additional_details-has_anything_else_text"] = "True"
|
additional_details_form["additional_details-has_anything_else_text"] = "True"
|
||||||
|
additional_details_form["additional_details-cisa_representative_first_name"] = "CISA-first-name"
|
||||||
|
additional_details_form["additional_details-cisa_representative_last_name"] = "CISA-last-name"
|
||||||
additional_details_form["additional_details-cisa_representative_email"] = "FakeEmail@gmail.com"
|
additional_details_form["additional_details-cisa_representative_email"] = "FakeEmail@gmail.com"
|
||||||
additional_details_form["additional_details-anything_else"] = "Nothing else."
|
additional_details_form["additional_details-anything_else"] = "Nothing else."
|
||||||
|
|
||||||
|
@ -374,6 +428,8 @@ class DomainRequestTests(TestWithUser, WebTest):
|
||||||
additional_details_result = additional_details_form.submit()
|
additional_details_result = additional_details_form.submit()
|
||||||
# validate that data from this step are being saved
|
# validate that data from this step are being saved
|
||||||
domain_request = DomainRequest.objects.get() # there's only one
|
domain_request = DomainRequest.objects.get() # there's only one
|
||||||
|
self.assertEqual(domain_request.cisa_representative_first_name, "CISA-first-name")
|
||||||
|
self.assertEqual(domain_request.cisa_representative_last_name, "CISA-last-name")
|
||||||
self.assertEqual(domain_request.cisa_representative_email, "FakeEmail@gmail.com")
|
self.assertEqual(domain_request.cisa_representative_email, "FakeEmail@gmail.com")
|
||||||
self.assertEqual(domain_request.anything_else, "Nothing else.")
|
self.assertEqual(domain_request.anything_else, "Nothing else.")
|
||||||
# the post request should return a redirect to the next form in
|
# the post request should return a redirect to the next form in
|
||||||
|
@ -719,6 +775,8 @@ class DomainRequestTests(TestWithUser, WebTest):
|
||||||
|
|
||||||
additional_details_form["additional_details-has_cisa_representative"] = "True"
|
additional_details_form["additional_details-has_cisa_representative"] = "True"
|
||||||
additional_details_form["additional_details-has_anything_else_text"] = "True"
|
additional_details_form["additional_details-has_anything_else_text"] = "True"
|
||||||
|
additional_details_form["additional_details-cisa_representative_first_name"] = "cisa-first-name"
|
||||||
|
additional_details_form["additional_details-cisa_representative_last_name"] = "cisa-last-name"
|
||||||
additional_details_form["additional_details-cisa_representative_email"] = "FakeEmail@gmail.com"
|
additional_details_form["additional_details-cisa_representative_email"] = "FakeEmail@gmail.com"
|
||||||
additional_details_form["additional_details-anything_else"] = "Nothing else."
|
additional_details_form["additional_details-anything_else"] = "Nothing else."
|
||||||
|
|
||||||
|
@ -727,6 +785,8 @@ class DomainRequestTests(TestWithUser, WebTest):
|
||||||
additional_details_result = additional_details_form.submit()
|
additional_details_result = additional_details_form.submit()
|
||||||
# validate that data from this step are being saved
|
# validate that data from this step are being saved
|
||||||
domain_request = DomainRequest.objects.get() # there's only one
|
domain_request = DomainRequest.objects.get() # there's only one
|
||||||
|
self.assertEqual(domain_request.cisa_representative_first_name, "cisa-first-name")
|
||||||
|
self.assertEqual(domain_request.cisa_representative_last_name, "cisa-last-name")
|
||||||
self.assertEqual(domain_request.cisa_representative_email, "FakeEmail@gmail.com")
|
self.assertEqual(domain_request.cisa_representative_email, "FakeEmail@gmail.com")
|
||||||
self.assertEqual(domain_request.anything_else, "Nothing else.")
|
self.assertEqual(domain_request.anything_else, "Nothing else.")
|
||||||
# the post request should return a redirect to the next form in
|
# the post request should return a redirect to the next form in
|
||||||
|
@ -1125,11 +1185,10 @@ class DomainRequestTests(TestWithUser, WebTest):
|
||||||
|
|
||||||
def test_yes_no_form_inits_yes_for_cisa_representative_and_anything_else(self):
|
def test_yes_no_form_inits_yes_for_cisa_representative_and_anything_else(self):
|
||||||
"""On the Additional Details page, the yes/no form gets initialized with YES selected
|
"""On the Additional Details page, the yes/no form gets initialized with YES selected
|
||||||
for both yes/no radios if the domain request has a value for cisa_representative and
|
for both yes/no radios if the domain request has a values for cisa_representative_first_name and
|
||||||
anything_else"""
|
anything_else"""
|
||||||
|
|
||||||
domain_request = completed_domain_request(user=self.user, has_anything_else=True)
|
domain_request = completed_domain_request(user=self.user, has_anything_else=True, has_cisa_representative=True)
|
||||||
domain_request.cisa_representative_email = "test@igorville.gov"
|
|
||||||
domain_request.anything_else = "1234"
|
domain_request.anything_else = "1234"
|
||||||
domain_request.save()
|
domain_request.save()
|
||||||
|
|
||||||
|
@ -1181,12 +1240,13 @@ class DomainRequestTests(TestWithUser, WebTest):
|
||||||
"""On the Additional details page, the form preselects "no" when has_cisa_representative
|
"""On the Additional details page, the form preselects "no" when has_cisa_representative
|
||||||
and anything_else is no"""
|
and anything_else is no"""
|
||||||
|
|
||||||
domain_request = completed_domain_request(user=self.user, has_anything_else=False)
|
domain_request = completed_domain_request(
|
||||||
|
user=self.user, has_anything_else=False, has_cisa_representative=False
|
||||||
|
)
|
||||||
|
|
||||||
# Unlike the other contacts form, the no button is tracked with these boolean fields.
|
# Unlike the other contacts form, the no button is tracked with these boolean fields.
|
||||||
# This means that we should expect this to correlate with the no button.
|
# This means that we should expect this to correlate with the no button.
|
||||||
domain_request.has_anything_else_text = False
|
domain_request.has_anything_else_text = False
|
||||||
domain_request.has_cisa_representative = False
|
|
||||||
domain_request.save()
|
domain_request.save()
|
||||||
|
|
||||||
# prime the form by visiting /edit
|
# prime the form by visiting /edit
|
||||||
|
@ -1205,7 +1265,7 @@ class DomainRequestTests(TestWithUser, WebTest):
|
||||||
|
|
||||||
# Check the cisa representative yes/no field
|
# Check the cisa representative yes/no field
|
||||||
yes_no_cisa = additional_details_form["additional_details-has_cisa_representative"].value
|
yes_no_cisa = additional_details_form["additional_details-has_cisa_representative"].value
|
||||||
self.assertEquals(yes_no_cisa, "False")
|
self.assertEquals(yes_no_cisa, None)
|
||||||
|
|
||||||
# Check the anything else yes/no field
|
# Check the anything else yes/no field
|
||||||
yes_no_anything_else = additional_details_form["additional_details-has_anything_else_text"].value
|
yes_no_anything_else = additional_details_form["additional_details-has_anything_else_text"].value
|
||||||
|
@ -1215,11 +1275,15 @@ class DomainRequestTests(TestWithUser, WebTest):
|
||||||
"""When a user submits the Additional Details form with no selected for all fields,
|
"""When a user submits the Additional Details form with no selected for all fields,
|
||||||
the domain request's data gets wiped when submitted"""
|
the domain request's data gets wiped when submitted"""
|
||||||
domain_request = completed_domain_request(name="nocisareps.gov", user=self.user)
|
domain_request = completed_domain_request(name="nocisareps.gov", user=self.user)
|
||||||
|
domain_request.cisa_representative_first_name = "cisa-firstname1"
|
||||||
|
domain_request.cisa_representative_last_name = "cisa-lastname1"
|
||||||
domain_request.cisa_representative_email = "fake@faketown.gov"
|
domain_request.cisa_representative_email = "fake@faketown.gov"
|
||||||
domain_request.save()
|
domain_request.save()
|
||||||
|
|
||||||
# Make sure we have the data we need for the test
|
# Make sure we have the data we need for the test
|
||||||
self.assertEqual(domain_request.anything_else, "There is more")
|
self.assertEqual(domain_request.anything_else, "There is more")
|
||||||
|
self.assertEqual(domain_request.cisa_representative_first_name, "cisa-firstname1")
|
||||||
|
self.assertEqual(domain_request.cisa_representative_last_name, "cisa-lastname1")
|
||||||
self.assertEqual(domain_request.cisa_representative_email, "fake@faketown.gov")
|
self.assertEqual(domain_request.cisa_representative_email, "fake@faketown.gov")
|
||||||
|
|
||||||
# prime the form by visiting /edit
|
# prime the form by visiting /edit
|
||||||
|
@ -1253,25 +1317,31 @@ class DomainRequestTests(TestWithUser, WebTest):
|
||||||
|
|
||||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
|
||||||
# Verify that the anything_else and cisa_representative have been deleted from the DB
|
# Verify that the anything_else and cisa_representative information have been deleted from the DB
|
||||||
domain_request = DomainRequest.objects.get(requested_domain__name="nocisareps.gov")
|
domain_request = DomainRequest.objects.get(requested_domain__name="nocisareps.gov")
|
||||||
|
|
||||||
# Check that our data has been cleared
|
# Check that our data has been cleared
|
||||||
self.assertEqual(domain_request.anything_else, None)
|
self.assertEqual(domain_request.anything_else, None)
|
||||||
|
self.assertEqual(domain_request.cisa_representative_first_name, None)
|
||||||
|
self.assertEqual(domain_request.cisa_representative_last_name, None)
|
||||||
self.assertEqual(domain_request.cisa_representative_email, None)
|
self.assertEqual(domain_request.cisa_representative_email, None)
|
||||||
|
|
||||||
# Double check the yes/no fields
|
# Double check the yes/no fields
|
||||||
self.assertEqual(domain_request.has_anything_else_text, False)
|
self.assertEqual(domain_request.has_anything_else_text, False)
|
||||||
self.assertEqual(domain_request.has_cisa_representative, False)
|
self.assertEqual(domain_request.cisa_representative_first_name, None)
|
||||||
|
self.assertEqual(domain_request.cisa_representative_last_name, None)
|
||||||
|
self.assertEqual(domain_request.cisa_representative_email, None)
|
||||||
|
|
||||||
def test_submitting_additional_details_populates_cisa_representative_and_anything_else(self):
|
def test_submitting_additional_details_populates_cisa_representative_and_anything_else(self):
|
||||||
"""When a user submits the Additional Details form,
|
"""When a user submits the Additional Details form,
|
||||||
the domain request's data gets submitted"""
|
the domain request's data gets submitted"""
|
||||||
domain_request = completed_domain_request(name="cisareps.gov", user=self.user, has_anything_else=False)
|
domain_request = completed_domain_request(
|
||||||
|
name="cisareps.gov", user=self.user, has_anything_else=False, has_cisa_representative=False
|
||||||
|
)
|
||||||
|
|
||||||
# Make sure we have the data we need for the test
|
# Make sure we have the data we need for the test
|
||||||
self.assertEqual(domain_request.anything_else, None)
|
self.assertEqual(domain_request.anything_else, None)
|
||||||
self.assertEqual(domain_request.cisa_representative_email, None)
|
self.assertEqual(domain_request.cisa_representative_first_name, None)
|
||||||
|
|
||||||
# These fields should not be selected at all, since we haven't initialized the form yet
|
# These fields should not be selected at all, since we haven't initialized the form yet
|
||||||
self.assertEqual(domain_request.has_anything_else_text, None)
|
self.assertEqual(domain_request.has_anything_else_text, None)
|
||||||
|
@ -1294,6 +1364,8 @@ class DomainRequestTests(TestWithUser, WebTest):
|
||||||
# Set fields to true, and set data on those fields
|
# Set fields to true, and set data on those fields
|
||||||
additional_details_form["additional_details-has_cisa_representative"] = "True"
|
additional_details_form["additional_details-has_cisa_representative"] = "True"
|
||||||
additional_details_form["additional_details-has_anything_else_text"] = "True"
|
additional_details_form["additional_details-has_anything_else_text"] = "True"
|
||||||
|
additional_details_form["additional_details-cisa_representative_first_name"] = "cisa-firstname"
|
||||||
|
additional_details_form["additional_details-cisa_representative_last_name"] = "cisa-lastname"
|
||||||
additional_details_form["additional_details-cisa_representative_email"] = "test@faketest.gov"
|
additional_details_form["additional_details-cisa_representative_email"] = "test@faketest.gov"
|
||||||
additional_details_form["additional_details-anything_else"] = "redandblue"
|
additional_details_form["additional_details-anything_else"] = "redandblue"
|
||||||
|
|
||||||
|
@ -1302,10 +1374,12 @@ class DomainRequestTests(TestWithUser, WebTest):
|
||||||
|
|
||||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
|
||||||
# Verify that the anything_else and cisa_representative exist in the db
|
# Verify that the anything_else and cisa_representative information exist in the db
|
||||||
domain_request = DomainRequest.objects.get(requested_domain__name="cisareps.gov")
|
domain_request = DomainRequest.objects.get(requested_domain__name="cisareps.gov")
|
||||||
|
|
||||||
self.assertEqual(domain_request.anything_else, "redandblue")
|
self.assertEqual(domain_request.anything_else, "redandblue")
|
||||||
|
self.assertEqual(domain_request.cisa_representative_first_name, "cisa-firstname")
|
||||||
|
self.assertEqual(domain_request.cisa_representative_last_name, "cisa-lastname")
|
||||||
self.assertEqual(domain_request.cisa_representative_email, "test@faketest.gov")
|
self.assertEqual(domain_request.cisa_representative_email, "test@faketest.gov")
|
||||||
|
|
||||||
self.assertEqual(domain_request.has_cisa_representative, True)
|
self.assertEqual(domain_request.has_cisa_representative, True)
|
||||||
|
@ -1313,7 +1387,9 @@ class DomainRequestTests(TestWithUser, WebTest):
|
||||||
|
|
||||||
def test_if_cisa_representative_yes_no_form_is_yes_then_field_is_required(self):
|
def test_if_cisa_representative_yes_no_form_is_yes_then_field_is_required(self):
|
||||||
"""Applicants with a cisa representative must provide a value"""
|
"""Applicants with a cisa representative must provide a value"""
|
||||||
domain_request = completed_domain_request(name="cisareps.gov", user=self.user, has_anything_else=False)
|
domain_request = completed_domain_request(
|
||||||
|
name="cisareps.gov", user=self.user, has_anything_else=False, has_cisa_representative=False
|
||||||
|
)
|
||||||
|
|
||||||
# prime the form by visiting /edit
|
# prime the form by visiting /edit
|
||||||
self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk}))
|
self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk}))
|
||||||
|
@ -1338,7 +1414,8 @@ class DomainRequestTests(TestWithUser, WebTest):
|
||||||
|
|
||||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
|
||||||
self.assertContains(response, "Enter the email address of your CISA regional representative.")
|
self.assertContains(response, "Enter the first name / given name of the CISA regional representative.")
|
||||||
|
self.assertContains(response, "Enter the last name / family name of the CISA regional representative.")
|
||||||
|
|
||||||
def test_if_anything_else_yes_no_form_is_yes_then_field_is_required(self):
|
def test_if_anything_else_yes_no_form_is_yes_then_field_is_required(self):
|
||||||
"""Applicants with a anything else must provide a value"""
|
"""Applicants with a anything else must provide a value"""
|
||||||
|
@ -1373,7 +1450,9 @@ class DomainRequestTests(TestWithUser, WebTest):
|
||||||
def test_additional_details_form_fields_required(self):
|
def test_additional_details_form_fields_required(self):
|
||||||
"""When a user submits the Additional Details form without checking the
|
"""When a user submits the Additional Details form without checking the
|
||||||
has_cisa_representative and has_anything_else_text fields, the form should deny this action"""
|
has_cisa_representative and has_anything_else_text fields, the form should deny this action"""
|
||||||
domain_request = completed_domain_request(name="cisareps.gov", user=self.user, has_anything_else=False)
|
domain_request = completed_domain_request(
|
||||||
|
name="cisareps.gov", user=self.user, has_anything_else=False, has_cisa_representative=False
|
||||||
|
)
|
||||||
|
|
||||||
self.assertEqual(domain_request.has_anything_else_text, None)
|
self.assertEqual(domain_request.has_anything_else_text, None)
|
||||||
self.assertEqual(domain_request.has_cisa_representative, None)
|
self.assertEqual(domain_request.has_cisa_representative, None)
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
from registrar.models import DomainRequest
|
from registrar.models import DomainRequest
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from registrar.models.draft_domain import DraftDomain
|
||||||
from .test_views import TestWithUser
|
from .test_views import TestWithUser
|
||||||
from django_webtest import WebTest # type: ignore
|
from django_webtest import WebTest # type: ignore
|
||||||
from django.utils.dateparse import parse_datetime
|
from django.utils.dateparse import parse_datetime
|
||||||
|
@ -10,32 +12,37 @@ class GetRequestsJsonTest(TestWithUser, WebTest):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.app.set_user(self.user.username)
|
self.app.set_user(self.user.username)
|
||||||
|
|
||||||
|
lamb_chops, _ = DraftDomain.objects.get_or_create(name="lamb-chops.gov")
|
||||||
|
short_ribs, _ = DraftDomain.objects.get_or_create(name="short-ribs.gov")
|
||||||
|
beef_chuck, _ = DraftDomain.objects.get_or_create(name="beef-chuck.gov")
|
||||||
|
stew_beef, _ = DraftDomain.objects.get_or_create(name="stew-beef.gov")
|
||||||
|
|
||||||
# Create domain requests for the user
|
# Create domain requests for the user
|
||||||
self.domain_requests = [
|
self.domain_requests = [
|
||||||
DomainRequest.objects.create(
|
DomainRequest.objects.create(
|
||||||
creator=self.user,
|
creator=self.user,
|
||||||
requested_domain=None,
|
requested_domain=lamb_chops,
|
||||||
submission_date="2024-01-01",
|
submission_date="2024-01-01",
|
||||||
status=DomainRequest.DomainRequestStatus.STARTED,
|
status=DomainRequest.DomainRequestStatus.STARTED,
|
||||||
created_at="2024-01-01",
|
created_at="2024-01-01",
|
||||||
),
|
),
|
||||||
DomainRequest.objects.create(
|
DomainRequest.objects.create(
|
||||||
creator=self.user,
|
creator=self.user,
|
||||||
requested_domain=None,
|
requested_domain=short_ribs,
|
||||||
submission_date="2024-02-01",
|
submission_date="2024-02-01",
|
||||||
status=DomainRequest.DomainRequestStatus.WITHDRAWN,
|
status=DomainRequest.DomainRequestStatus.WITHDRAWN,
|
||||||
created_at="2024-02-01",
|
created_at="2024-02-01",
|
||||||
),
|
),
|
||||||
DomainRequest.objects.create(
|
DomainRequest.objects.create(
|
||||||
creator=self.user,
|
creator=self.user,
|
||||||
requested_domain=None,
|
requested_domain=beef_chuck,
|
||||||
submission_date="2024-03-01",
|
submission_date="2024-03-01",
|
||||||
status=DomainRequest.DomainRequestStatus.REJECTED,
|
status=DomainRequest.DomainRequestStatus.REJECTED,
|
||||||
created_at="2024-03-01",
|
created_at="2024-03-01",
|
||||||
),
|
),
|
||||||
DomainRequest.objects.create(
|
DomainRequest.objects.create(
|
||||||
creator=self.user,
|
creator=self.user,
|
||||||
requested_domain=None,
|
requested_domain=stew_beef,
|
||||||
submission_date="2024-04-01",
|
submission_date="2024-04-01",
|
||||||
status=DomainRequest.DomainRequestStatus.STARTED,
|
status=DomainRequest.DomainRequestStatus.STARTED,
|
||||||
created_at="2024-04-01",
|
created_at="2024-04-01",
|
||||||
|
@ -195,6 +202,61 @@ class GetRequestsJsonTest(TestWithUser, WebTest):
|
||||||
)
|
)
|
||||||
self.assertEqual(svg_icon_expected, svg_icons[i])
|
self.assertEqual(svg_icon_expected, svg_icons[i])
|
||||||
|
|
||||||
|
def test_get_domain_requests_json_search(self):
|
||||||
|
"""Test search."""
|
||||||
|
# Define your URL variables as a dictionary
|
||||||
|
url_vars = {"search_term": "lamb"}
|
||||||
|
|
||||||
|
# Use the params parameter to include URL variables
|
||||||
|
response = self.app.get(reverse("get_domain_requests_json"), params=url_vars)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json
|
||||||
|
|
||||||
|
# Check pagination info
|
||||||
|
self.assertEqual(data["page"], 1)
|
||||||
|
self.assertFalse(data["has_next"])
|
||||||
|
self.assertFalse(data["has_previous"])
|
||||||
|
self.assertEqual(data["num_pages"], 1)
|
||||||
|
self.assertEqual(data["total"], 1)
|
||||||
|
self.assertEqual(data["unfiltered_total"], 12)
|
||||||
|
|
||||||
|
# Check the number of domain requests
|
||||||
|
self.assertEqual(len(data["domain_requests"]), 1)
|
||||||
|
|
||||||
|
# Extract fields from response
|
||||||
|
requested_domains = [request["requested_domain"] for request in data["domain_requests"]]
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
self.domain_requests[0].requested_domain.name,
|
||||||
|
requested_domains[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_domain_requests_json_search_new_domains(self):
|
||||||
|
"""Test search when looking up New domain requests"""
|
||||||
|
# Define your URL variables as a dictionary
|
||||||
|
url_vars = {"search_term": "ew"}
|
||||||
|
|
||||||
|
# Use the params parameter to include URL variables
|
||||||
|
response = self.app.get(reverse("get_domain_requests_json"), params=url_vars)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json
|
||||||
|
|
||||||
|
# Check pagination info
|
||||||
|
pagination_fields = ["page", "has_next", "has_previous", "num_pages", "total", "unfiltered_total"]
|
||||||
|
expected_pagination_values = [1, False, False, 1, 9, 12]
|
||||||
|
for field, expected_value in zip(pagination_fields, expected_pagination_values):
|
||||||
|
self.assertEqual(data[field], expected_value)
|
||||||
|
|
||||||
|
# Check the number of domain requests
|
||||||
|
self.assertEqual(len(data["domain_requests"]), 9)
|
||||||
|
|
||||||
|
# Extract fields from response
|
||||||
|
requested_domains = [request.get("requested_domain") for request in data["domain_requests"]]
|
||||||
|
|
||||||
|
expected_domain_values = ["stew-beef.gov"] + [None] * 8
|
||||||
|
for expected_value, actual_value in zip(expected_domain_values, requested_domains):
|
||||||
|
self.assertEqual(expected_value, actual_value)
|
||||||
|
|
||||||
def test_pagination(self):
|
def test_pagination(self):
|
||||||
"""Test that pagination works properly. There are 11 total non-approved requests and
|
"""Test that pagination works properly. There are 11 total non-approved requests and
|
||||||
a page size of 10"""
|
a page size of 10"""
|
||||||
|
|
12
src/registrar/utility/constants.py
Normal file
12
src/registrar/utility/constants.py
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class BranchChoices(models.TextChoices):
|
||||||
|
EXECUTIVE = "executive", "Executive"
|
||||||
|
JUDICIAL = "judicial", "Judicial"
|
||||||
|
LEGISLATIVE = "legislative", "Legislative"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_branch_label(cls, branch_name: str):
|
||||||
|
"""Returns the associated label for a given org name"""
|
||||||
|
return cls(branch_name).label if branch_name else None
|
|
@ -1,18 +1,25 @@
|
||||||
import csv
|
import csv
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from registrar.models.domain import Domain
|
from registrar.models import (
|
||||||
from registrar.models.domain_invitation import DomainInvitation
|
Domain,
|
||||||
from registrar.models.domain_request import DomainRequest
|
DomainInvitation,
|
||||||
from registrar.models.domain_information import DomainInformation
|
DomainRequest,
|
||||||
|
DomainInformation,
|
||||||
|
PublicContact,
|
||||||
|
UserDomainRole,
|
||||||
|
)
|
||||||
|
from django.db.models import QuerySet, Value, CharField, Count, Q, F
|
||||||
|
from django.db.models import ManyToManyField
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db.models import F, Value, CharField
|
|
||||||
from django.db.models.functions import Concat, Coalesce
|
from django.db.models.functions import Concat, Coalesce
|
||||||
|
from django.contrib.postgres.aggregates import StringAgg
|
||||||
from registrar.models.public_contact import PublicContact
|
from registrar.models.utility.generic_helper import convert_queryset_to_dict
|
||||||
from registrar.models.user_domain_role import UserDomainRole
|
from registrar.templatetags.custom_filters import get_region
|
||||||
from registrar.utility.enums import DefaultEmail
|
from registrar.utility.enums import DefaultEmail
|
||||||
|
from registrar.utility.constants import BranchChoices
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -299,88 +306,11 @@ def write_csv_for_domains(
|
||||||
writer.writerows(total_body_rows)
|
writer.writerows(total_body_rows)
|
||||||
|
|
||||||
|
|
||||||
def get_requests(filter_condition, sort_fields):
|
|
||||||
"""
|
|
||||||
Returns DomainRequest objects filtered and sorted based on the provided conditions.
|
|
||||||
filter_condition -> A dictionary of conditions to filter the objects.
|
|
||||||
sort_fields -> A list of fields to sort the resulting query set.
|
|
||||||
returns: A queryset of DomainRequest objects
|
|
||||||
"""
|
|
||||||
requests = DomainRequest.objects.filter(**filter_condition).order_by(*sort_fields).distinct()
|
|
||||||
return requests
|
|
||||||
|
|
||||||
|
|
||||||
def parse_row_for_requests(columns, request: DomainRequest):
|
|
||||||
"""Given a set of columns, generate a new row from cleaned column data"""
|
|
||||||
|
|
||||||
requested_domain_name = "No requested domain"
|
|
||||||
|
|
||||||
if request.requested_domain is not None:
|
|
||||||
requested_domain_name = request.requested_domain.name
|
|
||||||
|
|
||||||
if request.federal_type:
|
|
||||||
request_type = f"{request.get_organization_type_display()} - {request.get_federal_type_display()}"
|
|
||||||
else:
|
|
||||||
request_type = request.get_organization_type_display()
|
|
||||||
|
|
||||||
# create a dictionary of fields which can be included in output
|
|
||||||
FIELDS = {
|
|
||||||
"Requested domain": requested_domain_name,
|
|
||||||
"Status": request.get_status_display(),
|
|
||||||
"Organization type": request_type,
|
|
||||||
"Agency": request.federal_agency,
|
|
||||||
"Organization name": request.organization_name,
|
|
||||||
"City": request.city,
|
|
||||||
"State": request.state_territory,
|
|
||||||
"AO email": request.authorizing_official.email if request.authorizing_official else " ",
|
|
||||||
"Security contact email": request,
|
|
||||||
"Created at": request.created_at,
|
|
||||||
"Submission date": request.submission_date,
|
|
||||||
}
|
|
||||||
|
|
||||||
row = [FIELDS.get(column, "") for column in columns]
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def write_csv_for_requests(
|
|
||||||
writer,
|
|
||||||
columns,
|
|
||||||
sort_fields,
|
|
||||||
filter_condition,
|
|
||||||
should_write_header=True,
|
|
||||||
):
|
|
||||||
"""Receives params from the parent methods and outputs a CSV with filtered and sorted requests.
|
|
||||||
Works with write_header as long as the same writer object is passed."""
|
|
||||||
|
|
||||||
all_requests = get_requests(filter_condition, sort_fields)
|
|
||||||
|
|
||||||
# Reduce the memory overhead when performing the write operation
|
|
||||||
paginator = Paginator(all_requests, 1000)
|
|
||||||
total_body_rows = []
|
|
||||||
|
|
||||||
for page_num in paginator.page_range:
|
|
||||||
page = paginator.page(page_num)
|
|
||||||
rows = []
|
|
||||||
for request in page.object_list:
|
|
||||||
try:
|
|
||||||
row = parse_row_for_requests(columns, request)
|
|
||||||
rows.append(row)
|
|
||||||
except ValueError:
|
|
||||||
# This should not happen. If it does, just skip this row.
|
|
||||||
# It indicates that DomainInformation.domain is None.
|
|
||||||
logger.error("csv_export -> Error when parsing row, domain was None")
|
|
||||||
continue
|
|
||||||
total_body_rows.extend(rows)
|
|
||||||
|
|
||||||
if should_write_header:
|
|
||||||
write_header(writer, columns)
|
|
||||||
writer.writerows(total_body_rows)
|
|
||||||
|
|
||||||
|
|
||||||
def export_data_type_to_csv(csv_file):
|
def export_data_type_to_csv(csv_file):
|
||||||
"""
|
"""
|
||||||
All domains report with extra columns.
|
All domains report with extra columns.
|
||||||
This maps to the "All domain metadata" button.
|
This maps to the "All domain metadata" button.
|
||||||
|
Exports domains of all statuses.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
writer = csv.writer(csv_file)
|
writer = csv.writer(csv_file)
|
||||||
|
@ -408,15 +338,8 @@ def export_data_type_to_csv(csv_file):
|
||||||
"federal_agency",
|
"federal_agency",
|
||||||
"domain__name",
|
"domain__name",
|
||||||
]
|
]
|
||||||
filter_condition = {
|
|
||||||
"domain__state__in": [
|
|
||||||
Domain.State.READY,
|
|
||||||
Domain.State.DNS_NEEDED,
|
|
||||||
Domain.State.ON_HOLD,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
write_csv_for_domains(
|
write_csv_for_domains(
|
||||||
writer, columns, sort_fields, filter_condition, should_get_domain_managers=True, should_write_header=True
|
writer, columns, sort_fields, filter_condition={}, should_get_domain_managers=True, should_write_header=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -781,30 +704,338 @@ def export_data_unmanaged_domains_to_csv(csv_file, start_date, end_date):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def export_data_requests_growth_to_csv(csv_file, start_date, end_date):
|
class DomainRequestExport:
|
||||||
"""
|
"""
|
||||||
Growth report:
|
A collection of functions which return csv files regarding the DomainRequest model.
|
||||||
Receive start and end dates from the view, parse them.
|
|
||||||
Request from write_requests_body SUBMITTED requests that are created between
|
|
||||||
the start and end dates. Specify sort params.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
start_date_formatted = format_start_date(start_date)
|
# Get all columns on the full metadata report
|
||||||
end_date_formatted = format_end_date(end_date)
|
all_columns = [
|
||||||
writer = csv.writer(csv_file)
|
"Domain request",
|
||||||
# define columns to include in export
|
"Submitted at",
|
||||||
columns = [
|
"Status",
|
||||||
"Requested domain",
|
"Domain type",
|
||||||
"Organization type",
|
"Federal type",
|
||||||
"Submission date",
|
"Federal agency",
|
||||||
|
"Organization name",
|
||||||
|
"Election office",
|
||||||
|
"City",
|
||||||
|
"State/territory",
|
||||||
|
"Region",
|
||||||
|
"Creator first name",
|
||||||
|
"Creator last name",
|
||||||
|
"Creator email",
|
||||||
|
"Creator approved domains count",
|
||||||
|
"Creator active requests count",
|
||||||
|
"Alternative domains",
|
||||||
|
"AO first name",
|
||||||
|
"AO last name",
|
||||||
|
"AO email",
|
||||||
|
"AO title/role",
|
||||||
|
"Request purpose",
|
||||||
|
"Request additional details",
|
||||||
|
"Other contacts",
|
||||||
|
"CISA regional representative",
|
||||||
|
"Current websites",
|
||||||
|
"Investigator",
|
||||||
]
|
]
|
||||||
sort_fields = [
|
|
||||||
"requested_domain__name",
|
|
||||||
]
|
|
||||||
filter_condition = {
|
|
||||||
"status": DomainRequest.DomainRequestStatus.SUBMITTED,
|
|
||||||
"submission_date__lte": end_date_formatted,
|
|
||||||
"submission_date__gte": start_date_formatted,
|
|
||||||
}
|
|
||||||
|
|
||||||
write_csv_for_requests(writer, columns, sort_fields, filter_condition, should_write_header=True)
|
@classmethod
|
||||||
|
def export_data_requests_growth_to_csv(cls, csv_file, start_date, end_date):
|
||||||
|
"""
|
||||||
|
Growth report:
|
||||||
|
Receive start and end dates from the view, parse them.
|
||||||
|
Request from write_requests_body SUBMITTED requests that are created between
|
||||||
|
the start and end dates. Specify sort params.
|
||||||
|
"""
|
||||||
|
|
||||||
|
start_date_formatted = format_start_date(start_date)
|
||||||
|
end_date_formatted = format_end_date(end_date)
|
||||||
|
writer = csv.writer(csv_file)
|
||||||
|
# define columns to include in export
|
||||||
|
columns = [
|
||||||
|
"Domain request",
|
||||||
|
"Domain type",
|
||||||
|
"Federal type",
|
||||||
|
"Submitted at",
|
||||||
|
]
|
||||||
|
|
||||||
|
sort_fields = [
|
||||||
|
"requested_domain__name",
|
||||||
|
]
|
||||||
|
filter_condition = {
|
||||||
|
"status": DomainRequest.DomainRequestStatus.SUBMITTED,
|
||||||
|
"submission_date__lte": end_date_formatted,
|
||||||
|
"submission_date__gte": start_date_formatted,
|
||||||
|
}
|
||||||
|
|
||||||
|
# We don't want to annotate anything, but we do want to access the requested domain name
|
||||||
|
annotations = {}
|
||||||
|
additional_values = ["requested_domain__name"]
|
||||||
|
|
||||||
|
all_requests = DomainRequest.objects.filter(**filter_condition).order_by(*sort_fields).distinct()
|
||||||
|
|
||||||
|
annotated_requests = cls.annotate_and_retrieve_fields(all_requests, annotations, additional_values)
|
||||||
|
requests_dict = convert_queryset_to_dict(annotated_requests, is_model=False)
|
||||||
|
|
||||||
|
cls.write_csv_for_requests(writer, columns, requests_dict)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def export_full_domain_request_report(cls, csv_file):
|
||||||
|
"""
|
||||||
|
Generates a detailed domain request report to a CSV file.
|
||||||
|
|
||||||
|
Retrieves and annotates DomainRequest objects, excluding 'STARTED' status,
|
||||||
|
with related data optimizations via select/prefetch and annotation.
|
||||||
|
|
||||||
|
Annotated with counts and aggregates of related entities.
|
||||||
|
Converts to dict and writes to CSV using predefined columns.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
csv_file (file-like object): Target CSV file.
|
||||||
|
"""
|
||||||
|
writer = csv.writer(csv_file)
|
||||||
|
|
||||||
|
requests = (
|
||||||
|
DomainRequest.objects.select_related(
|
||||||
|
"creator", "authorizing_official", "federal_agency", "investigator", "requested_domain"
|
||||||
|
)
|
||||||
|
.prefetch_related("current_websites", "other_contacts", "alternative_domains")
|
||||||
|
.exclude(status__in=[DomainRequest.DomainRequestStatus.STARTED])
|
||||||
|
.order_by(
|
||||||
|
"status",
|
||||||
|
"requested_domain__name",
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Annotations are custom columns returned to the queryset (AKA: computed in the DB).
|
||||||
|
annotations = cls._full_domain_request_annotations()
|
||||||
|
|
||||||
|
# The .values returned from annotate_and_retrieve_fields can't go two levels deep
|
||||||
|
# (just returns the field id of say, "creator") - so we have to include this.
|
||||||
|
additional_values = [
|
||||||
|
"requested_domain__name",
|
||||||
|
"federal_agency__agency",
|
||||||
|
"authorizing_official__first_name",
|
||||||
|
"authorizing_official__last_name",
|
||||||
|
"authorizing_official__email",
|
||||||
|
"authorizing_official__title",
|
||||||
|
"creator__first_name",
|
||||||
|
"creator__last_name",
|
||||||
|
"creator__email",
|
||||||
|
"investigator__email",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Convert the domain request queryset to a dictionary (including annotated fields)
|
||||||
|
annotated_requests = cls.annotate_and_retrieve_fields(requests, annotations, additional_values)
|
||||||
|
requests_dict = convert_queryset_to_dict(annotated_requests, is_model=False)
|
||||||
|
|
||||||
|
# Write the csv file
|
||||||
|
cls.write_csv_for_requests(writer, cls.all_columns, requests_dict)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _full_domain_request_annotations(cls, delimiter=" | "):
|
||||||
|
"""Returns the annotations for the full domain request report"""
|
||||||
|
return {
|
||||||
|
"creator_approved_domains_count": DomainRequestExport.get_creator_approved_domains_count_query(),
|
||||||
|
"creator_active_requests_count": DomainRequestExport.get_creator_active_requests_count_query(),
|
||||||
|
"all_current_websites": StringAgg("current_websites__website", delimiter=delimiter, distinct=True),
|
||||||
|
"all_alternative_domains": StringAgg("alternative_domains__website", delimiter=delimiter, distinct=True),
|
||||||
|
# Coerce the other contacts object to "{first_name} {last_name} {email}"
|
||||||
|
"all_other_contacts": StringAgg(
|
||||||
|
Concat(
|
||||||
|
"other_contacts__first_name",
|
||||||
|
Value(" "),
|
||||||
|
"other_contacts__last_name",
|
||||||
|
Value(" "),
|
||||||
|
"other_contacts__email",
|
||||||
|
),
|
||||||
|
delimiter=delimiter,
|
||||||
|
distinct=True,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def write_csv_for_requests(
|
||||||
|
writer,
|
||||||
|
columns,
|
||||||
|
requests_dict,
|
||||||
|
should_write_header=True,
|
||||||
|
):
|
||||||
|
"""Receives params from the parent methods and outputs a CSV with filtered and sorted requests.
|
||||||
|
Works with write_header as long as the same writer object is passed."""
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for request in requests_dict.values():
|
||||||
|
try:
|
||||||
|
row = DomainRequestExport.parse_row_for_requests(columns, request)
|
||||||
|
rows.append(row)
|
||||||
|
except ValueError as err:
|
||||||
|
logger.error(f"csv_export -> Error when parsing row: {err}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if should_write_header:
|
||||||
|
write_header(writer, columns)
|
||||||
|
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_row_for_requests(columns, request):
|
||||||
|
"""
|
||||||
|
Given a set of columns and a request dictionary, generate a new row from cleaned column data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Handle the federal_type field. Defaults to the wrong format.
|
||||||
|
federal_type = request.get("federal_type")
|
||||||
|
human_readable_federal_type = BranchChoices.get_branch_label(federal_type) if federal_type else None
|
||||||
|
|
||||||
|
# Handle the org_type field
|
||||||
|
org_type = request.get("generic_org_type") or request.get("organization_type")
|
||||||
|
human_readable_org_type = DomainRequest.OrganizationChoices.get_org_label(org_type) if org_type else None
|
||||||
|
|
||||||
|
# Handle the status field. Defaults to the wrong format.
|
||||||
|
status = request.get("status")
|
||||||
|
status_display = DomainRequest.DomainRequestStatus.get_status_label(status) if status else None
|
||||||
|
|
||||||
|
# Handle the region field.
|
||||||
|
state_territory = request.get("state_territory")
|
||||||
|
region = get_region(state_territory) if state_territory else None
|
||||||
|
|
||||||
|
# Handle the requested_domain field (add a default if None)
|
||||||
|
requested_domain = request.get("requested_domain__name")
|
||||||
|
requested_domain_name = requested_domain if requested_domain else "No requested domain"
|
||||||
|
|
||||||
|
# Handle the election field. N/A if None, "Yes"/"No" if boolean
|
||||||
|
human_readable_election_board = "N/A"
|
||||||
|
is_election_board = request.get("is_election_board")
|
||||||
|
if is_election_board is not None:
|
||||||
|
human_readable_election_board = "Yes" if is_election_board else "No"
|
||||||
|
|
||||||
|
# Handle the additional details field. Pipe seperated.
|
||||||
|
cisa_rep_first = request.get("cisa_representative_first_name")
|
||||||
|
cisa_rep_last = request.get("cisa_representative_last_name")
|
||||||
|
name = [n for n in [cisa_rep_first, cisa_rep_last] if n]
|
||||||
|
|
||||||
|
cisa_rep = " ".join(name) if name else None
|
||||||
|
details = [cisa_rep, request.get("anything_else")]
|
||||||
|
additional_details = " | ".join([field for field in details if field])
|
||||||
|
|
||||||
|
# create a dictionary of fields which can be included in output.
|
||||||
|
# "extra_fields" are precomputed fields (generated in the DB or parsed).
|
||||||
|
FIELDS = {
|
||||||
|
# Parsed fields - defined above.
|
||||||
|
"Domain request": requested_domain_name,
|
||||||
|
"Region": region,
|
||||||
|
"Status": status_display,
|
||||||
|
"Election office": human_readable_election_board,
|
||||||
|
"Federal type": human_readable_federal_type,
|
||||||
|
"Domain type": human_readable_org_type,
|
||||||
|
"Request additional details": additional_details,
|
||||||
|
# Annotated fields - passed into the request dict.
|
||||||
|
"Creator approved domains count": request.get("creator_approved_domains_count", 0),
|
||||||
|
"Creator active requests count": request.get("creator_active_requests_count", 0),
|
||||||
|
"Alternative domains": request.get("all_alternative_domains"),
|
||||||
|
"Other contacts": request.get("all_other_contacts"),
|
||||||
|
"Current websites": request.get("all_current_websites"),
|
||||||
|
# Untouched FK fields - passed into the request dict.
|
||||||
|
"Federal agency": request.get("federal_agency__agency"),
|
||||||
|
"AO first name": request.get("authorizing_official__first_name"),
|
||||||
|
"AO last name": request.get("authorizing_official__last_name"),
|
||||||
|
"AO email": request.get("authorizing_official__email"),
|
||||||
|
"AO title/role": request.get("authorizing_official__title"),
|
||||||
|
"Creator first name": request.get("creator__first_name"),
|
||||||
|
"Creator last name": request.get("creator__last_name"),
|
||||||
|
"Creator email": request.get("creator__email"),
|
||||||
|
"Investigator": request.get("investigator__email"),
|
||||||
|
# Untouched fields
|
||||||
|
"Organization name": request.get("organization_name"),
|
||||||
|
"City": request.get("city"),
|
||||||
|
"State/territory": request.get("state_territory"),
|
||||||
|
"Request purpose": request.get("purpose"),
|
||||||
|
"CISA regional representative": request.get("cisa_representative_email"),
|
||||||
|
"Submitted at": request.get("submission_date"),
|
||||||
|
}
|
||||||
|
|
||||||
|
row = [FIELDS.get(column, "") for column in columns]
|
||||||
|
return row
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def annotate_and_retrieve_fields(
|
||||||
|
cls, requests, annotations, additional_values=None, include_many_to_many=False
|
||||||
|
) -> QuerySet:
|
||||||
|
"""
|
||||||
|
Applies annotations to a queryset and retrieves specified fields,
|
||||||
|
including class-defined and annotation-defined.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
requests (QuerySet): Initial queryset.
|
||||||
|
annotations (dict, optional): Fields to compute {field_name: expression}.
|
||||||
|
additional_values (list, optional): Extra fields to retrieve; defaults to annotation keys if None.
|
||||||
|
include_many_to_many (bool, optional): Determines if we should include many to many fields or not
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet: Contains dictionaries with the specified fields for each record.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if additional_values is None:
|
||||||
|
additional_values = []
|
||||||
|
|
||||||
|
# We can infer that if we're passing in annotations,
|
||||||
|
# we want to grab the result of said annotation.
|
||||||
|
if annotations:
|
||||||
|
additional_values.extend(annotations.keys())
|
||||||
|
|
||||||
|
# Get prexisting fields on DomainRequest
|
||||||
|
domain_request_fields = set()
|
||||||
|
for field in DomainRequest._meta.get_fields():
|
||||||
|
# Exclude many to many fields unless we specify
|
||||||
|
many_to_many = isinstance(field, ManyToManyField) and include_many_to_many
|
||||||
|
if many_to_many or not isinstance(field, ManyToManyField):
|
||||||
|
domain_request_fields.add(field.name)
|
||||||
|
|
||||||
|
queryset = requests.annotate(**annotations).values(*domain_request_fields, *additional_values)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
# ============================================================= #
|
||||||
|
# Helper functions for django ORM queries. #
|
||||||
|
# We are using these rather than pure python for speed reasons. #
|
||||||
|
# ============================================================= #
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_creator_approved_domains_count_query():
|
||||||
|
"""
|
||||||
|
Generates a Count query for distinct approved domain requests per creator.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Count: Aggregates distinct 'APPROVED' domain requests by creator.
|
||||||
|
"""
|
||||||
|
|
||||||
|
query = Count(
|
||||||
|
"creator__domain_requests_created__id",
|
||||||
|
filter=Q(creator__domain_requests_created__status=DomainRequest.DomainRequestStatus.APPROVED),
|
||||||
|
distinct=True,
|
||||||
|
)
|
||||||
|
return query
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_creator_active_requests_count_query():
|
||||||
|
"""
|
||||||
|
Generates a Count query for distinct approved domain requests per creator.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Count: Aggregates distinct 'SUBMITTED', 'IN_REVIEW', and 'ACTION_NEEDED' domain requests by creator.
|
||||||
|
"""
|
||||||
|
|
||||||
|
query = Count(
|
||||||
|
"creator__domain_requests_created__id",
|
||||||
|
filter=Q(
|
||||||
|
creator__domain_requests_created__status__in=[
|
||||||
|
DomainRequest.DomainRequestStatus.SUBMITTED,
|
||||||
|
DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||||
|
DomainRequest.DomainRequestStatus.ACTION_NEEDED,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
distinct=True,
|
||||||
|
)
|
||||||
|
return query
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
import logging
|
import logging
|
||||||
|
import textwrap
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.template.loader import get_template
|
from django.template.loader import get_template
|
||||||
|
@ -27,6 +28,7 @@ def send_templated_email(
|
||||||
bcc_address="",
|
bcc_address="",
|
||||||
context={},
|
context={},
|
||||||
attachment_file: str = None,
|
attachment_file: str = None,
|
||||||
|
wrap_email=False,
|
||||||
):
|
):
|
||||||
"""Send an email built from a template to one email address.
|
"""Send an email built from a template to one email address.
|
||||||
|
|
||||||
|
@ -66,6 +68,11 @@ def send_templated_email(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if attachment_file is None:
|
if attachment_file is None:
|
||||||
|
# Wrap the email body to a maximum width of 80 characters per line.
|
||||||
|
# Not all email clients support CSS to do this, and our .txt files require parsing.
|
||||||
|
if wrap_email:
|
||||||
|
email_body = wrap_text_and_preserve_paragraphs(email_body, width=80)
|
||||||
|
|
||||||
ses_client.send_email(
|
ses_client.send_email(
|
||||||
FromEmailAddress=settings.DEFAULT_FROM_EMAIL,
|
FromEmailAddress=settings.DEFAULT_FROM_EMAIL,
|
||||||
Destination=destination,
|
Destination=destination,
|
||||||
|
@ -91,6 +98,26 @@ def send_templated_email(
|
||||||
raise EmailSendingError("Could not send SES email.") from exc
|
raise EmailSendingError("Could not send SES email.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_text_and_preserve_paragraphs(text, width):
|
||||||
|
"""
|
||||||
|
Wraps text to `width` preserving newlines; splits on '\n', wraps segments, rejoins with '\n'.
|
||||||
|
Args:
|
||||||
|
text (str): Text to wrap.
|
||||||
|
width (int): Max width per line, default 80.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Wrapped text with preserved paragraph structure.
|
||||||
|
"""
|
||||||
|
# Split text into paragraphs by newlines
|
||||||
|
paragraphs = text.split("\n")
|
||||||
|
|
||||||
|
# Add \n to any line that exceeds our max length
|
||||||
|
wrapped_paragraphs = [textwrap.fill(paragraph, width=width) for paragraph in paragraphs]
|
||||||
|
|
||||||
|
# Join paragraphs with double newlines
|
||||||
|
return "\n".join(wrapped_paragraphs)
|
||||||
|
|
||||||
|
|
||||||
def send_email_with_attachment(sender, recipient, subject, body, attachment_file, ses_client):
|
def send_email_with_attachment(sender, recipient, subject, body, attachment_file, ses_client):
|
||||||
# Create a multipart/mixed parent container
|
# Create a multipart/mixed parent container
|
||||||
msg = MIMEMultipart("mixed")
|
msg = MIMEMultipart("mixed")
|
||||||
|
|
|
@ -79,6 +79,7 @@ class FSMErrorCodes(IntEnum):
|
||||||
- 3 INVESTIGATOR_NOT_STAFF Investigator is a non-staff user
|
- 3 INVESTIGATOR_NOT_STAFF Investigator is a non-staff user
|
||||||
- 4 INVESTIGATOR_NOT_SUBMITTER The form submitter is not the investigator
|
- 4 INVESTIGATOR_NOT_SUBMITTER The form submitter is not the investigator
|
||||||
- 5 NO_REJECTION_REASON No rejection reason is specified
|
- 5 NO_REJECTION_REASON No rejection reason is specified
|
||||||
|
- 6 NO_ACTION_NEEDED_REASON No action needed reason is specified
|
||||||
"""
|
"""
|
||||||
|
|
||||||
APPROVE_DOMAIN_IN_USE = 1
|
APPROVE_DOMAIN_IN_USE = 1
|
||||||
|
@ -86,6 +87,7 @@ class FSMErrorCodes(IntEnum):
|
||||||
INVESTIGATOR_NOT_STAFF = 3
|
INVESTIGATOR_NOT_STAFF = 3
|
||||||
INVESTIGATOR_NOT_SUBMITTER = 4
|
INVESTIGATOR_NOT_SUBMITTER = 4
|
||||||
NO_REJECTION_REASON = 5
|
NO_REJECTION_REASON = 5
|
||||||
|
NO_ACTION_NEEDED_REASON = 6
|
||||||
|
|
||||||
|
|
||||||
class FSMDomainRequestError(Exception):
|
class FSMDomainRequestError(Exception):
|
||||||
|
@ -100,6 +102,7 @@ class FSMDomainRequestError(Exception):
|
||||||
FSMErrorCodes.INVESTIGATOR_NOT_STAFF: ("Investigator is not a staff user."),
|
FSMErrorCodes.INVESTIGATOR_NOT_STAFF: ("Investigator is not a staff user."),
|
||||||
FSMErrorCodes.INVESTIGATOR_NOT_SUBMITTER: ("Only the assigned investigator can make this change."),
|
FSMErrorCodes.INVESTIGATOR_NOT_SUBMITTER: ("Only the assigned investigator can make this change."),
|
||||||
FSMErrorCodes.NO_REJECTION_REASON: ("A rejection reason is required."),
|
FSMErrorCodes.NO_REJECTION_REASON: ("A rejection reason is required."),
|
||||||
|
FSMErrorCodes.NO_ACTION_NEEDED_REASON: ("A reason is required for this status."),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, code=None, **kwargs):
|
def __init__(self, *args, code=None, **kwargs):
|
||||||
|
|
|
@ -164,6 +164,17 @@ class ExportDataFederal(View):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class ExportDomainRequestDataFull(View):
|
||||||
|
"""Generates a downloaded report containing all Domain Requests (except started)"""
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
"""Returns a content disposition response for current-full-domain-request.csv"""
|
||||||
|
response = HttpResponse(content_type="text/csv")
|
||||||
|
response["Content-Disposition"] = 'attachment; filename="current-full-domain-request.csv"'
|
||||||
|
csv_export.DomainRequestExport.export_full_domain_request_report(response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
class ExportDataDomainsGrowth(View):
|
class ExportDataDomainsGrowth(View):
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
# Get start_date and end_date from the request's GET parameters
|
# Get start_date and end_date from the request's GET parameters
|
||||||
|
@ -191,7 +202,7 @@ class ExportDataRequestsGrowth(View):
|
||||||
response["Content-Disposition"] = f'attachment; filename="requests-{start_date}-to-{end_date}.csv"'
|
response["Content-Disposition"] = f'attachment; filename="requests-{start_date}-to-{end_date}.csv"'
|
||||||
# For #999: set export_data_domain_growth_to_csv to return the resulting queryset, which we can then use
|
# For #999: set export_data_domain_growth_to_csv to return the resulting queryset, which we can then use
|
||||||
# in context to display this data in the template.
|
# in context to display this data in the template.
|
||||||
csv_export.export_data_requests_growth_to_csv(response, start_date, end_date)
|
csv_export.DomainRequestExport.export_data_requests_growth_to_csv(response, start_date, end_date)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
|
@ -219,22 +219,23 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
||||||
self.storage["domain_request_id"] = kwargs["id"]
|
self.storage["domain_request_id"] = kwargs["id"]
|
||||||
self.storage["step_history"] = self.db_check_for_unlocking_steps()
|
self.storage["step_history"] = self.db_check_for_unlocking_steps()
|
||||||
|
|
||||||
# if accessing this class directly, redirect to the first step
|
# if accessing this class directly, redirect to either to an acknowledgement
|
||||||
# in other words, if `DomainRequestWizard` is called as view
|
# page or to the first step in the processes (if an edit rather than a new request);
|
||||||
# directly by some redirect or url handler, we'll send users
|
# subclasseswill NOT be redirected. The purpose of this is to allow code to
|
||||||
# either to an acknowledgement page or to the first step in
|
# send users "to the domain request wizard" without needing to know which view
|
||||||
# the processes (if an edit rather than a new request); subclasses
|
# is first in the list of steps.
|
||||||
# will NOT be redirected. The purpose of this is to allow code to
|
|
||||||
# send users "to the domain request wizard" without needing to
|
|
||||||
# know which view is first in the list of steps.
|
|
||||||
context = self.get_context_data()
|
|
||||||
if self.__class__ == DomainRequestWizard:
|
if self.__class__ == DomainRequestWizard:
|
||||||
if request.path_info == self.NEW_URL_NAME:
|
if request.path_info == self.NEW_URL_NAME:
|
||||||
context = self.get_context_data()
|
# Clear context so the prop getter won't create a request here.
|
||||||
return render(request, "domain_request_intro.html", context=context)
|
# Creating a request will be handled in the post method for the
|
||||||
|
# intro page. Only TEMPORARY context needed is has_profile_flag
|
||||||
|
has_profile_flag = flag_is_active(self.request, "profile_feature")
|
||||||
|
context_stuff = {"has_profile_feature_flag": has_profile_flag}
|
||||||
|
return render(request, "domain_request_intro.html", context=context_stuff)
|
||||||
else:
|
else:
|
||||||
return self.goto(self.steps.first)
|
return self.goto(self.steps.first)
|
||||||
|
|
||||||
|
context = self.get_context_data()
|
||||||
self.steps.current = current_url
|
self.steps.current = current_url
|
||||||
context["forms"] = self.get_forms()
|
context["forms"] = self.get_forms()
|
||||||
|
|
||||||
|
@ -369,7 +370,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
||||||
or self.domain_request.no_other_contacts_rationale is not None
|
or self.domain_request.no_other_contacts_rationale is not None
|
||||||
),
|
),
|
||||||
"additional_details": (
|
"additional_details": (
|
||||||
(self.domain_request.anything_else is not None and self.domain_request.cisa_representative_email)
|
(self.domain_request.anything_else is not None and self.domain_request.has_cisa_representative)
|
||||||
or self.domain_request.is_policy_acknowledged is not None
|
or self.domain_request.is_policy_acknowledged is not None
|
||||||
),
|
),
|
||||||
"requirements": self.domain_request.is_policy_acknowledged is not None,
|
"requirements": self.domain_request.is_policy_acknowledged is not None,
|
||||||
|
@ -380,7 +381,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
||||||
def get_context_data(self):
|
def get_context_data(self):
|
||||||
"""Define context for access on all wizard pages."""
|
"""Define context for access on all wizard pages."""
|
||||||
has_profile_flag = flag_is_active(self.request, "profile_feature")
|
has_profile_flag = flag_is_active(self.request, "profile_feature")
|
||||||
logger.debug("PROFILE FLAG is %s" % has_profile_flag)
|
|
||||||
|
|
||||||
context_stuff = {}
|
context_stuff = {}
|
||||||
if DomainRequest._form_complete(self.domain_request):
|
if DomainRequest._form_complete(self.domain_request):
|
||||||
|
@ -435,6 +435,10 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
||||||
return step_list
|
return step_list
|
||||||
|
|
||||||
def goto(self, step):
|
def goto(self, step):
|
||||||
|
if step == "generic_org_type":
|
||||||
|
# We need to avoid creating a new domain request if the user
|
||||||
|
# clicks the back button
|
||||||
|
self.request.session["new_request"] = False
|
||||||
self.steps.current = step
|
self.steps.current = step
|
||||||
return redirect(reverse(f"{self.URL_NAMESPACE}:{step}"))
|
return redirect(reverse(f"{self.URL_NAMESPACE}:{step}"))
|
||||||
|
|
||||||
|
@ -457,11 +461,17 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
||||||
|
|
||||||
# which button did the user press?
|
# which button did the user press?
|
||||||
button: str = request.POST.get("submit_button", "")
|
button: str = request.POST.get("submit_button", "")
|
||||||
|
# If a user hits the new request url directly
|
||||||
|
if "new_request" not in request.session:
|
||||||
|
request.session["new_request"] = True
|
||||||
# if user has acknowledged the intro message
|
# if user has acknowledged the intro message
|
||||||
if button == "intro_acknowledge":
|
if button == "intro_acknowledge":
|
||||||
if request.path_info == self.NEW_URL_NAME:
|
if request.path_info == self.NEW_URL_NAME:
|
||||||
del self.storage
|
|
||||||
|
if self.request.session["new_request"] is True:
|
||||||
|
# This will trigger the domain_request getter into creating a new DomainRequest
|
||||||
|
del self.storage
|
||||||
|
|
||||||
return self.goto(self.steps.first)
|
return self.goto(self.steps.first)
|
||||||
|
|
||||||
# if accessing this class directly, redirect to the first step
|
# if accessing this class directly, redirect to the first step
|
||||||
|
@ -799,7 +809,8 @@ class DomainRequestDeleteView(DomainRequestPermissionDeleteView):
|
||||||
contacts_to_delete, duplicates = self._get_orphaned_contacts(domain_request)
|
contacts_to_delete, duplicates = self._get_orphaned_contacts(domain_request)
|
||||||
|
|
||||||
# Delete the DomainRequest
|
# Delete the DomainRequest
|
||||||
response = super().post(request, *args, **kwargs)
|
self.object = self.get_object()
|
||||||
|
self.object.delete()
|
||||||
|
|
||||||
# Delete orphaned contacts - but only for if they are not associated with a user
|
# Delete orphaned contacts - but only for if they are not associated with a user
|
||||||
Contact.objects.filter(id__in=contacts_to_delete, user=None).delete()
|
Contact.objects.filter(id__in=contacts_to_delete, user=None).delete()
|
||||||
|
@ -811,7 +822,8 @@ class DomainRequestDeleteView(DomainRequestPermissionDeleteView):
|
||||||
duplicates_to_delete, _ = self._get_orphaned_contacts(domain_request, check_db=True)
|
duplicates_to_delete, _ = self._get_orphaned_contacts(domain_request, check_db=True)
|
||||||
Contact.objects.filter(id__in=duplicates_to_delete, user=None).delete()
|
Contact.objects.filter(id__in=duplicates_to_delete, user=None).delete()
|
||||||
|
|
||||||
return response
|
# Return a 200 response with an empty body
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
def _get_orphaned_contacts(self, domain_request: DomainRequest, check_db=False):
|
def _get_orphaned_contacts(self, domain_request: DomainRequest, check_db=False):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -4,6 +4,7 @@ from registrar.models import DomainRequest
|
||||||
from django.utils.dateformat import format
|
from django.utils.dateformat import format
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@ -14,9 +15,27 @@ def get_domain_requests_json(request):
|
||||||
domain_requests = DomainRequest.objects.filter(creator=request.user).exclude(
|
domain_requests = DomainRequest.objects.filter(creator=request.user).exclude(
|
||||||
status=DomainRequest.DomainRequestStatus.APPROVED
|
status=DomainRequest.DomainRequestStatus.APPROVED
|
||||||
)
|
)
|
||||||
|
unfiltered_total = domain_requests.count()
|
||||||
|
|
||||||
# Handle sorting
|
# Handle sorting
|
||||||
sort_by = request.GET.get("sort_by", "id") # Default to 'id'
|
sort_by = request.GET.get("sort_by", "id") # Default to 'id'
|
||||||
order = request.GET.get("order", "asc") # Default to 'asc'
|
order = request.GET.get("order", "asc") # Default to 'asc'
|
||||||
|
search_term = request.GET.get("search_term")
|
||||||
|
|
||||||
|
if search_term:
|
||||||
|
search_term_lower = search_term.lower()
|
||||||
|
new_domain_request_text = "new domain request"
|
||||||
|
|
||||||
|
# Check if the search term is a substring of 'New domain request'
|
||||||
|
# If yes, we should return domain requests that do not have a
|
||||||
|
# requested_domain (those display as New domain request in the UI)
|
||||||
|
if search_term_lower in new_domain_request_text:
|
||||||
|
domain_requests = domain_requests.filter(
|
||||||
|
Q(requested_domain__name__icontains=search_term) | Q(requested_domain__isnull=True)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
domain_requests = domain_requests.filter(Q(requested_domain__name__icontains=search_term))
|
||||||
|
|
||||||
if order == "desc":
|
if order == "desc":
|
||||||
sort_by = f"-{sort_by}"
|
sort_by = f"-{sort_by}"
|
||||||
domain_requests = domain_requests.order_by(sort_by)
|
domain_requests = domain_requests.order_by(sort_by)
|
||||||
|
@ -75,5 +94,6 @@ def get_domain_requests_json(request):
|
||||||
"page": page_obj.number,
|
"page": page_obj.number,
|
||||||
"num_pages": paginator.num_pages,
|
"num_pages": paginator.num_pages,
|
||||||
"total": paginator.count,
|
"total": paginator.count,
|
||||||
|
"unfiltered_total": unfiltered_total,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
@ -3,6 +3,7 @@ from django.core.paginator import Paginator
|
||||||
from registrar.models import UserDomainRole, Domain
|
from registrar.models import UserDomainRole, Domain
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@ -14,10 +15,15 @@ def get_domains_json(request):
|
||||||
domain_ids = user_domain_roles.values_list("domain_id", flat=True)
|
domain_ids = user_domain_roles.values_list("domain_id", flat=True)
|
||||||
|
|
||||||
objects = Domain.objects.filter(id__in=domain_ids)
|
objects = Domain.objects.filter(id__in=domain_ids)
|
||||||
|
unfiltered_total = objects.count()
|
||||||
|
|
||||||
# Handle sorting
|
# Handle sorting
|
||||||
sort_by = request.GET.get("sort_by", "id") # Default to 'id'
|
sort_by = request.GET.get("sort_by", "id") # Default to 'id'
|
||||||
order = request.GET.get("order", "asc") # Default to 'asc'
|
order = request.GET.get("order", "asc") # Default to 'asc'
|
||||||
|
search_term = request.GET.get("search_term")
|
||||||
|
|
||||||
|
if search_term:
|
||||||
|
objects = objects.filter(Q(name__icontains=search_term))
|
||||||
|
|
||||||
if sort_by == "state_display":
|
if sort_by == "state_display":
|
||||||
# Fetch the objects and sort them in Python
|
# Fetch the objects and sort them in Python
|
||||||
|
@ -56,5 +62,6 @@ def get_domains_json(request):
|
||||||
"has_previous": page_obj.has_previous(),
|
"has_previous": page_obj.has_previous(),
|
||||||
"has_next": page_obj.has_next(),
|
"has_next": page_obj.has_next(),
|
||||||
"total": paginator.count,
|
"total": paginator.count,
|
||||||
|
"unfiltered_total": unfiltered_total,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from registrar.models import DomainRequest
|
|
||||||
from waffle.decorators import flag_is_active
|
from waffle.decorators import flag_is_active
|
||||||
|
|
||||||
|
|
||||||
|
@ -8,46 +7,10 @@ def index(request):
|
||||||
context = {}
|
context = {}
|
||||||
|
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
# Get all domain requests the user has access to
|
|
||||||
domain_requests, deletable_domain_requests = _get_domain_requests(request)
|
|
||||||
|
|
||||||
context["domain_requests"] = domain_requests
|
|
||||||
|
|
||||||
# Determine if the user will see domain requests that they can delete
|
|
||||||
has_deletable_domain_requests = deletable_domain_requests.exists()
|
|
||||||
context["has_deletable_domain_requests"] = has_deletable_domain_requests
|
|
||||||
|
|
||||||
# This is a django waffle flag which toggles features based off of the "flag" table
|
# This is a django waffle flag which toggles features based off of the "flag" table
|
||||||
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
|
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
|
||||||
|
|
||||||
# If they can delete domain requests, add the delete button to the context
|
# This controls the creation of a new domain request in the wizard
|
||||||
if has_deletable_domain_requests:
|
request.session["new_request"] = True
|
||||||
# Add the delete modal button to the context
|
|
||||||
modal_button = (
|
|
||||||
'<button type="submit" '
|
|
||||||
'class="usa-button usa-button--secondary" '
|
|
||||||
'name="delete-domain-request">Yes, delete request</button>'
|
|
||||||
)
|
|
||||||
context["modal_button"] = modal_button
|
|
||||||
|
|
||||||
return render(request, "home.html", context)
|
return render(request, "home.html", context)
|
||||||
|
|
||||||
|
|
||||||
def _get_domain_requests(request):
|
|
||||||
"""Given the current request,
|
|
||||||
get all DomainRequests that are associated with the UserDomainRole object.
|
|
||||||
|
|
||||||
Returns a tuple of all domain requests, and those that are deletable by the user.
|
|
||||||
"""
|
|
||||||
# Let's exclude the approved domain requests since our
|
|
||||||
# domain_requests context will be used to populate
|
|
||||||
# the active domain requests table
|
|
||||||
domain_requests = DomainRequest.objects.filter(creator=request.user).exclude(
|
|
||||||
status=DomainRequest.DomainRequestStatus.APPROVED
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create a placeholder DraftDomain for each incomplete draft
|
|
||||||
valid_statuses = [DomainRequest.DomainRequestStatus.STARTED, DomainRequest.DomainRequestStatus.WITHDRAWN]
|
|
||||||
deletable_domain_requests = domain_requests.filter(status__in=valid_statuses)
|
|
||||||
|
|
||||||
return (domain_requests, deletable_domain_requests)
|
|
||||||
|
|
|
@ -15,6 +15,7 @@ from django.urls import NoReverseMatch, reverse
|
||||||
from registrar.models import (
|
from registrar.models import (
|
||||||
Contact,
|
Contact,
|
||||||
)
|
)
|
||||||
|
from registrar.models.user import User
|
||||||
from registrar.models.utility.generic_helper import replace_url_queryparams
|
from registrar.models.utility.generic_helper import replace_url_queryparams
|
||||||
from registrar.views.utility.permission_views import UserProfilePermissionView
|
from registrar.views.utility.permission_views import UserProfilePermissionView
|
||||||
from waffle.decorators import flag_is_active, waffle_flag
|
from waffle.decorators import flag_is_active, waffle_flag
|
||||||
|
@ -41,6 +42,13 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
|
||||||
form = self.form_class(instance=self.object)
|
form = self.form_class(instance=self.object)
|
||||||
context = self.get_context_data(object=self.object, form=form)
|
context = self.get_context_data(object=self.object, form=form)
|
||||||
|
|
||||||
|
if (
|
||||||
|
hasattr(self.user, "finished_setup")
|
||||||
|
and not self.user.finished_setup
|
||||||
|
and self.user.verification_type != User.VerificationTypeChoices.REGULAR
|
||||||
|
):
|
||||||
|
context["show_confirmation_modal"] = True
|
||||||
|
|
||||||
return_to_request = request.GET.get("return_to_request")
|
return_to_request = request.GET.get("return_to_request")
|
||||||
if return_to_request:
|
if return_to_request:
|
||||||
context["return_to_request"] = True
|
context["return_to_request"] = True
|
||||||
|
@ -67,7 +75,11 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
|
||||||
|
|
||||||
# The text for the back button on this page
|
# The text for the back button on this page
|
||||||
context["profile_back_button_text"] = "Go to manage your domains"
|
context["profile_back_button_text"] = "Go to manage your domains"
|
||||||
context["show_back_button"] = True
|
context["show_back_button"] = False
|
||||||
|
|
||||||
|
if hasattr(self.user, "finished_setup") and self.user.finished_setup:
|
||||||
|
context["user_finished_setup"] = True
|
||||||
|
context["show_back_button"] = True
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
@ -94,6 +106,12 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
|
||||||
else:
|
else:
|
||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
def form_invalid(self, form):
|
||||||
|
"""If the form is invalid, conditionally display an additional error."""
|
||||||
|
if hasattr(self.user, "finished_setup") and not self.user.finished_setup:
|
||||||
|
messages.error(self.request, "Before you can manage your domain, we need you to add contact information.")
|
||||||
|
return super().form_invalid(form)
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
"""Handle successful and valid form submissions."""
|
"""Handle successful and valid form submissions."""
|
||||||
form.save()
|
form.save()
|
||||||
|
@ -105,9 +123,9 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
|
||||||
|
|
||||||
def get_object(self, queryset=None):
|
def get_object(self, queryset=None):
|
||||||
"""Override get_object to return the logged-in user's contact"""
|
"""Override get_object to return the logged-in user's contact"""
|
||||||
user = self.request.user # get the logged in user
|
self.user = self.request.user # get the logged in user
|
||||||
if hasattr(user, "contact"): # Check if the user has a contact instance
|
if hasattr(self.user, "contact"): # Check if the user has a contact instance
|
||||||
return user.contact
|
return self.user.contact
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue