mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-08-04 08:52:16 +02:00
Merge branch 'main' into za/2301-fill-federal-agency-type
This commit is contained in:
commit
a3d9e208bb
63 changed files with 2675 additions and 775 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, 'hotgov/')
|
||||
|| startsWith(github.head_ref, 'litterbox/')
|
||||
|| startsWith(github.head_ref, 'ag/')
|
||||
outputs:
|
||||
environment: ${{ steps.var.outputs.environment}}
|
||||
runs-on: "ubuntu-latest"
|
||||
|
|
1
.github/workflows/migrate.yaml
vendored
1
.github/workflows/migrate.yaml
vendored
|
@ -16,6 +16,7 @@ on:
|
|||
- stable
|
||||
- staging
|
||||
- development
|
||||
- ag
|
||||
- litterbox
|
||||
- hotgov
|
||||
- cb
|
||||
|
|
1
.github/workflows/reset-db.yaml
vendored
1
.github/workflows/reset-db.yaml
vendored
|
@ -16,6 +16,7 @@ on:
|
|||
options:
|
||||
- staging
|
||||
- development
|
||||
- ag
|
||||
- litterbox
|
||||
- hotgov
|
||||
- cb
|
||||
|
|
2
.github/workflows/test.yaml
vendored
2
.github/workflows/test.yaml
vendored
|
@ -70,6 +70,6 @@ jobs:
|
|||
- name: run pa11y
|
||||
working-directory: ./src
|
||||
run: |
|
||||
sleep 10;
|
||||
sleep 20;
|
||||
npm i -g pa11y-ci
|
||||
pa11y-ci
|
||||
|
|
|
@ -724,3 +724,31 @@ Example: `cf ssh getgov-za`
|
|||
|
||||
#### Step 1: Running the script
|
||||
```docker-compose exec app ./manage.py transfer_federal_agency_type```
|
||||
|
||||
## Email current metadata report
|
||||
|
||||
### Running on sandboxes
|
||||
|
||||
#### Step 1: Login to CloudFoundry
|
||||
```cf login -a api.fr.cloud.gov --sso```
|
||||
|
||||
#### Step 2: SSH into your environment
|
||||
```cf ssh getgov-{space}```
|
||||
|
||||
Example: `cf ssh getgov-za`
|
||||
|
||||
#### Step 3: Create a shell instance
|
||||
```/tmp/lifecycle/shell```
|
||||
|
||||
#### Step 4: Running the script
|
||||
```./manage.py email_current_metadata_report --emailTo {desired email address}```
|
||||
|
||||
### Running locally
|
||||
|
||||
#### Step 1: Running the script
|
||||
```docker-compose exec app ./manage.py email_current_metadata_report --emailTo {desired email address}```
|
||||
|
||||
##### Parameters
|
||||
| | Parameter | Description |
|
||||
|:-:|:-------------------------- |:-----------------------------------------------------------------------------------|
|
||||
| 1 | **emailTo** | Specifies where the email will be emailed. Defaults to help@get.gov on production. |
|
|
@ -32,9 +32,11 @@ For reference, the zip file will contain the following tables in csv form:
|
|||
* DomainInformation
|
||||
* DomainUserRole
|
||||
* DraftDomain
|
||||
* FederalAgency
|
||||
* Websites
|
||||
* Host
|
||||
* HostIP
|
||||
* PublicContact
|
||||
|
||||
After exporting the file from the target environment, scp the exported_tables.zip
|
||||
file from the target environment to local. Run the below commands from local.
|
||||
|
@ -75,17 +77,25 @@ For reference, this deletes all rows from the following tables:
|
|||
* DomainInformation
|
||||
* DomainRequest
|
||||
* Domain
|
||||
* User (all but the current user)
|
||||
* User
|
||||
* Contact
|
||||
* Websites
|
||||
* DraftDomain
|
||||
* HostIP
|
||||
* Host
|
||||
* PublicContact
|
||||
* FederalAgency
|
||||
|
||||
#### Importing into Target Environment
|
||||
|
||||
Once target environment is prepared, files can be imported.
|
||||
|
||||
If importing tables from stable environment into an OT&E sandbox, there will be a difference
|
||||
between the stable's registry and the sandbox's registry. Therefore, you need to run import_tables
|
||||
with --skipEppSave option set to False. If you set to False, it will attempt to save PublicContact
|
||||
records to the registry on load. If this is unset, or set to True, it will load the database and not
|
||||
attempt to update the registry on load.
|
||||
|
||||
To scp the exported_tables.zip file from local to the sandbox, run the following:
|
||||
|
||||
Get passcode by running:
|
||||
|
@ -107,7 +117,7 @@ cf ssh {target-app}
|
|||
example cleaning getgov-backup:
|
||||
cf ssh getgov-backup
|
||||
/tmp/lifecycle/backup
|
||||
./manage.py import_tables
|
||||
./manage.py import_tables --no-skipEppSave
|
||||
|
||||
For reference, this imports tables in the following order:
|
||||
|
||||
|
@ -118,9 +128,11 @@ For reference, this imports tables in the following order:
|
|||
* HostIP
|
||||
* DraftDomain
|
||||
* Websites
|
||||
* FederalAgency
|
||||
* DomainRequest
|
||||
* DomainInformation
|
||||
* UserDomainRole
|
||||
* PublicContact
|
||||
|
||||
Optional step:
|
||||
* Run fixtures to load fixture users back in
|
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 import_export import resources
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
@ -1232,7 +1233,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
search_help_text = "Search by domain."
|
||||
|
||||
fieldsets = [
|
||||
(None, {"fields": ["creator", "submitter", "domain_request", "notes"]}),
|
||||
(None, {"fields": ["portfolio", "creator", "submitter", "domain_request", "notes"]}),
|
||||
(".gov domain", {"fields": ["domain"]}),
|
||||
("Contacts", {"fields": ["authorizing_official", "other_contacts", "no_other_contacts_rationale"]}),
|
||||
("Background info", {"fields": ["anything_else"]}),
|
||||
|
@ -1318,6 +1319,32 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
|
||||
change_form_template = "django/admin/domain_information_change_form.html"
|
||||
|
||||
superuser_only_fields = [
|
||||
"portfolio",
|
||||
]
|
||||
|
||||
# DEVELOPER's NOTE:
|
||||
# Normally, to exclude a field from an Admin form, we could simply utilize
|
||||
# Django's "exclude" feature. However, it causes a "missing key" error if we
|
||||
# go that route for this particular form. The error gets thrown by our
|
||||
# custom fieldset.html code and is due to the fact that "exclude" removes
|
||||
# fields from base_fields but not fieldsets. Rather than reworking our
|
||||
# custom frontend, it seems more straightforward (and easier to read) to simply
|
||||
# modify the fieldsets list so that it excludes any fields we want to remove
|
||||
# based on permissions (eg. superuser_only_fields) or other conditions.
|
||||
def get_fieldsets(self, request, obj=None):
|
||||
fieldsets = self.fieldsets
|
||||
|
||||
# Create a modified version of fieldsets to exclude certain fields
|
||||
if not request.user.has_perm("registrar.full_access_permission"):
|
||||
modified_fieldsets = []
|
||||
for name, data in fieldsets:
|
||||
fields = data.get("fields", [])
|
||||
fields = tuple(field for field in fields if field not in DomainInformationAdmin.superuser_only_fields)
|
||||
modified_fieldsets.append((name, {"fields": fields}))
|
||||
return modified_fieldsets
|
||||
return fieldsets
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""Set the read-only state on form elements.
|
||||
We have 1 conditions that determine which fields are read-only:
|
||||
|
@ -1481,6 +1508,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
None,
|
||||
{
|
||||
"fields": [
|
||||
"portfolio",
|
||||
"status",
|
||||
"rejection_reason",
|
||||
"action_needed_reason",
|
||||
|
@ -1591,6 +1619,32 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
]
|
||||
filter_horizontal = ("current_websites", "alternative_domains", "other_contacts")
|
||||
|
||||
superuser_only_fields = [
|
||||
"portfolio",
|
||||
]
|
||||
|
||||
# DEVELOPER's NOTE:
|
||||
# Normally, to exclude a field from an Admin form, we could simply utilize
|
||||
# Django's "exclude" feature. However, it causes a "missing key" error if we
|
||||
# go that route for this particular form. The error gets thrown by our
|
||||
# custom fieldset.html code and is due to the fact that "exclude" removes
|
||||
# fields from base_fields but not fieldsets. Rather than reworking our
|
||||
# custom frontend, it seems more straightforward (and easier to read) to simply
|
||||
# modify the fieldsets list so that it excludes any fields we want to remove
|
||||
# based on permissions (eg. superuser_only_fields) or other conditions.
|
||||
def get_fieldsets(self, request, obj=None):
|
||||
fieldsets = super().get_fieldsets(request, obj)
|
||||
|
||||
# Create a modified version of fieldsets to exclude certain fields
|
||||
if not request.user.has_perm("registrar.full_access_permission"):
|
||||
modified_fieldsets = []
|
||||
for name, data in fieldsets:
|
||||
fields = data.get("fields", [])
|
||||
fields = tuple(field for field in fields if field not in self.superuser_only_fields)
|
||||
modified_fieldsets.append((name, {"fields": fields}))
|
||||
return modified_fieldsets
|
||||
return fieldsets
|
||||
|
||||
# Table ordering
|
||||
# NOTE: This impacts the select2 dropdowns (combobox)
|
||||
# Currentl, there's only one for requests on DomainInfo
|
||||
|
@ -1818,10 +1872,93 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
return response
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
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):
|
||||
"""Custom transition domain admin class."""
|
||||
|
@ -1853,13 +1990,7 @@ class DomainInformationInline(admin.StackedInline):
|
|||
template = "django/admin/includes/domain_info_inline_stacked.html"
|
||||
model = models.DomainInformation
|
||||
|
||||
fieldsets = copy.deepcopy(DomainInformationAdmin.fieldsets)
|
||||
# remove .gov domain from fieldset
|
||||
for index, (title, f) in enumerate(fieldsets):
|
||||
if title == ".gov domain":
|
||||
del fieldsets[index]
|
||||
break
|
||||
|
||||
fieldsets = DomainInformationAdmin.fieldsets
|
||||
readonly_fields = DomainInformationAdmin.readonly_fields
|
||||
analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields
|
||||
|
||||
|
@ -1906,6 +2037,23 @@ class DomainInformationInline(admin.StackedInline):
|
|||
def get_readonly_fields(self, request, obj=None):
|
||||
return DomainInformationAdmin.get_readonly_fields(self, request, obj=None)
|
||||
|
||||
# Re-route the get_fieldsets method to utilize DomainInformationAdmin.get_fieldsets
|
||||
# since that has all the logic for excluding certain fields according to user permissions.
|
||||
# Then modify the remaining fields to further trim out any we don't want for this inline
|
||||
# form
|
||||
def get_fieldsets(self, request, obj=None):
|
||||
# Grab fieldsets from DomainInformationAdmin so that it handles all logic
|
||||
# for permission-based field visibility.
|
||||
modified_fieldsets = DomainInformationAdmin.get_fieldsets(self, request, obj=None)
|
||||
|
||||
# remove .gov domain from fieldset
|
||||
for index, (title, f) in enumerate(modified_fieldsets):
|
||||
if title == ".gov domain":
|
||||
del modified_fieldsets[index]
|
||||
break
|
||||
|
||||
return modified_fieldsets
|
||||
|
||||
|
||||
class DomainResource(FsmModelResource):
|
||||
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
|
||||
|
@ -2394,16 +2542,35 @@ class PublicContactResource(resources.ModelResource):
|
|||
|
||||
class Meta:
|
||||
model = models.PublicContact
|
||||
# may want to consider these bulk options in future, so left in as comments
|
||||
# use_bulk = True
|
||||
# batch_size = 1000
|
||||
# force_init_instance = True
|
||||
|
||||
def import_row(self, row, instance_loader, using_transactions=True, dry_run=False, raise_errors=None, **kwargs):
|
||||
"""Override kwargs skip_epp_save and set to True"""
|
||||
kwargs["skip_epp_save"] = True
|
||||
return super().import_row(
|
||||
row,
|
||||
instance_loader,
|
||||
using_transactions=using_transactions,
|
||||
dry_run=dry_run,
|
||||
raise_errors=raise_errors,
|
||||
def __init__(self):
|
||||
"""Sets global variables for code tidyness"""
|
||||
super().__init__()
|
||||
self.skip_epp_save = False
|
||||
|
||||
def import_data(
|
||||
self,
|
||||
dataset,
|
||||
dry_run=False,
|
||||
raise_errors=False,
|
||||
use_transactions=None,
|
||||
collect_failed_rows=False,
|
||||
rollback_on_validation_errors=False,
|
||||
**kwargs,
|
||||
):
|
||||
"""Override import_data to set self.skip_epp_save if in kwargs"""
|
||||
self.skip_epp_save = kwargs.get("skip_epp_save", False)
|
||||
return super().import_data(
|
||||
dataset,
|
||||
dry_run,
|
||||
raise_errors,
|
||||
use_transactions,
|
||||
collect_failed_rows,
|
||||
rollback_on_validation_errors,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
@ -2419,7 +2586,7 @@ class PublicContactResource(resources.ModelResource):
|
|||
# we don't have transactions and we want to do a dry_run
|
||||
pass
|
||||
else:
|
||||
instance.save(skip_epp_save=True)
|
||||
instance.save(skip_epp_save=self.skip_epp_save)
|
||||
self.after_save_instance(instance, using_transactions, dry_run)
|
||||
|
||||
|
||||
|
@ -2468,11 +2635,48 @@ class VerifiedByStaffAdmin(ListHeaderAdmin):
|
|||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
class FederalAgencyAdmin(ListHeaderAdmin):
|
||||
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):
|
||||
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
|
||||
import/export file"""
|
||||
|
||||
class Meta:
|
||||
model = models.FederalAgency
|
||||
|
||||
|
||||
class FederalAgencyAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||
list_display = ["agency"]
|
||||
search_fields = ["agency"]
|
||||
search_help_text = "Search by agency name."
|
||||
ordering = ["agency"]
|
||||
resource_classes = [FederalAgencyResource]
|
||||
|
||||
|
||||
class UserGroupAdmin(AuditedAdmin):
|
||||
|
@ -2516,6 +2720,14 @@ class WaffleFlagAdmin(FlagAdmin):
|
|||
fields = "__all__"
|
||||
|
||||
|
||||
class DomainGroupAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||
list_display = ["name", "portfolio"]
|
||||
|
||||
|
||||
class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||
list_display = ["name", "portfolio"]
|
||||
|
||||
|
||||
admin.site.unregister(LogEntry) # Unregister the default registration
|
||||
|
||||
admin.site.register(LogEntry, CustomLogEntryAdmin)
|
||||
|
@ -2538,6 +2750,9 @@ admin.site.register(models.PublicContact, PublicContactAdmin)
|
|||
admin.site.register(models.DomainRequest, DomainRequestAdmin)
|
||||
admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
|
||||
admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin)
|
||||
admin.site.register(models.Portfolio, PortfolioAdmin)
|
||||
admin.site.register(models.DomainGroup, DomainGroupAdmin)
|
||||
admin.site.register(models.Suborganization, SuborganizationAdmin)
|
||||
|
||||
# Register our custom waffle implementations
|
||||
admin.site.register(models.WaffleFlag, WaffleFlagAdmin)
|
||||
|
|
|
@ -137,6 +137,47 @@ function openInNewTab(el, removeAttribute = false){
|
|||
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
|
||||
*/
|
||||
(function (){
|
||||
|
@ -360,6 +401,30 @@ function initializeWidgetOnList(list, parentId) {
|
|||
sessionStorage.removeItem(name);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
})
|
||||
});
|
||||
})();
|
||||
|
||||
/** An IIFE for toggling the submit bar on domain request forms
|
||||
|
|
|
@ -17,6 +17,49 @@ var SUCCESS = "success";
|
|||
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
|
||||
// Helper functions.
|
||||
|
||||
/**
|
||||
* Hide element
|
||||
*
|
||||
*/
|
||||
const hideElement = (element) => {
|
||||
element.classList.add('display-none');
|
||||
};
|
||||
|
||||
/**
|
||||
* Show element
|
||||
*
|
||||
*/
|
||||
const showElement = (element) => {
|
||||
element.classList.remove('display-none');
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function that scrolls to an element
|
||||
* @param {string} attributeName - The string "class" or "id"
|
||||
* @param {string} attributeValue - The class or id name
|
||||
*/
|
||||
function ScrollToElement(attributeName, attributeValue) {
|
||||
let targetEl = null;
|
||||
|
||||
if (attributeName === 'class') {
|
||||
targetEl = document.getElementsByClassName(attributeValue)[0];
|
||||
} else if (attributeName === 'id') {
|
||||
targetEl = document.getElementById(attributeValue);
|
||||
} else {
|
||||
console.log('Error: unknown attribute name provided.');
|
||||
return; // Exit the function if an invalid attributeName is provided
|
||||
}
|
||||
|
||||
if (targetEl) {
|
||||
const rect = targetEl.getBoundingClientRect();
|
||||
const scrollTop = window.scrollY || document.documentElement.scrollTop;
|
||||
window.scrollTo({
|
||||
top: rect.top + scrollTop,
|
||||
behavior: 'smooth' // Optional: for smooth scrolling
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Makes an element invisible. */
|
||||
function makeHidden(el) {
|
||||
el.style.position = "absolute";
|
||||
|
@ -879,33 +922,6 @@ function unloadModals() {
|
|||
window.modal.off();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function that scrolls to an element
|
||||
* @param {string} attributeName - The string "class" or "id"
|
||||
* @param {string} attributeValue - The class or id name
|
||||
*/
|
||||
function ScrollToElement(attributeName, attributeValue) {
|
||||
let targetEl = null;
|
||||
|
||||
if (attributeName === 'class') {
|
||||
targetEl = document.getElementsByClassName(attributeValue)[0];
|
||||
} else if (attributeName === 'id') {
|
||||
targetEl = document.getElementById(attributeValue);
|
||||
} else {
|
||||
console.log('Error: unknown attribute name provided.');
|
||||
return; // Exit the function if an invalid attributeName is provided
|
||||
}
|
||||
|
||||
if (targetEl) {
|
||||
const rect = targetEl.getBoundingClientRect();
|
||||
const scrollTop = window.scrollY || document.documentElement.scrollTop;
|
||||
window.scrollTo({
|
||||
top: rect.top + scrollTop,
|
||||
behavior: 'smooth' // Optional: for smooth scrolling
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generalized function to update pagination for a list.
|
||||
* @param {string} itemName - The name displayed in the counter
|
||||
|
@ -918,8 +934,9 @@ function ScrollToElement(attributeName, attributeValue) {
|
|||
* @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 {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 paginationCounter = document.querySelector(counterSelector);
|
||||
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
|
||||
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) {
|
||||
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
|
||||
|
@ -1025,13 +1083,21 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
|
|||
*
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let domainsWrapper = document.querySelector('.domains-wrapper');
|
||||
const domainsWrapper = document.querySelector('.domains__table-wrapper');
|
||||
|
||||
if (domainsWrapper) {
|
||||
let currentSortBy = 'id';
|
||||
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 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
|
||||
|
@ -1040,10 +1106,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
* @param {*} sortBy - the sort column option
|
||||
* @param {*} order - the sort order {asc, desc}
|
||||
* @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(`/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(data => {
|
||||
if (data.error) {
|
||||
|
@ -1051,23 +1118,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
return;
|
||||
}
|
||||
|
||||
// handle the display of proper messaging in the event that no domains exist in the list
|
||||
if (data.domains.length) {
|
||||
domainsWrapper.classList.remove('display-none');
|
||||
noDomainsWrapper.classList.add('display-none');
|
||||
} else {
|
||||
domainsWrapper.classList.add('display-none');
|
||||
noDomainsWrapper.classList.remove('display-none');
|
||||
}
|
||||
// handle the display of proper messaging in the event that no domains exist in the list or search returns no results
|
||||
updateDisplay(data, domainsWrapper, noDomainsWrapper, noSearchResultsWrapper, searchTermHolder, currentSearchTerm);
|
||||
|
||||
// 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 = '';
|
||||
|
||||
data.domains.forEach(domain => {
|
||||
const options = { year: 'numeric', month: 'short', day: 'numeric' };
|
||||
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 actionUrl = domain.action_url;
|
||||
|
||||
|
@ -1106,9 +1167,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
});
|
||||
// initialize tool tips immediately after the associated DOM elements are added
|
||||
initializeTooltips();
|
||||
|
||||
// Do not scroll on first page load
|
||||
if (loaded)
|
||||
ScrollToElement('id', 'domains-header');
|
||||
|
||||
hasLoaded = true;
|
||||
|
||||
// update pagination
|
||||
|
@ -1122,18 +1184,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
data.num_pages,
|
||||
data.has_previous,
|
||||
data.has_next,
|
||||
data.total
|
||||
data.total,
|
||||
currentSearchTerm
|
||||
);
|
||||
currentSortBy = sortBy;
|
||||
currentOrder = order;
|
||||
currentSearchTerm = searchTerm;
|
||||
})
|
||||
.catch(error => console.error('Error fetching domains:', error));
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 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() {
|
||||
const sortBy = this.getAttribute('data-sortable');
|
||||
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
|
||||
loadDomains(1);
|
||||
}
|
||||
|
@ -1157,25 +1256,75 @@ const utcDateString = (dateString) => {
|
|||
const utcYear = date.getUTCFullYear();
|
||||
const utcMonth = date.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
|
||||
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');
|
||||
|
||||
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.
|
||||
*
|
||||
*/
|
||||
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) {
|
||||
let currentSortBy = 'id';
|
||||
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 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) {
|
||||
|
||||
// Use to debug uswds modal issues
|
||||
//console.log('deleteDomainRequest')
|
||||
|
||||
// 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
|
||||
|
@ -1184,10 +1333,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
* @param {*} sortBy - the sort column option
|
||||
* @param {*} order - the sort order {asc, desc}
|
||||
* @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(`/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(data => {
|
||||
if (data.error) {
|
||||
|
@ -1195,41 +1345,146 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
return;
|
||||
}
|
||||
|
||||
// handle the display of proper messaging in the event that no domain requests exist in the list
|
||||
if (data.domain_requests.length) {
|
||||
domainRequestsWrapper.classList.remove('display-none');
|
||||
noDomainRequestsWrapper.classList.add('display-none');
|
||||
} else {
|
||||
domainRequestsWrapper.classList.add('display-none');
|
||||
noDomainRequestsWrapper.classList.remove('display-none');
|
||||
}
|
||||
// handle the display of proper messaging in the event that no requests exist in the list or search returns no results
|
||||
updateDisplay(data, domainRequestsWrapper, noDomainRequestsWrapper, noSearchResultsWrapper, searchTermHolder, currentSearchTerm);
|
||||
|
||||
// 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 = '';
|
||||
|
||||
// Unload modals will re-inject the DOM with the initial placeholders to allow for .on() in regular use cases
|
||||
// We do NOT want that as it will cause multiple placeholders and therefore multiple inits on delete,
|
||||
// which will cause bad delete requests to be sent.
|
||||
const preExistingModalPlaceholders = document.querySelectorAll('[data-placeholder-for^="toggle-delete-domain-alert"]');
|
||||
preExistingModalPlaceholders.forEach(element => {
|
||||
element.remove();
|
||||
});
|
||||
|
||||
// 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
|
||||
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 => {
|
||||
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 actionUrl = request.action_url;
|
||||
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 deleteButton = request.is_deletable ? `
|
||||
<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>` : '';
|
||||
|
||||
// Even if the request is not deletable, we may need this empty string for the td if the deletable column is displayed
|
||||
let modalTrigger = '';
|
||||
|
||||
// If the request is deletable, create modal body and insert it
|
||||
if (request.is_deletable) {
|
||||
let modalHeading = '';
|
||||
let modalDescription = '';
|
||||
|
||||
if (request.requested_domain) {
|
||||
modalHeading = `Are you sure you want to delete ${request.requested_domain}?`;
|
||||
modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.';
|
||||
} 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');
|
||||
row.innerHTML = `
|
||||
|
@ -1250,15 +1505,36 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
${actionLabel} <span class="usa-sr-only">${request.requested_domain ? request.requested_domain : 'New domain request'}</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>${deleteButton}</td>
|
||||
${needsDeleteColumn ? '<td>'+modalTrigger+'</td>' : ''}
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
|
||||
// initialize modals immediately after the DOM content is updated
|
||||
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)
|
||||
ScrollToElement('id', 'domain-requests-header');
|
||||
|
||||
hasLoaded = true;
|
||||
|
||||
// update the pagination after the domain requests list is updated
|
||||
|
@ -1272,16 +1548,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
data.num_pages,
|
||||
data.has_previous,
|
||||
data.has_next,
|
||||
data.total
|
||||
data.total,
|
||||
currentSearchTerm
|
||||
);
|
||||
currentSortBy = sortBy;
|
||||
currentOrder = order;
|
||||
currentSearchTerm = searchTerm;
|
||||
})
|
||||
.catch(error => console.error('Error fetching domain requests:', error));
|
||||
}
|
||||
|
||||
// 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() {
|
||||
const sortBy = this.getAttribute('data-sortable');
|
||||
let order = 'asc';
|
||||
|
@ -1294,6 +1572,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
|
||||
loadDomainRequests(1);
|
||||
}
|
||||
|
@ -1301,6 +1616,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
|
||||
*/
|
||||
|
|
|
@ -782,3 +782,7 @@ div.dja__model-description{
|
|||
padding: 6px 8px 10px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.usa-button--dja-link-color {
|
||||
color: var(--link-fg);
|
||||
}
|
||||
|
|
|
@ -70,7 +70,7 @@ body {
|
|||
top: 50%;
|
||||
left: 0;
|
||||
width: 0; /* No width since it's a border */
|
||||
height: 50%;
|
||||
height: 40%;
|
||||
border-left: solid 1px color('base-light');
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
|
|
@ -98,7 +98,7 @@
|
|||
}
|
||||
}
|
||||
@media (min-width: 1040px){
|
||||
.dotgov-table__domain-requests {
|
||||
.domain-requests__table {
|
||||
th:nth-of-type(1) {
|
||||
width: 200px;
|
||||
}
|
||||
|
@ -122,7 +122,7 @@
|
|||
}
|
||||
|
||||
@media (min-width: 1040px){
|
||||
.dotgov-table__registered-domains {
|
||||
.domains__table {
|
||||
th:nth-of-type(1) {
|
||||
width: 200px;
|
||||
}
|
||||
|
|
|
@ -189,6 +189,7 @@ MIDDLEWARE = [
|
|||
# Used for waffle feature flags
|
||||
"waffle.middleware.WaffleMiddleware",
|
||||
"registrar.registrar_middleware.CheckUserProfileMiddleware",
|
||||
"registrar.registrar_middleware.CheckPortfolioMiddleware",
|
||||
]
|
||||
|
||||
# application object used by Django’s built-in servers (e.g. `runserver`)
|
||||
|
@ -659,6 +660,7 @@ ALLOWED_HOSTS = [
|
|||
"getgov-stable.app.cloud.gov",
|
||||
"getgov-staging.app.cloud.gov",
|
||||
"getgov-development.app.cloud.gov",
|
||||
"getgov-ag.app.cloud.gov",
|
||||
"getgov-litterbox.app.cloud.gov",
|
||||
"getgov-hotgov.app.cloud.gov",
|
||||
"getgov-cb.app.cloud.gov",
|
||||
|
|
|
@ -25,6 +25,7 @@ from registrar.views.domain_request import Step
|
|||
from registrar.views.domain_requests_json import get_domain_requests_json
|
||||
from registrar.views.domains_json import get_domains_json
|
||||
from registrar.views.utility import always_404
|
||||
from registrar.views.portfolios import portfolio_domains, portfolio_domain_requests
|
||||
from api.views import available, get_current_federal, get_current_full
|
||||
|
||||
|
||||
|
@ -58,6 +59,16 @@ for step, view in [
|
|||
|
||||
urlpatterns = [
|
||||
path("", views.index, name="home"),
|
||||
path(
|
||||
"portfolio/<int:portfolio_id>/domains/",
|
||||
portfolio_domains,
|
||||
name="portfolio-domains",
|
||||
),
|
||||
path(
|
||||
"portfolio/<int:portfolio_id>/domain_requests/",
|
||||
portfolio_domain_requests,
|
||||
name="portfolio-domain-requests",
|
||||
),
|
||||
path(
|
||||
"admin/logout/",
|
||||
RedirectView.as_view(pattern_name="logout", permanent=False),
|
||||
|
|
|
@ -10,6 +10,8 @@ from registrar.models.utility.domain_helper import DomainHelper
|
|||
class UserProfileForm(forms.ModelForm):
|
||||
"""Form for updating user profile."""
|
||||
|
||||
redirect = forms.CharField(widget=forms.HiddenInput(), required=False)
|
||||
|
||||
class Meta:
|
||||
model = Contact
|
||||
fields = ["first_name", "middle_name", "last_name", "title", "email", "phone"]
|
||||
|
@ -47,7 +49,7 @@ class UserProfileForm(forms.ModelForm):
|
|||
self.fields["middle_name"].label = "Middle name (optional)"
|
||||
self.fields["last_name"].label = "Last name / family name"
|
||||
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
|
||||
self.fields["first_name"].error_messages = {"required": "Enter your first name / given name."}
|
||||
|
|
|
@ -28,6 +28,7 @@ class Command(BaseCommand):
|
|||
* DomainInformation
|
||||
* DomainRequest
|
||||
* DraftDomain
|
||||
* FederalAgency
|
||||
* Host
|
||||
* HostIp
|
||||
* PublicContact
|
||||
|
@ -40,14 +41,15 @@ class Command(BaseCommand):
|
|||
table_names = [
|
||||
"DomainInformation",
|
||||
"DomainRequest",
|
||||
"FederalAgency",
|
||||
"PublicContact",
|
||||
"HostIp",
|
||||
"Host",
|
||||
"Domain",
|
||||
"User",
|
||||
"Contact",
|
||||
"Website",
|
||||
"DraftDomain",
|
||||
"HostIp",
|
||||
"Host",
|
||||
]
|
||||
|
||||
for table_name in table_names:
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
"""Generates current-metadata.csv then uploads to S3 + sends email"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import pyzipper
|
||||
|
||||
from datetime import datetime
|
||||
|
@ -9,7 +8,7 @@ from datetime import datetime
|
|||
from django.core.management import BaseCommand
|
||||
from django.conf import settings
|
||||
from registrar.utility import csv_export
|
||||
from registrar.utility.s3_bucket import S3ClientHelper
|
||||
from io import StringIO
|
||||
from ...utility.email import send_templated_email
|
||||
|
||||
|
||||
|
@ -17,89 +16,101 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Emails a encrypted zip file containing a csv of our domains and domain requests"""
|
||||
|
||||
help = (
|
||||
"Generates and uploads a domain-metadata.csv file to our S3 bucket "
|
||||
"which is based off of all existing Domains."
|
||||
)
|
||||
current_date = datetime.now().strftime("%m%d%Y")
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""Add our two filename arguments."""
|
||||
parser.add_argument("--directory", default="migrationdata", help="Desired directory")
|
||||
parser.add_argument(
|
||||
"--checkpath",
|
||||
default=True,
|
||||
help="Flag that determines if we do a check for os.path.exists. Used for test cases",
|
||||
"--emailTo",
|
||||
default=settings.DEFAULT_FROM_EMAIL,
|
||||
help="Defines where we should email this report",
|
||||
)
|
||||
|
||||
def handle(self, **options):
|
||||
"""Grabs the directory then creates domain-metadata.csv in that directory"""
|
||||
file_name = "domain-metadata.csv"
|
||||
# Ensures a slash is added
|
||||
directory = os.path.join(options.get("directory"), "")
|
||||
check_path = options.get("checkpath")
|
||||
zip_filename = f"domain-metadata-{self.current_date}.zip"
|
||||
email_to = options.get("emailTo")
|
||||
|
||||
# Don't email to DEFAULT_FROM_EMAIL when not prod.
|
||||
if not settings.IS_PRODUCTION and email_to == settings.DEFAULT_FROM_EMAIL:
|
||||
raise ValueError(
|
||||
"The --emailTo arg must be specified in non-prod environments, "
|
||||
"and the arg must not equal the DEFAULT_FROM_EMAIL value (aka: help@get.gov)."
|
||||
)
|
||||
|
||||
logger.info("Generating report...")
|
||||
try:
|
||||
self.email_current_metadata_report(directory, file_name, check_path)
|
||||
self.email_current_metadata_report(zip_filename, email_to)
|
||||
except Exception as err:
|
||||
# TODO - #1317: Notify operations when auto report generation fails
|
||||
raise err
|
||||
else:
|
||||
logger.info(f"Success! Created {file_name} and successfully sent out an email!")
|
||||
logger.info(f"Success! Created {zip_filename} and successfully sent out an email!")
|
||||
|
||||
def email_current_metadata_report(self, directory, file_name, check_path):
|
||||
"""Creates a current-metadata.csv file under the specified directory,
|
||||
then uploads it to a AWS S3 bucket. This is done for resiliency
|
||||
reasons in the event our application goes down and/or the email
|
||||
cannot send -- we'll still be able to grab info from the S3
|
||||
instance"""
|
||||
s3_client = S3ClientHelper()
|
||||
file_path = os.path.join(directory, file_name)
|
||||
def email_current_metadata_report(self, zip_filename, email_to):
|
||||
"""Emails a password protected zip containing domain-metadata and domain-request-metadata"""
|
||||
reports = {
|
||||
"Domain report": {
|
||||
"report_filename": f"domain-metadata-{self.current_date}.csv",
|
||||
"report_function": csv_export.export_data_type_to_csv,
|
||||
},
|
||||
"Domain request report": {
|
||||
"report_filename": f"domain-request-metadata-{self.current_date}.csv",
|
||||
"report_function": csv_export.DomainRequestExport.export_full_domain_request_report,
|
||||
},
|
||||
}
|
||||
|
||||
# Generate a file locally for upload
|
||||
with open(file_path, "w") as file:
|
||||
csv_export.export_data_type_to_csv(file)
|
||||
# Set the password equal to our content in SECRET_ENCRYPT_METADATA.
|
||||
# For local development, this will be "devpwd" unless otherwise set.
|
||||
# Uncomment these lines if you want to use this:
|
||||
# override = settings.SECRET_ENCRYPT_METADATA is None and not settings.IS_PRODUCTION
|
||||
# password = "devpwd" if override else settings.SECRET_ENCRYPT_METADATA
|
||||
password = settings.SECRET_ENCRYPT_METADATA
|
||||
if not password:
|
||||
raise ValueError("No password was specified for this zip file.")
|
||||
|
||||
if check_path and not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"Could not find newly created file at '{file_path}'")
|
||||
|
||||
s3_client.upload_file(file_path, file_name)
|
||||
|
||||
# Set zip file name
|
||||
current_date = datetime.now().strftime("%m%d%Y")
|
||||
current_filename = f"domain-metadata-{current_date}.zip"
|
||||
|
||||
# Pre-set zip file name
|
||||
encrypted_metadata_output = current_filename
|
||||
|
||||
# Set context for the subject
|
||||
current_date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Encrypt the metadata
|
||||
encrypted_metadata_in_bytes = self._encrypt_metadata(
|
||||
s3_client.get_file(file_name), encrypted_metadata_output, str.encode(settings.SECRET_ENCRYPT_METADATA)
|
||||
)
|
||||
encrypted_zip_in_bytes = self.get_encrypted_zip(zip_filename, reports, password)
|
||||
|
||||
# Send the metadata file that is zipped
|
||||
send_templated_email(
|
||||
template_name="emails/metadata_body.txt",
|
||||
subject_template_name="emails/metadata_subject.txt",
|
||||
to_address=settings.DEFAULT_FROM_EMAIL,
|
||||
context={"current_date_str": current_date_str},
|
||||
attachment_file=encrypted_metadata_in_bytes,
|
||||
to_address=email_to,
|
||||
context={"current_date_str": datetime.now().strftime("%Y-%m-%d")},
|
||||
attachment_file=encrypted_zip_in_bytes,
|
||||
)
|
||||
|
||||
def _encrypt_metadata(self, input_file, output_file, password):
|
||||
def get_encrypted_zip(self, zip_filename, reports, password):
|
||||
"""Helper function for encrypting the attachment file"""
|
||||
current_date = datetime.now().strftime("%m%d%Y")
|
||||
current_filename = f"domain-metadata-{current_date}.csv"
|
||||
|
||||
# Using ZIP_DEFLATED bc it's a more common compression method supported by most zip utilities and faster
|
||||
# We could also use compression=pyzipper.ZIP_LZMA if we are looking for smaller file size
|
||||
with pyzipper.AESZipFile(
|
||||
output_file, "w", compression=pyzipper.ZIP_DEFLATED, encryption=pyzipper.WZ_AES
|
||||
zip_filename, "w", compression=pyzipper.ZIP_DEFLATED, encryption=pyzipper.WZ_AES
|
||||
) as f_out:
|
||||
f_out.setpassword(password)
|
||||
f_out.writestr(current_filename, input_file)
|
||||
with open(output_file, "rb") as file_data:
|
||||
f_out.setpassword(str.encode(password))
|
||||
for report_name, report in reports.items():
|
||||
logger.info(f"Generating {report_name}")
|
||||
report_content = self.write_and_return_report(report["report_function"])
|
||||
f_out.writestr(report["report_filename"], report_content)
|
||||
|
||||
# Get the final report for emailing purposes
|
||||
with open(zip_filename, "rb") as file_data:
|
||||
attachment_in_bytes = file_data.read()
|
||||
|
||||
return attachment_in_bytes
|
||||
|
||||
def write_and_return_report(self, report_function):
|
||||
"""Writes a report to a StringIO object given a report_function and returns the string."""
|
||||
report_bytes = StringIO()
|
||||
report_function(report_bytes)
|
||||
|
||||
# Rewind the buffer to the beginning after writing
|
||||
report_bytes.seek(0)
|
||||
return report_bytes.read()
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
from django.core.paginator import Paginator
|
||||
import logging
|
||||
import os
|
||||
import pyzipper
|
||||
import tablib
|
||||
from django.core.management import BaseCommand
|
||||
import registrar.admin
|
||||
|
||||
|
@ -18,6 +20,7 @@ class Command(BaseCommand):
|
|||
"Domain",
|
||||
"DomainRequest",
|
||||
"DomainInformation",
|
||||
"FederalAgency",
|
||||
"UserDomainRole",
|
||||
"DraftDomain",
|
||||
"Website",
|
||||
|
@ -36,28 +39,58 @@ class Command(BaseCommand):
|
|||
zip_filename = "tmp/exported_tables.zip"
|
||||
with pyzipper.AESZipFile(zip_filename, "w", compression=pyzipper.ZIP_DEFLATED) as zipf:
|
||||
for table_name in table_names:
|
||||
csv_filename = f"tmp/{table_name}.csv"
|
||||
if os.path.exists(csv_filename):
|
||||
zipf.write(csv_filename, os.path.basename(csv_filename))
|
||||
logger.info(f"Added {csv_filename} to zip archive {zip_filename}")
|
||||
|
||||
# Remove the CSV files after adding them to the zip file
|
||||
for table_name in table_names:
|
||||
csv_filename = f"tmp/{table_name}.csv"
|
||||
if os.path.exists(csv_filename):
|
||||
os.remove(csv_filename)
|
||||
logger.info(f"Removed temporary file {csv_filename}")
|
||||
# Define the tmp directory and the file pattern
|
||||
tmp_dir = "tmp"
|
||||
pattern = f"{table_name}_"
|
||||
zip_file_path = os.path.join(tmp_dir, "exported_files.zip")
|
||||
|
||||
# Find all files that match the pattern
|
||||
matching_files = [file for file in os.listdir(tmp_dir) if file.startswith(pattern)]
|
||||
for file_path in matching_files:
|
||||
# Add each file to the zip archive
|
||||
zipf.write(f"tmp/{file_path}", os.path.basename(file_path))
|
||||
logger.info(f"Added {file_path} to {zip_file_path}")
|
||||
|
||||
# Remove the file after adding to zip
|
||||
os.remove(f"tmp/{file_path}")
|
||||
logger.info(f"Removed {file_path}")
|
||||
|
||||
def export_table(self, table_name):
|
||||
"""Export a given table to a csv file in the tmp directory"""
|
||||
"""Export a given table to csv files in the tmp directory"""
|
||||
resourcename = f"{table_name}Resource"
|
||||
try:
|
||||
resourceclass = getattr(registrar.admin, resourcename)
|
||||
dataset = resourceclass().export()
|
||||
filename = f"tmp/{table_name}.csv"
|
||||
with open(filename, "w") as outputfile:
|
||||
outputfile.write(dataset.csv)
|
||||
logger.info(f"Successfully exported {table_name} to {filename}")
|
||||
if not isinstance(dataset, tablib.Dataset):
|
||||
raise ValueError(f"Exported data from {resourcename} is not a tablib.Dataset")
|
||||
|
||||
# Determine the number of rows per file
|
||||
rows_per_file = 10000
|
||||
|
||||
# Use Paginator to handle splitting the dataset
|
||||
paginator = Paginator(dataset.dict, rows_per_file)
|
||||
num_files = paginator.num_pages
|
||||
|
||||
logger.info(f"splitting {table_name} into {num_files} files")
|
||||
|
||||
# Export each page to a separate file
|
||||
for page_num in paginator.page_range:
|
||||
page = paginator.page(page_num)
|
||||
|
||||
# Create a new dataset for the chunk
|
||||
chunk = tablib.Dataset(headers=dataset.headers)
|
||||
for row_dict in page.object_list:
|
||||
row = [row_dict[header] for header in dataset.headers]
|
||||
chunk.append(row)
|
||||
|
||||
# Export the chunk to a new file
|
||||
filename = f"tmp/{table_name}_{page_num}.csv"
|
||||
with open(filename, "w") as f:
|
||||
f.write(chunk.export("csv"))
|
||||
|
||||
logger.info(f"Successfully exported {table_name} into {num_files} files.")
|
||||
|
||||
except AttributeError:
|
||||
logger.error(f"Resource class {resourcename} not found in registrar.admin")
|
||||
except Exception as e:
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import pyzipper
|
||||
|
@ -14,6 +15,10 @@ logger = logging.getLogger(__name__)
|
|||
class Command(BaseCommand):
|
||||
help = "Imports tables from a zip file, exported_tables.zip, containing CSV files in the tmp directory."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""Add command line arguments."""
|
||||
parser.add_argument("--skipEppSave", default=True, action=argparse.BooleanOptionalAction)
|
||||
|
||||
def handle(self, **options):
|
||||
"""Extracts CSV files from a zip archive and imports them into the respective tables"""
|
||||
|
||||
|
@ -21,6 +26,8 @@ class Command(BaseCommand):
|
|||
logger.error("import_tables cannot be run in production")
|
||||
return
|
||||
|
||||
self.skip_epp_save = options.get("skipEppSave")
|
||||
|
||||
table_names = [
|
||||
"User",
|
||||
"Contact",
|
||||
|
@ -29,6 +36,7 @@ class Command(BaseCommand):
|
|||
"HostIp",
|
||||
"DraftDomain",
|
||||
"Website",
|
||||
"FederalAgency",
|
||||
"DomainRequest",
|
||||
"DomainInformation",
|
||||
"UserDomainRole",
|
||||
|
@ -56,38 +64,46 @@ class Command(BaseCommand):
|
|||
"""Import data from a CSV file into the given table"""
|
||||
|
||||
resourcename = f"{table_name}Resource"
|
||||
csv_filename = f"tmp/{table_name}.csv"
|
||||
try:
|
||||
if not os.path.exists(csv_filename):
|
||||
logger.error(f"CSV file {csv_filename} not found.")
|
||||
return
|
||||
|
||||
# if table_name is Contact, clean the table first
|
||||
# User table is loaded before Contact, and signals create
|
||||
# rows in Contact table which break the import, so need
|
||||
# to be cleaned again before running import on Contact table
|
||||
if table_name == "Contact":
|
||||
self.clean_table(table_name)
|
||||
# if table_name is Contact, clean the table first
|
||||
# User table is loaded before Contact, and signals create
|
||||
# rows in Contact table which break the import, so need
|
||||
# to be cleaned again before running import on Contact table
|
||||
if table_name == "Contact":
|
||||
self.clean_table(table_name)
|
||||
|
||||
resourceclass = getattr(registrar.admin, resourcename)
|
||||
resource_instance = resourceclass()
|
||||
with open(csv_filename, "r") as csvfile:
|
||||
dataset = tablib.Dataset().load(csvfile.read(), format="csv")
|
||||
result = resource_instance.import_data(dataset, dry_run=False, skip_epp_save=True)
|
||||
# Define the directory and the pattern for csv filenames
|
||||
tmp_dir = "tmp"
|
||||
pattern = f"{table_name}_"
|
||||
|
||||
if result.has_errors():
|
||||
logger.error(f"Errors occurred while importing {csv_filename}: {result.row_errors()}")
|
||||
else:
|
||||
logger.info(f"Successfully imported {csv_filename} into {table_name}")
|
||||
resourceclass = getattr(registrar.admin, resourcename)
|
||||
resource_instance = resourceclass()
|
||||
|
||||
except AttributeError:
|
||||
logger.error(f"Resource class {resourcename} not found in registrar.admin")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import {csv_filename}: {e}")
|
||||
finally:
|
||||
if os.path.exists(csv_filename):
|
||||
os.remove(csv_filename)
|
||||
logger.info(f"Removed temporary file {csv_filename}")
|
||||
# Find all files that match the pattern
|
||||
matching_files = [file for file in os.listdir(tmp_dir) if file.startswith(pattern)]
|
||||
for csv_filename in matching_files:
|
||||
try:
|
||||
with open(f"tmp/{csv_filename}", "r") as csvfile:
|
||||
dataset = tablib.Dataset().load(csvfile.read(), format="csv")
|
||||
result = resource_instance.import_data(dataset, dry_run=False, skip_epp_save=self.skip_epp_save)
|
||||
if result.has_errors():
|
||||
logger.error(f"Errors occurred while importing {csv_filename}:")
|
||||
for row_error in result.row_errors():
|
||||
row_index = row_error[0]
|
||||
errors = row_error[1]
|
||||
for error in errors:
|
||||
logger.error(f"Row {row_index} - {error.error} - {error.row}")
|
||||
else:
|
||||
logger.info(f"Successfully imported {csv_filename} into {table_name}")
|
||||
|
||||
except AttributeError:
|
||||
logger.error(f"Resource class {resourcename} not found in registrar.admin")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import {csv_filename}: {e}")
|
||||
finally:
|
||||
if os.path.exists(csv_filename):
|
||||
os.remove(csv_filename)
|
||||
logger.info(f"Removed temporary file {csv_filename}")
|
||||
|
||||
def clean_table(self, table_name):
|
||||
"""Delete all rows in the given table"""
|
||||
|
|
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,
|
||||
),
|
||||
]
|
41
src/registrar/migrations/0105_suborganization_domaingroup.py
Normal file
41
src/registrar/migrations/0105_suborganization_domaingroup.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
# Generated by Django 4.2.10 on 2024-06-21 18:15
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("registrar", "0104_create_groups_v13"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Suborganization",
|
||||
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)),
|
||||
("name", models.CharField(help_text="Suborganization", max_length=1000, unique=True)),
|
||||
("portfolio", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="registrar.portfolio")),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="DomainGroup",
|
||||
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)),
|
||||
("name", models.CharField(help_text="Domain group", unique=True)),
|
||||
("domains", models.ManyToManyField(blank=True, to="registrar.domaininformation")),
|
||||
("portfolio", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="registrar.portfolio")),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("name", "portfolio")},
|
||||
},
|
||||
),
|
||||
]
|
37
src/registrar/migrations/0106_create_groups_v14.py
Normal file
37
src/registrar/migrations/0106_create_groups_v14.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", "0105_suborganization_domaingroup"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
create_groups,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
atomic=True,
|
||||
),
|
||||
]
|
|
@ -16,6 +16,9 @@ from .website import Website
|
|||
from .transition_domain import TransitionDomain
|
||||
from .verified_by_staff import VerifiedByStaff
|
||||
from .waffle_flag import WaffleFlag
|
||||
from .portfolio import Portfolio
|
||||
from .domain_group import DomainGroup
|
||||
from .suborganization import Suborganization
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
@ -36,6 +39,9 @@ __all__ = [
|
|||
"TransitionDomain",
|
||||
"VerifiedByStaff",
|
||||
"WaffleFlag",
|
||||
"Portfolio",
|
||||
"DomainGroup",
|
||||
"Suborganization",
|
||||
]
|
||||
|
||||
auditlog.register(Contact)
|
||||
|
@ -55,3 +61,6 @@ auditlog.register(Website)
|
|||
auditlog.register(TransitionDomain)
|
||||
auditlog.register(VerifiedByStaff)
|
||||
auditlog.register(WaffleFlag)
|
||||
auditlog.register(Portfolio)
|
||||
auditlog.register(DomainGroup)
|
||||
auditlog.register(Suborganization)
|
||||
|
|
|
@ -40,6 +40,8 @@ from .utility.time_stamped_model import TimeStampedModel
|
|||
|
||||
from .public_contact import PublicContact
|
||||
|
||||
from .user_domain_role import UserDomainRole
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
@ -672,11 +674,29 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
remRequest = commands.UpdateDomain(name=self.name)
|
||||
remExtension = commands.UpdateDomainDNSSECExtension(**remParams)
|
||||
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:
|
||||
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)
|
||||
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)
|
||||
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:
|
||||
logger.error("Error updating DNSSEC, code was %s error was %s" % (e.code, e))
|
||||
raise e
|
||||
|
@ -1057,6 +1077,12 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
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):
|
||||
return self.state == Domain.State.CREATED
|
||||
|
||||
|
|
23
src/registrar/models/domain_group.py
Normal file
23
src/registrar/models/domain_group.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
from django.db import models
|
||||
from .utility.time_stamped_model import TimeStampedModel
|
||||
|
||||
|
||||
class DomainGroup(TimeStampedModel):
|
||||
"""
|
||||
Organized group of domains.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
unique_together = [("name", "portfolio")]
|
||||
|
||||
name = models.CharField(
|
||||
unique=True,
|
||||
help_text="Domain group",
|
||||
)
|
||||
|
||||
portfolio = models.ForeignKey("registrar.Portfolio", on_delete=models.PROTECT)
|
||||
|
||||
domains = models.ManyToManyField("registrar.DomainInformation", blank=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name}"
|
|
@ -57,6 +57,16 @@ class DomainInformation(TimeStampedModel):
|
|||
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(
|
||||
"registrar.DomainRequest",
|
||||
on_delete=models.PROTECT,
|
||||
|
|
|
@ -17,7 +17,7 @@ from .utility.time_stamped_model import TimeStampedModel
|
|||
from ..utility.email import send_templated_email, EmailSendingError
|
||||
from itertools import chain
|
||||
|
||||
from auditlog.models import AuditlogHistoryField # type: ignore
|
||||
from waffle.decorators import flag_is_active
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -35,11 +35,7 @@ class DomainRequest(TimeStampedModel):
|
|||
]
|
||||
|
||||
# https://django-auditlog.readthedocs.io/en/latest/usage.html#object-history
|
||||
# If we note any performace degradation due to this addition,
|
||||
# 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()
|
||||
# history = AuditlogHistoryField()
|
||||
|
||||
# Constants for choice fields
|
||||
class DomainRequestStatus(models.TextChoices):
|
||||
|
@ -262,6 +258,11 @@ class DomainRequest(TimeStampedModel):
|
|||
NAMING_REQUIREMENTS = "naming_not_met", "Naming requirements not met"
|
||||
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"""
|
||||
|
||||
|
@ -271,6 +272,11 @@ class DomainRequest(TimeStampedModel):
|
|||
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 #####
|
||||
status = FSMField(
|
||||
choices=DomainRequestStatus.choices, # possible states as an array of constants
|
||||
|
@ -299,6 +305,16 @@ class DomainRequest(TimeStampedModel):
|
|||
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
|
||||
# information that they gave is in the `submitter` field
|
||||
creator = models.ForeignKey(
|
||||
|
@ -1151,19 +1167,21 @@ class DomainRequest(TimeStampedModel):
|
|||
def _is_policy_acknowledgement_complete(self):
|
||||
return self.is_policy_acknowledged is not None
|
||||
|
||||
def _is_general_form_complete(self):
|
||||
def _is_general_form_complete(self, request):
|
||||
has_profile_feature_flag = flag_is_active(request, "profile_feature")
|
||||
return (
|
||||
self._is_organization_name_and_address_complete()
|
||||
and self._is_authorizing_official_complete()
|
||||
and self._is_requested_domain_complete()
|
||||
and self._is_purpose_complete()
|
||||
and self._is_submitter_complete()
|
||||
# NOTE: This flag leaves submitter as empty (request wont submit) hence preset to True
|
||||
and (self._is_submitter_complete() if not has_profile_feature_flag else True)
|
||||
and self._is_other_contacts_complete()
|
||||
and self._is_additional_details_complete()
|
||||
and self._is_policy_acknowledgement_complete()
|
||||
)
|
||||
|
||||
def _form_complete(self):
|
||||
def _form_complete(self, request):
|
||||
match self.generic_org_type:
|
||||
case DomainRequest.OrganizationChoices.FEDERAL:
|
||||
is_complete = self._is_federal_complete()
|
||||
|
@ -1184,8 +1202,6 @@ class DomainRequest(TimeStampedModel):
|
|||
case _:
|
||||
# NOTE: Shouldn't happen, this is only if somehow they didn't choose an org type
|
||||
is_complete = False
|
||||
|
||||
if not is_complete or not self._is_general_form_complete():
|
||||
if not is_complete or not self._is_general_form_complete(request):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
|
|
@ -230,3 +230,8 @@ class FederalAgency(TimeStampedModel):
|
|||
FederalAgency.objects.bulk_create(agencies)
|
||||
except Exception as 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()
|
||||
|
|
102
src/registrar/models/portfolio.py
Normal file
102
src/registrar/models/portfolio.py
Normal file
|
@ -0,0 +1,102 @@
|
|||
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,
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.organization_name}"
|
22
src/registrar/models/suborganization.py
Normal file
22
src/registrar/models/suborganization.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
from django.db import models
|
||||
from .utility.time_stamped_model import TimeStampedModel
|
||||
|
||||
|
||||
class Suborganization(TimeStampedModel):
|
||||
"""
|
||||
Suborganization under an organization (portfolio)
|
||||
"""
|
||||
|
||||
name = models.CharField(
|
||||
unique=True,
|
||||
max_length=1000,
|
||||
help_text="Suborganization",
|
||||
)
|
||||
|
||||
portfolio = models.ForeignKey(
|
||||
"registrar.Portfolio",
|
||||
on_delete=models.PROTECT,
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name}"
|
|
@ -2,13 +2,18 @@
|
|||
Contains middleware used in settings.py
|
||||
"""
|
||||
|
||||
import logging
|
||||
from urllib.parse import parse_qs
|
||||
from django.urls import reverse
|
||||
from django.http import HttpResponseRedirect
|
||||
from registrar.models.portfolio import Portfolio
|
||||
from registrar.models.user import User
|
||||
from waffle.decorators import flag_is_active
|
||||
|
||||
from registrar.models.utility.generic_helper import replace_url_queryparams
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NoCacheMiddleware:
|
||||
"""
|
||||
|
@ -38,11 +43,27 @@ class CheckUserProfileMiddleware:
|
|||
self.get_response = get_response
|
||||
|
||||
self.setup_page = reverse("finish-user-profile-setup")
|
||||
self.profile_page = reverse("user-profile")
|
||||
self.logout_page = reverse("logout")
|
||||
self.excluded_pages = [
|
||||
|
||||
self.regular_excluded_pages = [
|
||||
self.setup_page,
|
||||
self.logout_page,
|
||||
"/admin",
|
||||
]
|
||||
self.other_excluded_pages = [
|
||||
self.profile_page,
|
||||
self.logout_page,
|
||||
"/admin",
|
||||
]
|
||||
|
||||
self.excluded_pages = {
|
||||
self.setup_page: self.regular_excluded_pages,
|
||||
self.profile_page: self.other_excluded_pages,
|
||||
}
|
||||
|
||||
def _get_excluded_pages(self, page):
|
||||
return self.excluded_pages.get(page, [])
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
|
@ -60,13 +81,16 @@ class CheckUserProfileMiddleware:
|
|||
return None
|
||||
|
||||
if request.user.is_authenticated:
|
||||
profile_page = self.profile_page
|
||||
if request.user.verification_type == User.VerificationTypeChoices.REGULAR:
|
||||
profile_page = self.setup_page
|
||||
if hasattr(request.user, "finished_setup") and not request.user.finished_setup:
|
||||
return self._handle_setup_not_finished(request)
|
||||
return self._handle_user_setup_not_finished(request, profile_page)
|
||||
|
||||
# Continue processing the view
|
||||
return None
|
||||
|
||||
def _handle_setup_not_finished(self, request):
|
||||
def _handle_user_setup_not_finished(self, request, profile_page):
|
||||
"""Redirects the given user to the finish setup page.
|
||||
|
||||
We set the "redirect" query param equal to where the user wants to go.
|
||||
|
@ -82,7 +106,7 @@ class CheckUserProfileMiddleware:
|
|||
custom_redirect = "domain-request:" if request.path == "/request/" else None
|
||||
|
||||
# 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._get_excluded_pages(profile_page)):
|
||||
|
||||
# Preserve the original query parameters, and coerce them into a dict
|
||||
query_params = parse_qs(request.META["QUERY_STRING"])
|
||||
|
@ -92,9 +116,39 @@ class CheckUserProfileMiddleware:
|
|||
query_params["redirect"] = custom_redirect
|
||||
|
||||
# Add our new query param, while preserving old ones
|
||||
new_setup_page = replace_url_queryparams(self.setup_page, query_params) if query_params else self.setup_page
|
||||
new_setup_page = replace_url_queryparams(profile_page, query_params) if query_params else profile_page
|
||||
|
||||
return HttpResponseRedirect(new_setup_page)
|
||||
else:
|
||||
# Process the view as normal
|
||||
return None
|
||||
|
||||
|
||||
class CheckPortfolioMiddleware:
|
||||
"""
|
||||
Checks if the current user has a portfolio
|
||||
If they do, redirect them to the portfolio homepage when they navigate to home.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
self.home = reverse("home")
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
return response
|
||||
|
||||
def process_view(self, request, view_func, view_args, view_kwargs):
|
||||
current_path = request.path
|
||||
|
||||
has_organization_feature_flag = flag_is_active(request, "organization_feature")
|
||||
|
||||
if current_path == self.home:
|
||||
if has_organization_feature_flag:
|
||||
if request.user.is_authenticated:
|
||||
user_portfolios = Portfolio.objects.filter(creator=request.user)
|
||||
if user_portfolios.exists():
|
||||
first_portfolio = user_portfolios.first()
|
||||
home_with_portfolio = reverse("portfolio-domains", kwargs={"portfolio_id": first_portfolio.id})
|
||||
return HttpResponseRedirect(home_with_portfolio)
|
||||
return None
|
||||
|
|
|
@ -68,42 +68,52 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
{% endblock field_readonly %}
|
||||
|
||||
{% block after_help_text %}
|
||||
{% if field.field.name == "status" and original_object.history.count > 0 %}
|
||||
<div class="flex-container" id="dja-status-changelog">
|
||||
<label aria-label="Status changelog"></label>
|
||||
<div>
|
||||
<div class="usa-table-container--scrollable collapse--dgsimple" tabindex="0">
|
||||
<table class="usa-table usa-table--borderless">
|
||||
<thead>
|
||||
{% if field.field.name == "status" and filtered_audit_log_entries %}
|
||||
<div class="flex-container" id="dja-status-changelog">
|
||||
<label aria-label="Status changelog"></label>
|
||||
<div>
|
||||
<div class="usa-table-container--scrollable collapse--dgsimple collapsed" tabindex="0">
|
||||
<table class="usa-table usa-table--borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>User</th>
|
||||
<th>Changed at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in filtered_audit_log_entries %}
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>User</th>
|
||||
<th>Changed at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
<td>
|
||||
{% if entry.status %}
|
||||
{{ entry.status|default:"Error" }}
|
||||
{% else %}
|
||||
Error
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button type="button" class="collapse-toggle--dgsimple usa-button usa-button--unstyled margin-top-2 margin-bottom-1 margin-left-1">
|
||||
<span>Hide 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_less"></use>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{% if entry.rejection_reason %}
|
||||
- {{ entry.rejection_reason|default:"Error" }}
|
||||
{% endif %}
|
||||
|
||||
{% if entry.action_needed_reason %}
|
||||
- {{ entry.action_needed_reason|default:"Error" }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ entry.actor|default:"Error" }}</td>
|
||||
<td>{{ entry.timestamp|date:"Y-m-d H:i:s" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
{% elif field.field.name == "creator" %}
|
||||
<div class="flex-container tablet:margin-top-2">
|
||||
<label aria-label="Creator contact details"></label>
|
||||
|
@ -174,5 +184,19 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
{% endif %}
|
||||
</span>
|
||||
</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 %}
|
||||
{% endblock after_help_text %}
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
{% extends 'domain_request_form.html' %}
|
||||
{% 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 %}
|
||||
{# commented out so it does not appear at this point on this page #}
|
||||
{% include "includes/required_fields.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block form_fields %}
|
||||
|
||||
<fieldset class="usa-fieldset margin-top-2">
|
||||
<legend>
|
||||
<h2>Are you working with a CISA regional representative on your domain request?</h2>
|
||||
|
@ -17,6 +16,7 @@
|
|||
</legend>
|
||||
|
||||
<!-- 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" %}
|
||||
{% input_with_errors forms.0.has_cisa_representative %}
|
||||
{% endwith %}
|
||||
|
@ -30,20 +30,21 @@
|
|||
{# forms.1 is a form for inputting the e-mail of a cisa representative #}
|
||||
</div>
|
||||
|
||||
|
||||
<fieldset class="usa-fieldset margin-top-2">
|
||||
<legend>
|
||||
<h2>Is there anything else you’d like us to know about your domain request?</h2>
|
||||
</legend>
|
||||
|
||||
<!-- 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" %}
|
||||
{% input_with_errors forms.2.has_anything_else_text %}
|
||||
{% endwith %}
|
||||
{# forms.2 is a small yes/no form that toggles the visibility of "cisa representative" formset #}
|
||||
</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" %}
|
||||
{% input_with_errors forms.3.anything_else %}
|
||||
{% endwith %}
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
completing your domain request might take around 15 minutes.</p>
|
||||
{% if has_profile_feature_flag %}
|
||||
<h2>How we’ll reach you</h2>
|
||||
<p>While reviewing your domain request, we may need to reach out with questions. We’ll also email you when we complete our review If the contact information below is not correct, visit <a href="{% url 'user-profile' %}?return_to_request=True" class="usa-link">your profile</a> to make updates.</p>
|
||||
<p>While reviewing your domain request, we may need to reach out with questions. We’ll also email you when we complete our review If the contact information below is not correct, visit <a href="{% url 'user-profile' %}?redirect=domain-request:" class="usa-link">your profile</a> to make updates.</p>
|
||||
{% include "includes/profile_information.html" with user=user%}
|
||||
{% endif %}
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
{# Disable the redirect #}
|
||||
{% block logo %}
|
||||
{% include "includes/gov_extended_logo.html" with logo_clickable=confirm_changes %}
|
||||
{% include "includes/gov_extended_logo.html" with logo_clickable=user_finished_setup %}
|
||||
{% endblock %}
|
||||
|
||||
{# Add the new form #}
|
||||
|
@ -16,5 +16,5 @@
|
|||
{% endblock content_bottom %}
|
||||
|
||||
{% block footer %}
|
||||
{% include "includes/footer.html" with show_manage_your_domains=confirm_changes %}
|
||||
{% include "includes/footer.html" with show_manage_your_domains=user_finished_setup %}
|
||||
{% endblock footer %}
|
||||
|
|
|
@ -9,156 +9,48 @@
|
|||
{% if user.is_authenticated %}
|
||||
{# the entire logged in page goes here #}
|
||||
|
||||
<div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1">
|
||||
{% block messages %}
|
||||
{% include "includes/form_messages.html" %}
|
||||
{% endblock %}
|
||||
<h1>Manage your domains</h2>
|
||||
{% block homepage_content %}
|
||||
|
||||
<div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1">
|
||||
{% block messages %}
|
||||
{% include "includes/form_messages.html" %}
|
||||
{% endblock %}
|
||||
<h1>Manage your domains</h1>
|
||||
|
||||
<p class="margin-top-4">
|
||||
<a href="{% url 'domain-request:' %}" class="usa-button"
|
||||
>
|
||||
Start a new domain request
|
||||
</a>
|
||||
</p>
|
||||
{% 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">
|
||||
<a href="{% url 'domain-request:' %}" class="usa-button"
|
||||
>
|
||||
Start a new domain request
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<section class="section--outlined">
|
||||
<h2 id="domains-header">Domains</h2>
|
||||
<div class="domains-wrapper display-none">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked dotgov-table__registered-domains">
|
||||
<caption class="sr-only">Your registered domains</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sortable="name" scope="col" role="columnheader">Domain name</th>
|
||||
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
|
||||
<th data-sortable="state_display" scope="col" role="columnheader">Status</th>
|
||||
<th
|
||||
scope="col"
|
||||
role="columnheader"
|
||||
>
|
||||
<span class="usa-sr-only">Action</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- AJAX will populate this tbody -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class="usa-sr-only usa-table__announcement-region"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
</div>
|
||||
<div class="no-domains-wrapper display-none">
|
||||
<p>You don't have any registered domains.</p>
|
||||
<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">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#help_outline"></use>
|
||||
</svg>
|
||||
Why don't I see my domain when I sign in to the registrar?
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<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">
|
||||
<!-- Count will be dynamically populated by JS -->
|
||||
</span>
|
||||
<ul class="usa-pagination__list">
|
||||
<!-- Pagination links will be dynamically populated by JS -->
|
||||
</ul>
|
||||
</nav>
|
||||
{% include "includes/domains_table.html" %}
|
||||
{% include "includes/domain_requests_table.html" %}
|
||||
|
||||
<section class="section--outlined">
|
||||
<h2 id="domain-requests-header">Domain requests</h2>
|
||||
<div class="domain-requests-wrapper display-none">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked dotgov-table__domain-requests">
|
||||
<caption class="sr-only">Your domain requests</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sortable="requested_domain__name" scope="col" role="columnheader">Domain name</th>
|
||||
<th data-sortable="submission_date" scope="col" role="columnheader">Date submitted</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">Delete Action</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="domain-requests-tbody">
|
||||
<!-- AJAX will populate this tbody -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class="usa-sr-only usa-table__announcement-region"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
{# Note: Reimplement this after MVP #}
|
||||
<!--
|
||||
<section class="section--outlined tablet:grid-col-11 desktop:grid-col-10">
|
||||
<h2>Archived domains</h2>
|
||||
<p>You don't have any archived domains</p>
|
||||
</section>
|
||||
-->
|
||||
|
||||
{% 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 %}
|
||||
<!-- Note: Uncomment below when this is being implemented post-MVP -->
|
||||
<!-- <section class="tablet:grid-col-11 desktop:grid-col-10">
|
||||
<h2 class="padding-top-1 mobile-lg:padding-top-3"> Export domains</h2>
|
||||
<p>Download a list of your domains and their statuses as a csv file.</p>
|
||||
<a href="{% url 'todo' %}" class="usa-button usa-button--outline">
|
||||
Export domains as csv
|
||||
</a>
|
||||
</section>
|
||||
-->
|
||||
|
||||
</div>
|
||||
<div class="no-domain-requests-wrapper display-none">
|
||||
<p>You haven't requested any domains.</p>
|
||||
</div>
|
||||
</section>
|
||||
<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">
|
||||
<!-- Count will be dynamically populated by JS -->
|
||||
</span>
|
||||
<ul class="usa-pagination__list">
|
||||
<!-- Pagination links will be dynamically populated by JS -->
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{# Note: Reimplement this after MVP #}
|
||||
<!--
|
||||
<section class="section--outlined tablet:grid-col-11 desktop:grid-col-10">
|
||||
<h2>Archived domains</h2>
|
||||
<p>You don't have any archived domains</p>
|
||||
</section>
|
||||
-->
|
||||
|
||||
<!-- Note: Uncomment below when this is being implemented post-MVP -->
|
||||
<!-- <section class="tablet:grid-col-11 desktop:grid-col-10">
|
||||
<h2 class="padding-top-1 mobile-lg:padding-top-3"> Export domains</h2>
|
||||
<p>Download a list of your domains and their statuses as a csv file.</p>
|
||||
<a href="{% url 'todo' %}" class="usa-button usa-button--outline">
|
||||
Export domains as csv
|
||||
</a>
|
||||
</section>
|
||||
-->
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
{% else %} {# not user.is_authenticated #}
|
||||
|
|
71
src/registrar/templates/includes/domain_requests_table.html
Normal file
71
src/registrar/templates/includes/domain_requests_table.html
Normal file
|
@ -0,0 +1,71 @@
|
|||
{% load static %}
|
||||
|
||||
<section class="section--outlined domain-requests">
|
||||
<div class="grid-row">
|
||||
{% if portfolio is None %}
|
||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
|
||||
</div>
|
||||
{% endif %}
|
||||
<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>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sortable="requested_domain__name" scope="col" role="columnheader">Domain name</th>
|
||||
<th data-sortable="submission_date" scope="col" role="columnheader">Date submitted</th>
|
||||
<th data-sortable="status" scope="col" role="columnheader">Status</th>
|
||||
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th>
|
||||
<!-- AJAX will conditionally add a th for delete actions -->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="domain-requests-tbody">
|
||||
<!-- AJAX will populate this tbody -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class="usa-sr-only usa-table__announcement-region"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
</div>
|
||||
<div class="domain-requests__no-data display-none">
|
||||
<p>You haven't requested any domains.</p>
|
||||
</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>
|
||||
<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">
|
||||
<!-- Count will be dynamically populated by JS -->
|
||||
</span>
|
||||
<ul class="usa-pagination__list">
|
||||
<!-- Pagination links will be dynamically populated by JS -->
|
||||
</ul>
|
||||
</nav>
|
83
src/registrar/templates/includes/domains_table.html
Normal file
83
src/registrar/templates/includes/domains_table.html
Normal file
|
@ -0,0 +1,83 @@
|
|||
{% load static %}
|
||||
|
||||
<section class="section--outlined domains">
|
||||
<div class="grid-row">
|
||||
{% if portfolio is None %}
|
||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||
<h2 id="domains-header" class="flex-6">Domains</h2>
|
||||
</div>
|
||||
{% endif %}
|
||||
<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>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sortable="name" scope="col" role="columnheader">Domain name</th>
|
||||
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
|
||||
<th data-sortable="state_display" scope="col" role="columnheader">Status</th>
|
||||
<th
|
||||
scope="col"
|
||||
role="columnheader"
|
||||
>
|
||||
<span class="usa-sr-only">Action</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- AJAX will populate this tbody -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class="usa-sr-only usa-table__announcement-region"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
</div>
|
||||
<div class="domains__no-data display-none">
|
||||
<p>You don't have any registered domains.</p>
|
||||
<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">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#help_outline"></use>
|
||||
</svg>
|
||||
Why don't I see my domain when I sign in to the registrar?
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="domains__no-search-results display-none">
|
||||
<p>No results found for "<span class="domains__search-term"></span>"</p>
|
||||
</div>
|
||||
</section>
|
||||
<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">
|
||||
<!-- Count will be dynamically populated by JS -->
|
||||
</span>
|
||||
<ul class="usa-pagination__list">
|
||||
<!-- Pagination links will be dynamically populated by JS -->
|
||||
</ul>
|
||||
</nav>
|
|
@ -33,6 +33,8 @@
|
|||
Your contact information
|
||||
</legend>
|
||||
|
||||
<input type="hidden" name="redirect" value="{{ form.initial.redirect }}">
|
||||
|
||||
{% with show_edit_button=True show_readonly=True group_classes="usa-form-editable usa-form-editable--no-border padding-top-2" %}
|
||||
{% input_with_errors form.full_name %}
|
||||
{% endwith %}
|
||||
|
@ -78,7 +80,7 @@
|
|||
<button type="submit" name="contact_setup_save_button" class="usa-button ">
|
||||
Save
|
||||
</button>
|
||||
{% if confirm_changes and going_to_specific_page %}
|
||||
{% if user_finished_setup and going_to_specific_page %}
|
||||
<button type="submit" name="contact_setup_submit_button" class="usa-button usa-button--outline">
|
||||
{{redirect_button_text }}
|
||||
</button>
|
||||
|
|
|
@ -19,6 +19,9 @@
|
|||
<form class="usa-form usa-form--large" method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
{# Include the hidden 'redirect' field #}
|
||||
<input type="hidden" name="redirect" value="{{ form.initial.redirect }}">
|
||||
|
||||
{% input_with_errors form.first_name %}
|
||||
|
||||
{% input_with_errors form.middle_name %}
|
||||
|
|
24
src/registrar/templates/portfolio.html
Normal file
24
src/registrar/templates/portfolio.html
Normal file
|
@ -0,0 +1,24 @@
|
|||
{% extends 'home.html' %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
{% block homepage_content %}
|
||||
|
||||
<div class="tablet:grid-col-12">
|
||||
<div class="grid-row grid-gap">
|
||||
<div class="tablet:grid-col-3">
|
||||
{% include "portfolio_sidebar.html" with portfolio=portfolio %}
|
||||
</div>
|
||||
<div class="tablet:grid-col-9">
|
||||
{% block messages %}
|
||||
{% include "includes/form_messages.html" %}
|
||||
{% endblock %}
|
||||
{# Note: Reimplement commented out functionality #}
|
||||
|
||||
{% block portfolio_content %}
|
||||
{% endblock %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
8
src/registrar/templates/portfolio_domains.html
Normal file
8
src/registrar/templates/portfolio_domains.html
Normal file
|
@ -0,0 +1,8 @@
|
|||
{% extends 'portfolio.html' %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
{% block portfolio_content %}
|
||||
<h1>Domains</h1>
|
||||
{% include "includes/domains_table.html" with portfolio=portfolio %}
|
||||
{% endblock %}
|
21
src/registrar/templates/portfolio_requests.html
Normal file
21
src/registrar/templates/portfolio_requests.html
Normal file
|
@ -0,0 +1,21 @@
|
|||
{% extends 'portfolio.html' %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
{% block portfolio_content %}
|
||||
<h1>Domain requests</h1>
|
||||
|
||||
{% 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">
|
||||
<a href="{% url 'domain-request:' %}" class="usa-button"
|
||||
>
|
||||
Start a new domain request
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{% include "includes/domain_requests_table.html" with portfolio=portfolio %}
|
||||
{% endblock %}
|
37
src/registrar/templates/portfolio_sidebar.html
Normal file
37
src/registrar/templates/portfolio_sidebar.html
Normal file
|
@ -0,0 +1,37 @@
|
|||
{% load static url_helpers %}
|
||||
|
||||
<div class="margin-bottom-4 tablet:margin-bottom-0">
|
||||
<nav aria-label="">
|
||||
<h2 class="margin-top-0 text-semibold">{{ portfolio.organization_name }}</h2>
|
||||
<ul class="usa-sidenav">
|
||||
<li class="usa-sidenav__item">
|
||||
{% url 'portfolio-domains' portfolio.id as url %}
|
||||
<a href="{{ url }}" {% if request.path == url %}class="usa-current"{% endif %}>
|
||||
Domains
|
||||
</a>
|
||||
|
||||
</li>
|
||||
<li class="usa-sidenav__item">
|
||||
{% url 'portfolio-domain-requests' portfolio.id as url %}
|
||||
<a href="{{ url }}" {% if request.path == url %}class="usa-current"{% endif %}>
|
||||
Domain requests
|
||||
</a>
|
||||
</li>
|
||||
<li class="usa-sidenav__item">
|
||||
<a href="#">
|
||||
Members
|
||||
</a>
|
||||
</li>
|
||||
<li class="usa-sidenav__item">
|
||||
<a href="#">
|
||||
Organization
|
||||
</a>
|
||||
</li>
|
||||
<li class="usa-sidenav__item">
|
||||
<a href="#">
|
||||
Senior official
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
|
@ -5,6 +5,11 @@ Edit your User Profile |
|
|||
{% endblock title %}
|
||||
{% load static url_helpers %}
|
||||
|
||||
{# Disable the redirect #}
|
||||
{% block logo %}
|
||||
{% include "includes/gov_extended_logo.html" with logo_clickable=user_finished_setup %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main id="main-content" class="grid-container">
|
||||
<div class="grid-col desktop:grid-offset-2 desktop:grid-col-8">
|
||||
|
@ -20,21 +25,70 @@ Edit your User Profile |
|
|||
{% include "includes/form_errors.html" with form=form %}
|
||||
|
||||
{% if show_back_button %}
|
||||
<a href="{% if not return_to_request %}{% url 'home' %}{% else %}{% url 'domain-request:' %}{% endif %}" class="breadcrumb__back">
|
||||
<a href="{% url form.initial.redirect %}" class="breadcrumb__back">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
|
||||
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
|
||||
</svg>
|
||||
{% if not return_to_request %}
|
||||
<p class="margin-left-05 margin-top-0 margin-bottom-0 line-height-sans-1">
|
||||
{{ profile_back_button_text }}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="margin-left-05 margin-top-0 margin-bottom-0 line-height-sans-1">
|
||||
Go back to your domain request
|
||||
</p>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% 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 %}
|
||||
|
||||
|
@ -43,3 +97,7 @@ Edit your User Profile |
|
|||
</div>
|
||||
</main>
|
||||
{% endblock content_bottom %}
|
||||
|
||||
{% block footer %}
|
||||
{% include "includes/footer.html" with show_manage_your_domains=user_finished_setup %}
|
||||
{% endblock footer %}
|
||||
|
|
|
@ -1055,6 +1055,18 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
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
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.STARTED)
|
||||
|
||||
|
@ -1069,48 +1081,23 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
self.assertEqual(response.status_code, 200)
|
||||
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.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.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_reason = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS
|
||||
domain_request.save()
|
||||
|
||||
response = self.client.get(
|
||||
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
|
||||
follow=True,
|
||||
)
|
||||
# Let's just change the action needed reason
|
||||
domain_request.action_needed_reason = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR
|
||||
domain_request.save()
|
||||
|
||||
# Table will contain and extra row for Action needed
|
||||
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)
|
||||
self.assertContains(response, "<td>Action needed</td>", count=1)
|
||||
domain_request.reject()
|
||||
domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE
|
||||
domain_request.save()
|
||||
|
||||
domain_request.in_review()
|
||||
domain_request.save()
|
||||
|
@ -1120,24 +1107,28 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
follow=True,
|
||||
)
|
||||
|
||||
# Normalize the HTML response content
|
||||
normalized_content = " ".join(response.content.decode("utf-8").split())
|
||||
|
||||
# Define the expected sequence of status changes
|
||||
expected_status_changes = [
|
||||
"<td>In review</td>",
|
||||
"<td>Action needed</td>",
|
||||
"<td>In review</td>",
|
||||
"<td>Submitted</td>",
|
||||
"<td>Started</td>",
|
||||
"In review",
|
||||
"Rejected - Purpose requirements not met",
|
||||
"Action needed - Unclear organization eligibility",
|
||||
"Action needed - Already has domains",
|
||||
"In review",
|
||||
"Submitted",
|
||||
"Started",
|
||||
]
|
||||
|
||||
# Test for the order of status changes
|
||||
for status_change in expected_status_changes:
|
||||
self.assertContains(response, status_change, html=True)
|
||||
assert_status_order(normalized_content, expected_status_changes)
|
||||
|
||||
# Table now contains 2 rows for Approved
|
||||
self.assertContains(response, "<td>Started</td>", count=1)
|
||||
self.assertContains(response, "<td>Submitted</td>", count=1)
|
||||
self.assertContains(response, "<td>In review</td>", count=2)
|
||||
self.assertContains(response, "<td>Action needed</td>", count=1)
|
||||
assert_status_count(normalized_content, "Started", 1)
|
||||
assert_status_count(normalized_content, "Submitted", 1)
|
||||
assert_status_count(normalized_content, "In review", 2)
|
||||
assert_status_count(normalized_content, "Action needed - Already has domains", 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):
|
||||
"""
|
||||
|
@ -2300,6 +2291,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"rejection_reason",
|
||||
"action_needed_reason",
|
||||
"federal_agency",
|
||||
"portfolio",
|
||||
"creator",
|
||||
"investigator",
|
||||
"generic_org_type",
|
||||
|
|
|
@ -8,6 +8,7 @@ from django.utils.module_loading import import_string
|
|||
import logging
|
||||
import pyzipper
|
||||
from registrar.management.commands.clean_tables import Command as CleanTablesCommand
|
||||
from registrar.management.commands.export_tables import Command as ExportTablesCommand
|
||||
from registrar.models import (
|
||||
User,
|
||||
Domain,
|
||||
|
@ -874,84 +875,81 @@ class TestExportTables(MockEppLib):
|
|||
"""Test the export_tables script"""
|
||||
|
||||
def setUp(self):
|
||||
self.command = ExportTablesCommand()
|
||||
self.logger_patcher = patch("registrar.management.commands.export_tables.logger")
|
||||
self.logger_mock = self.logger_patcher.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.logger_patcher.stop()
|
||||
|
||||
@patch("registrar.management.commands.export_tables.os.makedirs")
|
||||
@patch("registrar.management.commands.export_tables.os.path.exists")
|
||||
@patch("registrar.management.commands.export_tables.os.remove")
|
||||
@patch("registrar.management.commands.export_tables.pyzipper.AESZipFile")
|
||||
@patch("os.makedirs")
|
||||
@patch("os.path.exists")
|
||||
@patch("os.remove")
|
||||
@patch("pyzipper.AESZipFile")
|
||||
@patch("registrar.management.commands.export_tables.getattr")
|
||||
@patch("builtins.open", new_callable=mock_open, read_data=b"mock_csv_data")
|
||||
@patch("django.utils.translation.trans_real._translations", {})
|
||||
@patch("django.utils.translation.trans_real.translation")
|
||||
@patch("builtins.open", new_callable=mock_open)
|
||||
@patch("os.listdir")
|
||||
def test_handle(
|
||||
self, mock_translation, mock_file, mock_getattr, mock_zipfile, mock_remove, mock_path_exists, mock_makedirs
|
||||
self, mock_listdir, mock_open, mock_getattr, mock_zipfile, mock_remove, mock_path_exists, mock_makedirs
|
||||
):
|
||||
"""test that the handle method properly exports tables"""
|
||||
with less_console_noise():
|
||||
# Mock os.makedirs to do nothing
|
||||
mock_makedirs.return_value = None
|
||||
# Mock os.makedirs to do nothing
|
||||
mock_makedirs.return_value = None
|
||||
|
||||
# Mock os.path.exists to always return True
|
||||
mock_path_exists.return_value = True
|
||||
# Mock os.path.exists to always return True
|
||||
mock_path_exists.return_value = True
|
||||
|
||||
# Mock the resource class and its export method
|
||||
mock_resource_class = MagicMock()
|
||||
mock_dataset = MagicMock()
|
||||
mock_dataset.csv = b"mock_csv_data"
|
||||
mock_resource_class().export.return_value = mock_dataset
|
||||
mock_getattr.return_value = mock_resource_class
|
||||
# Check that the export_table function was called for each table
|
||||
table_names = [
|
||||
"User",
|
||||
"Contact",
|
||||
"Domain",
|
||||
"DomainRequest",
|
||||
"DomainInformation",
|
||||
"FederalAgency",
|
||||
"UserDomainRole",
|
||||
"DraftDomain",
|
||||
"Website",
|
||||
"HostIp",
|
||||
"Host",
|
||||
"PublicContact",
|
||||
]
|
||||
|
||||
# Mock translation function to return a dummy translation object
|
||||
mock_translation.return_value = MagicMock()
|
||||
# Mock directory listing
|
||||
mock_listdir.side_effect = lambda path: [f"{table}_1.csv" for table in table_names]
|
||||
|
||||
call_command("export_tables")
|
||||
# Mock the resource class and its export method
|
||||
mock_dataset = tablib.Dataset()
|
||||
mock_dataset.headers = ["header1", "header2"]
|
||||
mock_dataset.append(["row1_col1", "row1_col2"])
|
||||
mock_resource_class = MagicMock()
|
||||
mock_resource_class().export.return_value = mock_dataset
|
||||
mock_getattr.return_value = mock_resource_class
|
||||
|
||||
# Check that os.makedirs was called once to create the tmp directory
|
||||
mock_makedirs.assert_called_once_with("tmp", exist_ok=True)
|
||||
command_instance = ExportTablesCommand()
|
||||
command_instance.handle()
|
||||
|
||||
# Check that the export_table function was called for each table
|
||||
table_names = [
|
||||
"User",
|
||||
"Contact",
|
||||
"Domain",
|
||||
"DomainRequest",
|
||||
"DomainInformation",
|
||||
"UserDomainRole",
|
||||
"DraftDomain",
|
||||
"Website",
|
||||
"HostIp",
|
||||
"Host",
|
||||
"PublicContact",
|
||||
]
|
||||
# Check that os.makedirs was called once to create the tmp directory
|
||||
mock_makedirs.assert_called_once_with("tmp", exist_ok=True)
|
||||
|
||||
# Check that the CSV file was written
|
||||
for table_name in table_names:
|
||||
mock_file().write.assert_any_call(b"mock_csv_data")
|
||||
# Check that os.path.exists was called
|
||||
mock_path_exists.assert_any_call(f"tmp/{table_name}.csv")
|
||||
# Check that os.remove was called
|
||||
mock_remove.assert_any_call(f"tmp/{table_name}.csv")
|
||||
# Check that the CSV file was written
|
||||
for table_name in table_names:
|
||||
# Check that os.remove was called
|
||||
mock_remove.assert_any_call(f"tmp/{table_name}_1.csv")
|
||||
|
||||
# Check that the zipfile was created and files were added
|
||||
mock_zipfile.assert_called_once_with("tmp/exported_tables.zip", "w", compression=pyzipper.ZIP_DEFLATED)
|
||||
zipfile_instance = mock_zipfile.return_value.__enter__.return_value
|
||||
for table_name in table_names:
|
||||
zipfile_instance.write.assert_any_call(f"tmp/{table_name}.csv", f"{table_name}.csv")
|
||||
# Check that the zipfile was created and files were added
|
||||
mock_zipfile.assert_called_once_with("tmp/exported_tables.zip", "w", compression=pyzipper.ZIP_DEFLATED)
|
||||
zipfile_instance = mock_zipfile.return_value.__enter__.return_value
|
||||
for table_name in table_names:
|
||||
zipfile_instance.write.assert_any_call(f"tmp/{table_name}_1.csv", f"{table_name}_1.csv")
|
||||
|
||||
# Verify logging for added files
|
||||
for table_name in table_names:
|
||||
self.logger_mock.info.assert_any_call(
|
||||
f"Added tmp/{table_name}.csv to zip archive tmp/exported_tables.zip"
|
||||
)
|
||||
# Verify logging for added files
|
||||
for table_name in table_names:
|
||||
self.logger_mock.info.assert_any_call(f"Added {table_name}_1.csv to tmp/exported_files.zip")
|
||||
|
||||
# Verify logging for removed files
|
||||
for table_name in table_names:
|
||||
self.logger_mock.info.assert_any_call(f"Removed temporary file tmp/{table_name}.csv")
|
||||
# Verify logging for removed files
|
||||
for table_name in table_names:
|
||||
self.logger_mock.info.assert_any_call(f"Removed {table_name}_1.csv")
|
||||
|
||||
@patch("registrar.management.commands.export_tables.getattr")
|
||||
def test_export_table_handles_missing_resource_class(self, mock_getattr):
|
||||
|
@ -996,8 +994,10 @@ class TestImportTables(TestCase):
|
|||
@patch("registrar.management.commands.import_tables.logger")
|
||||
@patch("registrar.management.commands.import_tables.getattr")
|
||||
@patch("django.apps.apps.get_model")
|
||||
@patch("os.listdir")
|
||||
def test_handle(
|
||||
self,
|
||||
mock_listdir,
|
||||
mock_get_model,
|
||||
mock_getattr,
|
||||
mock_logger,
|
||||
|
@ -1020,6 +1020,24 @@ class TestImportTables(TestCase):
|
|||
mock_zipfile_instance = mock_zipfile.return_value.__enter__.return_value
|
||||
mock_zipfile_instance.extractall.return_value = None
|
||||
|
||||
# Check that the import_table function was called for each table
|
||||
table_names = [
|
||||
"User",
|
||||
"Contact",
|
||||
"Domain",
|
||||
"DomainRequest",
|
||||
"DomainInformation",
|
||||
"UserDomainRole",
|
||||
"DraftDomain",
|
||||
"Website",
|
||||
"HostIp",
|
||||
"Host",
|
||||
"PublicContact",
|
||||
]
|
||||
|
||||
# Mock directory listing
|
||||
mock_listdir.side_effect = lambda path: [f"{table}_1.csv" for table in table_names]
|
||||
|
||||
# Mock the CSV file content
|
||||
csv_content = b"mock_csv_data"
|
||||
|
||||
|
@ -1055,23 +1073,9 @@ class TestImportTables(TestCase):
|
|||
# Check that extractall was called once to extract the zip file contents
|
||||
mock_zipfile_instance.extractall.assert_called_once_with("tmp")
|
||||
|
||||
# Check that the import_table function was called for each table
|
||||
table_names = [
|
||||
"User",
|
||||
"Contact",
|
||||
"Domain",
|
||||
"DomainRequest",
|
||||
"DomainInformation",
|
||||
"UserDomainRole",
|
||||
"DraftDomain",
|
||||
"Website",
|
||||
"HostIp",
|
||||
"Host",
|
||||
"PublicContact",
|
||||
]
|
||||
# Check that os.path.exists was called for each table
|
||||
for table_name in table_names:
|
||||
mock_path_exists.assert_any_call(f"tmp/{table_name}.csv")
|
||||
mock_path_exists.assert_any_call(f"{table_name}_1.csv")
|
||||
|
||||
# Check that clean_tables is called for Contact
|
||||
mock_get_model.assert_any_call("registrar", "Contact")
|
||||
|
@ -1080,18 +1084,18 @@ class TestImportTables(TestCase):
|
|||
|
||||
# Check that logger.info was called for each successful import
|
||||
for table_name in table_names:
|
||||
mock_logger.info.assert_any_call(f"Successfully imported tmp/{table_name}.csv into {table_name}")
|
||||
mock_logger.info.assert_any_call(f"Successfully imported {table_name}_1.csv into {table_name}")
|
||||
|
||||
# Check that logger.error was not called for resource class not found
|
||||
mock_logger.error.assert_not_called()
|
||||
|
||||
# Check that os.remove was called for each CSV file
|
||||
for table_name in table_names:
|
||||
mock_remove.assert_any_call(f"tmp/{table_name}.csv")
|
||||
mock_remove.assert_any_call(f"{table_name}_1.csv")
|
||||
|
||||
# Check that logger.info was called for each CSV file removal
|
||||
for table_name in table_names:
|
||||
mock_logger.info.assert_any_call(f"Removed temporary file tmp/{table_name}.csv")
|
||||
mock_logger.info.assert_any_call(f"Removed temporary file {table_name}_1.csv")
|
||||
|
||||
@patch("registrar.management.commands.import_tables.logger")
|
||||
@patch("registrar.management.commands.import_tables.os.makedirs")
|
||||
|
|
|
@ -3,6 +3,8 @@ from django.db.utils import IntegrityError
|
|||
from unittest.mock import patch
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from django.test import RequestFactory
|
||||
|
||||
from registrar.models import (
|
||||
Contact,
|
||||
DomainRequest,
|
||||
|
@ -1610,6 +1612,7 @@ class TestDomainInformationCustomSave(TestCase):
|
|||
class TestDomainRequestIncomplete(TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.factory = RequestFactory()
|
||||
username = "test_user"
|
||||
first_name = "First"
|
||||
last_name = "Last"
|
||||
|
@ -2013,7 +2016,10 @@ class TestDomainRequestIncomplete(TestCase):
|
|||
self.assertFalse(self.domain_request._is_policy_acknowledgement_complete())
|
||||
|
||||
def test_form_complete(self):
|
||||
self.assertTrue(self.domain_request._form_complete())
|
||||
request = self.factory.get("/")
|
||||
request.user = self.user
|
||||
|
||||
self.assertTrue(self.domain_request._form_complete(request))
|
||||
self.domain_request.generic_org_type = None
|
||||
self.domain_request.save()
|
||||
self.assertFalse(self.domain_request._form_complete())
|
||||
self.assertFalse(self.domain_request._form_complete(request))
|
||||
|
|
|
@ -1901,12 +1901,8 @@ class TestRegistrantDNSSEC(MockEppLib):
|
|||
3 - setter adds the UpdateDNSSECExtension extension to the command
|
||||
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
|
||||
|
||||
"""
|
||||
|
||||
# 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):
|
||||
if isinstance(_request, commands.InfoDomain):
|
||||
if mocked_send.call_count == 1:
|
||||
|
@ -1924,17 +1920,30 @@ class TestRegistrantDNSSEC(MockEppLib):
|
|||
mocked_send = patcher.start()
|
||||
mocked_send.side_effect = side_effect
|
||||
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
|
||||
# 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
|
||||
# args[0] is the _request sent to registry
|
||||
args, _ = mocked_send.call_args
|
||||
# assert that the extension on the update matches
|
||||
# Assert that the extension on the update matches
|
||||
self.assertEquals(
|
||||
args[0].extensions[0],
|
||||
self.createUpdateExtension(self.dnssecExtensionWithDsData),
|
||||
)
|
||||
# test that the dnssecdata getter is functioning properly
|
||||
|
||||
# Test that the dnssecdata getter is functioning properly
|
||||
dnssecdata_get = domain.dnssecdata
|
||||
mocked_send.assert_has_calls(
|
||||
[
|
||||
|
@ -2129,13 +2138,9 @@ class TestRegistrantDNSSEC(MockEppLib):
|
|||
2 - first setter calls UpdateDomain command
|
||||
3 - second setter calls InfoDomain command again
|
||||
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):
|
||||
if isinstance(_request, commands.InfoDomain):
|
||||
if mocked_send.call_count == 1:
|
||||
|
@ -2153,10 +2158,25 @@ class TestRegistrantDNSSEC(MockEppLib):
|
|||
mocked_send = patcher.start()
|
||||
mocked_send.side_effect = side_effect
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
# verify that it is properly sent
|
||||
# args[0] is the _request sent to registry
|
||||
|
|
|
@ -24,6 +24,7 @@ SAMPLE_KWARGS = {
|
|||
"object_id": "3",
|
||||
"domain": "whitehouse.gov",
|
||||
"user_pk": "1",
|
||||
"portfolio_id": "1",
|
||||
}
|
||||
|
||||
# Our test suite will ignore some namespaces.
|
||||
|
|
|
@ -8,6 +8,7 @@ from api.tests.common import less_console_noise_decorator
|
|||
from registrar.models.contact import Contact
|
||||
from registrar.models.domain import Domain
|
||||
from registrar.models.draft_domain import DraftDomain
|
||||
from registrar.models.portfolio import Portfolio
|
||||
from registrar.models.public_contact import PublicContact
|
||||
from registrar.models.user import User
|
||||
from registrar.models.user_domain_role import UserDomainRole
|
||||
|
@ -63,11 +64,24 @@ class TestWithUser(MockEppLib):
|
|||
self.user.contact.title = title
|
||||
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"
|
||||
email_2 = "unicorn@igorville.com"
|
||||
self.incomplete_user = get_user_model().objects.create(
|
||||
username=username_incomplete, first_name=first_name_2, email=email_2
|
||||
# in the case below, REGULAR user is 'Verified by Login.gov, ie. IAL2
|
||||
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):
|
||||
|
@ -75,8 +89,7 @@ class TestWithUser(MockEppLib):
|
|||
super().tearDown()
|
||||
DomainRequest.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
self.user.delete()
|
||||
self.incomplete_user.delete()
|
||||
User.objects.all().delete()
|
||||
|
||||
|
||||
class TestEnvironmentVariablesEffects(TestCase):
|
||||
|
@ -384,15 +397,15 @@ class HomeTests(TestWithUser):
|
|||
)
|
||||
domain_request_2.other_contacts.set([contact_shared])
|
||||
|
||||
# Ensure that igorville.gov exists on the page
|
||||
home_page = self.client.get("/")
|
||||
self.assertContains(home_page, "igorville.gov")
|
||||
igorville = DomainRequest.objects.filter(requested_domain__name="igorville.gov")
|
||||
self.assertTrue(igorville.exists())
|
||||
|
||||
# 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
|
||||
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
|
||||
orphan = Contact.objects.filter(id=contact.id)
|
||||
|
@ -456,13 +469,14 @@ class HomeTests(TestWithUser):
|
|||
)
|
||||
domain_request_2.other_contacts.set([contact_shared])
|
||||
|
||||
home_page = self.client.get("/")
|
||||
self.assertContains(home_page, "teaville.gov")
|
||||
teaville = DomainRequest.objects.filter(requested_domain__name="teaville.gov")
|
||||
self.assertTrue(teaville.exists())
|
||||
|
||||
# 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
|
||||
orphan = Contact.objects.filter(id=contact_shared.id)
|
||||
|
@ -517,15 +531,18 @@ class FinishUserProfileTests(TestWithUser, WebTest):
|
|||
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()
|
||||
def _submit_form_webtest(self, form, follow=False, name=None):
|
||||
if name:
|
||||
page = form.submit(name=name)
|
||||
else:
|
||||
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"""
|
||||
self.app.set_user(self.incomplete_user.username)
|
||||
self.app.set_user(self.incomplete_regular_user.username)
|
||||
with override_flag("profile_feature", active=True):
|
||||
# This will redirect the user to the setup page.
|
||||
# Follow implicity checks if our redirect is working.
|
||||
|
@ -564,7 +581,7 @@ class FinishUserProfileTests(TestWithUser, WebTest):
|
|||
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"""
|
||||
|
||||
self.app.set_user(self.incomplete_user.username)
|
||||
self.app.set_user(self.incomplete_regular_user.username)
|
||||
with override_flag("profile_feature", active=True):
|
||||
# This will redirect the user to the setup page
|
||||
finish_setup_page = self.app.get(reverse("domain-request:")).follow()
|
||||
|
@ -592,6 +609,15 @@ class FinishUserProfileTests(TestWithUser, WebTest):
|
|||
|
||||
self.assertEqual(completed_setup_page.status_code, 200)
|
||||
|
||||
finish_setup_form = completed_setup_page.form
|
||||
|
||||
# Submit the form using the specific submit button to execute the redirect
|
||||
completed_setup_page = self._submit_form_webtest(
|
||||
finish_setup_form, follow=True, name="contact_setup_submit_button"
|
||||
)
|
||||
self.assertEqual(completed_setup_page.status_code, 200)
|
||||
|
||||
# Assert that we are still on the
|
||||
# Assert that we're on the domain request page
|
||||
self.assertNotContains(completed_setup_page, "Finish setting up your profile")
|
||||
self.assertNotContains(completed_setup_page, "What contact information should we use to reach you?")
|
||||
|
@ -618,6 +644,105 @@ class FinishUserProfileTests(TestWithUser, WebTest):
|
|||
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()
|
||||
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):
|
||||
"""A series of tests that target your profile functionality"""
|
||||
|
||||
|
@ -709,7 +834,7 @@ class UserProfileTests(TestWithUser, WebTest):
|
|||
"""tests user profile when profile_feature is on,
|
||||
and when they are redirected from the domain request page"""
|
||||
with override_flag("profile_feature", active=True):
|
||||
response = self.client.get("/user-profile?return_to_request=True")
|
||||
response = self.client.get("/user-profile?redirect=domain-request:")
|
||||
self.assertContains(response, "Your profile")
|
||||
self.assertContains(response, "Go back to your domain request")
|
||||
self.assertNotContains(response, "Back to manage your domains")
|
||||
|
@ -793,3 +918,77 @@ class UserProfileTests(TestWithUser, WebTest):
|
|||
profile_page = profile_page.follow()
|
||||
self.assertEqual(profile_page.status_code, 200)
|
||||
self.assertContains(profile_page, "Your profile has been updated")
|
||||
|
||||
|
||||
class PortfoliosTests(TestWithUser, WebTest):
|
||||
"""A series of tests that target the organizations"""
|
||||
|
||||
# csrf checks do not work well with WebTest.
|
||||
# We disable them here.
|
||||
csrf_checks = False
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
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
|
||||
)
|
||||
self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="xyz inc")
|
||||
|
||||
def tearDown(self):
|
||||
Portfolio.objects.all().delete()
|
||||
super().tearDown()
|
||||
PublicContact.objects.filter(domain=self.domain).delete()
|
||||
UserDomainRole.objects.all().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)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_middleware_redirects_to_portfolio_homepage(self):
|
||||
"""Tests that a user is redirected to the portfolio homepage when organization_feature is on and
|
||||
a portfolio belongs to the user, test for the special h1s which only exist in that version
|
||||
of the homepage"""
|
||||
self.app.set_user(self.user.username)
|
||||
with override_flag("organization_feature", active=True):
|
||||
# This will redirect the user to the portfolio page.
|
||||
# Follow implicity checks if our redirect is working.
|
||||
portfolio_page = self.app.get(reverse("home")).follow()
|
||||
self._set_session_cookie()
|
||||
|
||||
# Assert that we're on the right page
|
||||
self.assertContains(portfolio_page, self.portfolio.organization_name)
|
||||
|
||||
self.assertContains(portfolio_page, "<h1>Domains</h1>")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_no_redirect_when_org_flag_false(self):
|
||||
"""No redirect so no follow,
|
||||
implicitely test for the presense of the h2 by looking up its id"""
|
||||
self.app.set_user(self.user.username)
|
||||
home_page = self.app.get(reverse("home"))
|
||||
self._set_session_cookie()
|
||||
|
||||
self.assertNotContains(home_page, self.portfolio.organization_name)
|
||||
|
||||
self.assertContains(home_page, 'id="domain-requests-header"')
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_no_redirect_when_user_has_no_portfolios(self):
|
||||
"""No redirect so no follow,
|
||||
implicitely test for the presense of the h2 by looking up its id"""
|
||||
self.portfolio.delete()
|
||||
self.app.set_user(self.user.username)
|
||||
with override_flag("organization_feature", active=True):
|
||||
home_page = self.app.get(reverse("home"))
|
||||
self._set_session_cookie()
|
||||
|
||||
self.assertNotContains(home_page, self.portfolio.organization_name)
|
||||
|
||||
self.assertContains(home_page, 'id="domain-requests-header"')
|
||||
|
|
|
@ -102,6 +102,35 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
|
|||
)
|
||||
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):
|
||||
"""Test that pagination is correct in the response"""
|
||||
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")
|
||||
|
||||
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
|
||||
def test_domain_request_form_submission(self):
|
||||
"""
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
from registrar.models import DomainRequest
|
||||
from django.urls import reverse
|
||||
|
||||
from registrar.models.draft_domain import DraftDomain
|
||||
from .test_views import TestWithUser
|
||||
from django_webtest import WebTest # type: ignore
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
@ -10,32 +12,37 @@ class GetRequestsJsonTest(TestWithUser, WebTest):
|
|||
super().setUp()
|
||||
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
|
||||
self.domain_requests = [
|
||||
DomainRequest.objects.create(
|
||||
creator=self.user,
|
||||
requested_domain=None,
|
||||
requested_domain=lamb_chops,
|
||||
submission_date="2024-01-01",
|
||||
status=DomainRequest.DomainRequestStatus.STARTED,
|
||||
created_at="2024-01-01",
|
||||
),
|
||||
DomainRequest.objects.create(
|
||||
creator=self.user,
|
||||
requested_domain=None,
|
||||
requested_domain=short_ribs,
|
||||
submission_date="2024-02-01",
|
||||
status=DomainRequest.DomainRequestStatus.WITHDRAWN,
|
||||
created_at="2024-02-01",
|
||||
),
|
||||
DomainRequest.objects.create(
|
||||
creator=self.user,
|
||||
requested_domain=None,
|
||||
requested_domain=beef_chuck,
|
||||
submission_date="2024-03-01",
|
||||
status=DomainRequest.DomainRequestStatus.REJECTED,
|
||||
created_at="2024-03-01",
|
||||
),
|
||||
DomainRequest.objects.create(
|
||||
creator=self.user,
|
||||
requested_domain=None,
|
||||
requested_domain=stew_beef,
|
||||
submission_date="2024-04-01",
|
||||
status=DomainRequest.DomainRequestStatus.STARTED,
|
||||
created_at="2024-04-01",
|
||||
|
@ -195,6 +202,61 @@ class GetRequestsJsonTest(TestWithUser, WebTest):
|
|||
)
|
||||
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):
|
||||
"""Test that pagination works properly. There are 11 total non-approved requests and
|
||||
a page size of 10"""
|
||||
|
|
|
@ -219,22 +219,23 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
self.storage["domain_request_id"] = kwargs["id"]
|
||||
self.storage["step_history"] = self.db_check_for_unlocking_steps()
|
||||
|
||||
# if accessing this class directly, redirect to the first step
|
||||
# in other words, if `DomainRequestWizard` is called as view
|
||||
# directly by some redirect or url handler, we'll send users
|
||||
# either to an acknowledgement page or to the first step in
|
||||
# the processes (if an edit rather than a new request); subclasses
|
||||
# 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 accessing this class directly, redirect to either to an acknowledgement
|
||||
# page or to the first step in the processes (if an edit rather than a new request);
|
||||
# subclasseswill 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.
|
||||
if self.__class__ == DomainRequestWizard:
|
||||
if request.path_info == self.NEW_URL_NAME:
|
||||
context = self.get_context_data()
|
||||
return render(request, "domain_request_intro.html", context=context)
|
||||
# Clear context so the prop getter won't create a request here.
|
||||
# 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:
|
||||
return self.goto(self.steps.first)
|
||||
|
||||
context = self.get_context_data()
|
||||
self.steps.current = current_url
|
||||
context["forms"] = self.get_forms()
|
||||
|
||||
|
@ -382,7 +383,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
has_profile_flag = flag_is_active(self.request, "profile_feature")
|
||||
|
||||
context_stuff = {}
|
||||
if DomainRequest._form_complete(self.domain_request):
|
||||
if DomainRequest._form_complete(self.domain_request, self.request):
|
||||
modal_button = '<button type="submit" ' 'class="usa-button" ' ">Submit request</button>"
|
||||
context_stuff = {
|
||||
"not_form": False,
|
||||
|
@ -434,6 +435,10 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
return step_list
|
||||
|
||||
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
|
||||
return redirect(reverse(f"{self.URL_NAMESPACE}:{step}"))
|
||||
|
||||
|
@ -456,11 +461,17 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
|
||||
# which button did the user press?
|
||||
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 button == "intro_acknowledge":
|
||||
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)
|
||||
|
||||
# if accessing this class directly, redirect to the first step
|
||||
|
@ -684,7 +695,7 @@ class Review(DomainRequestWizard):
|
|||
forms = [] # type: ignore
|
||||
|
||||
def get_context_data(self):
|
||||
if DomainRequest._form_complete(self.domain_request) is False:
|
||||
if DomainRequest._form_complete(self.domain_request, self.request) is False:
|
||||
logger.warning("User arrived at review page with an incomplete form.")
|
||||
context = super().get_context_data()
|
||||
context["Step"] = Step.__members__
|
||||
|
@ -798,7 +809,8 @@ class DomainRequestDeleteView(DomainRequestPermissionDeleteView):
|
|||
contacts_to_delete, duplicates = self._get_orphaned_contacts(domain_request)
|
||||
|
||||
# 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
|
||||
Contact.objects.filter(id__in=contacts_to_delete, user=None).delete()
|
||||
|
@ -810,7 +822,8 @@ class DomainRequestDeleteView(DomainRequestPermissionDeleteView):
|
|||
duplicates_to_delete, _ = self._get_orphaned_contacts(domain_request, check_db=True)
|
||||
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):
|
||||
"""
|
||||
|
|
|
@ -4,6 +4,7 @@ from registrar.models import DomainRequest
|
|||
from django.utils.dateformat import format
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.urls import reverse
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
@login_required
|
||||
|
@ -14,9 +15,27 @@ def get_domain_requests_json(request):
|
|||
domain_requests = DomainRequest.objects.filter(creator=request.user).exclude(
|
||||
status=DomainRequest.DomainRequestStatus.APPROVED
|
||||
)
|
||||
unfiltered_total = domain_requests.count()
|
||||
|
||||
# Handle sorting
|
||||
sort_by = request.GET.get("sort_by", "id") # Default to 'id'
|
||||
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":
|
||||
sort_by = f"-{sort_by}"
|
||||
domain_requests = domain_requests.order_by(sort_by)
|
||||
|
@ -75,5 +94,6 @@ def get_domain_requests_json(request):
|
|||
"page": page_obj.number,
|
||||
"num_pages": paginator.num_pages,
|
||||
"total": paginator.count,
|
||||
"unfiltered_total": unfiltered_total,
|
||||
}
|
||||
)
|
||||
|
|
|
@ -3,6 +3,7 @@ from django.core.paginator import Paginator
|
|||
from registrar.models import UserDomainRole, Domain
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.urls import reverse
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
@login_required
|
||||
|
@ -14,10 +15,15 @@ def get_domains_json(request):
|
|||
domain_ids = user_domain_roles.values_list("domain_id", flat=True)
|
||||
|
||||
objects = Domain.objects.filter(id__in=domain_ids)
|
||||
unfiltered_total = objects.count()
|
||||
|
||||
# Handle sorting
|
||||
sort_by = request.GET.get("sort_by", "id") # Default to 'id'
|
||||
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":
|
||||
# Fetch the objects and sort them in Python
|
||||
|
@ -56,5 +62,6 @@ def get_domains_json(request):
|
|||
"has_previous": page_obj.has_previous(),
|
||||
"has_next": page_obj.has_next(),
|
||||
"total": paginator.count,
|
||||
"unfiltered_total": unfiltered_total,
|
||||
}
|
||||
)
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
from django.shortcuts import render
|
||||
from registrar.models import DomainRequest
|
||||
from waffle.decorators import flag_is_active
|
||||
|
||||
|
||||
|
@ -8,46 +7,11 @@ def index(request):
|
|||
context = {}
|
||||
|
||||
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
|
||||
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
|
||||
context["has_organization_feature_flag"] = flag_is_active(request, "organization_feature")
|
||||
|
||||
# If they can delete domain requests, add the delete button to the context
|
||||
if has_deletable_domain_requests:
|
||||
# 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
|
||||
# This controls the creation of a new domain request in the wizard
|
||||
request.session["new_request"] = True
|
||||
|
||||
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)
|
||||
|
|
39
src/registrar/views/portfolios.py
Normal file
39
src/registrar/views/portfolios.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
from django.shortcuts import get_object_or_404, render
|
||||
from registrar.models.portfolio import Portfolio
|
||||
from waffle.decorators import flag_is_active
|
||||
from django.contrib.auth.decorators import login_required
|
||||
|
||||
|
||||
@login_required
|
||||
def portfolio_domains(request, portfolio_id):
|
||||
context = {}
|
||||
|
||||
if request.user.is_authenticated:
|
||||
# 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_organization_feature_flag"] = flag_is_active(request, "organization_feature")
|
||||
|
||||
# Retrieve the portfolio object based on the provided portfolio_id
|
||||
portfolio = get_object_or_404(Portfolio, id=portfolio_id)
|
||||
context["portfolio"] = portfolio
|
||||
|
||||
return render(request, "portfolio_domains.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def portfolio_domain_requests(request, portfolio_id):
|
||||
context = {}
|
||||
|
||||
if request.user.is_authenticated:
|
||||
# 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_organization_feature_flag"] = flag_is_active(request, "organization_feature")
|
||||
|
||||
# Retrieve the portfolio object based on the provided portfolio_id
|
||||
portfolio = get_object_or_404(Portfolio, id=portfolio_id)
|
||||
context["portfolio"] = portfolio
|
||||
|
||||
# This controls the creation of a new domain request in the wizard
|
||||
request.session["new_request"] = True
|
||||
|
||||
return render(request, "portfolio_requests.html", context)
|
|
@ -2,26 +2,21 @@
|
|||
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
import logging
|
||||
from urllib.parse import parse_qs, unquote
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.contrib import messages
|
||||
from django.http import QueryDict
|
||||
from django.views.generic.edit import FormMixin
|
||||
from registrar.forms.user_profile import UserProfileForm, FinishSetupProfileForm
|
||||
from django.urls import NoReverseMatch, reverse
|
||||
from registrar.models import (
|
||||
Contact,
|
||||
)
|
||||
from registrar.models.user import User
|
||||
from registrar.models.utility.generic_helper import replace_url_queryparams
|
||||
from registrar.views.utility.permission_views import UserProfilePermissionView
|
||||
from waffle.decorators import flag_is_active, waffle_flag
|
||||
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_protect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
@ -33,30 +28,30 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
|
|||
model = Contact
|
||||
template_name = "profile.html"
|
||||
form_class = UserProfileForm
|
||||
base_view_name = "user-profile"
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Handle get requests by getting user's contact object and setting object
|
||||
and form to context before rendering."""
|
||||
self._refresh_session_and_object(request)
|
||||
form = self.form_class(instance=self.object)
|
||||
context = self.get_context_data(object=self.object, form=form)
|
||||
self.object = self.get_object()
|
||||
|
||||
return_to_request = request.GET.get("return_to_request")
|
||||
if return_to_request:
|
||||
context["return_to_request"] = True
|
||||
# Get the redirect parameter from the query string
|
||||
redirect = request.GET.get("redirect", "home")
|
||||
|
||||
form = self.form_class(instance=self.object, initial={"redirect": redirect})
|
||||
context = self.get_context_data(object=self.object, form=form, redirect=redirect)
|
||||
|
||||
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 self.render_to_response(context)
|
||||
|
||||
def _refresh_session_and_object(self, request):
|
||||
"""Sets the current session to self.session and the current object to self.object"""
|
||||
self.session = request.session
|
||||
self.object = self.get_object()
|
||||
|
||||
@waffle_flag("profile_feature") # type: ignore
|
||||
def dispatch(self, request, *args, **kwargs): # type: ignore
|
||||
# Store the original queryparams to persist them
|
||||
query_params = request.META["QUERY_STRING"]
|
||||
request.session["query_params"] = query_params
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
@ -65,28 +60,44 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
|
|||
# This is a django waffle flag which toggles features based off of the "flag" table
|
||||
context["has_profile_feature_flag"] = flag_is_active(self.request, "profile_feature")
|
||||
|
||||
# The text for the back button on this page
|
||||
context["profile_back_button_text"] = "Go to manage your domains"
|
||||
context["show_back_button"] = True
|
||||
# Set the profile_back_button_text based on the redirect parameter
|
||||
if kwargs.get("redirect") == "domain-request:":
|
||||
context["profile_back_button_text"] = "Go back to your domain request"
|
||||
else:
|
||||
context["profile_back_button_text"] = "Go to manage your domains"
|
||||
|
||||
# Show back button conditional on user having finished setup
|
||||
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
|
||||
|
||||
def get_success_url(self):
|
||||
"""Redirect to the user's profile page."""
|
||||
"""Redirect to the user's profile page with updated query parameters."""
|
||||
|
||||
query_params = {}
|
||||
if "query_params" in self.session:
|
||||
params = unquote(self.session["query_params"])
|
||||
query_params = parse_qs(params)
|
||||
# Get the redirect parameter from the form submission
|
||||
redirect_param = self.request.POST.get("redirect", None)
|
||||
|
||||
# Preserve queryparams and add them back to the url
|
||||
base_url = reverse("user-profile")
|
||||
new_redirect = replace_url_queryparams(base_url, query_params, convert_list_to_csv=True)
|
||||
return new_redirect
|
||||
# Initialize QueryDict with existing query parameters from current request
|
||||
query_params = QueryDict(mutable=True)
|
||||
query_params.update(self.request.GET)
|
||||
|
||||
# Update query parameters with the 'redirect' value from form submission
|
||||
if redirect_param and redirect_param != "home":
|
||||
query_params["redirect"] = redirect_param
|
||||
|
||||
# Generate the URL with updated query parameters
|
||||
base_url = reverse(self.base_view_name)
|
||||
|
||||
# Generate the full url from the given query params
|
||||
full_url = replace_url_queryparams(base_url, query_params)
|
||||
return full_url
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Handle post requests (form submissions)"""
|
||||
self._refresh_session_and_object(request)
|
||||
self.object = self.get_object()
|
||||
form = self.form_class(request.POST, instance=self.object)
|
||||
|
||||
if form.is_valid():
|
||||
|
@ -94,6 +105,12 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
|
|||
else:
|
||||
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):
|
||||
"""Handle successful and valid form submissions."""
|
||||
form.save()
|
||||
|
@ -105,9 +122,9 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
|
|||
|
||||
def get_object(self, queryset=None):
|
||||
"""Override get_object to return the logged-in user's contact"""
|
||||
user = self.request.user # get the logged in user
|
||||
if hasattr(user, "contact"): # Check if the user has a contact instance
|
||||
return user.contact
|
||||
self.user = self.request.user # get the logged in user
|
||||
if hasattr(self.user, "contact"): # Check if the user has a contact instance
|
||||
return self.user.contact
|
||||
return None
|
||||
|
||||
|
||||
|
@ -115,139 +132,53 @@ class FinishProfileSetupView(UserProfileView):
|
|||
"""This view forces the user into providing additional details that
|
||||
we may have missed from Login.gov"""
|
||||
|
||||
class RedirectType(Enum):
|
||||
"""
|
||||
Enums for each type of redirection. Enforces behaviour on `get_redirect_url()`.
|
||||
|
||||
- HOME: We want to redirect to reverse("home")
|
||||
- BACK_TO_SELF: We want to redirect back to this page
|
||||
- TO_SPECIFIC_PAGE: We want to redirect to the page specified in the queryparam "redirect"
|
||||
- COMPLETE_SETUP: Indicates that we want to navigate BACK_TO_SELF, but the subsequent
|
||||
redirect after the next POST should be either HOME or TO_SPECIFIC_PAGE
|
||||
"""
|
||||
|
||||
HOME = "home"
|
||||
TO_SPECIFIC_PAGE = "domain_request"
|
||||
BACK_TO_SELF = "back_to_self"
|
||||
COMPLETE_SETUP = "complete_setup"
|
||||
|
||||
@classmethod
|
||||
def get_all_redirect_types(cls) -> list[str]:
|
||||
"""Returns the value of every redirect type defined in this enum."""
|
||||
return [r.value for r in cls]
|
||||
|
||||
template_name = "finish_profile_setup.html"
|
||||
form_class = FinishSetupProfileForm
|
||||
model = Contact
|
||||
|
||||
all_redirect_types = RedirectType.get_all_redirect_types()
|
||||
redirect_type: RedirectType
|
||||
base_view_name = "finish-user-profile-setup"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
||||
"""Extend get_context_data to include has_profile_feature_flag"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
# Hide the back button by default
|
||||
# Show back button conditional on user having finished setup
|
||||
context["show_back_button"] = False
|
||||
|
||||
if self.redirect_type == self.RedirectType.COMPLETE_SETUP:
|
||||
context["confirm_changes"] = True
|
||||
|
||||
if "redirect_viewname" not in self.session:
|
||||
if hasattr(self.user, "finished_setup") and self.user.finished_setup:
|
||||
if kwargs.get("redirect") == "home":
|
||||
context["show_back_button"] = True
|
||||
else:
|
||||
context["going_to_specific_page"] = True
|
||||
context["redirect_button_text"] = "Continue to your request"
|
||||
|
||||
return context
|
||||
|
||||
@method_decorator(csrf_protect)
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
"""
|
||||
Handles dispatching of the view, applying CSRF protection and checking the 'profile_feature' flag.
|
||||
|
||||
This method sets the redirect type based on the 'redirect' query parameter,
|
||||
defaulting to BACK_TO_SELF if not provided.
|
||||
It updates the session with the redirect view name if the redirect type is TO_SPECIFIC_PAGE.
|
||||
|
||||
Returns:
|
||||
HttpResponse: The response generated by the parent class's dispatch method.
|
||||
"""
|
||||
|
||||
# Update redirect type based on the query parameter if present
|
||||
default_redirect_value = self.RedirectType.BACK_TO_SELF.value
|
||||
redirect_value = request.GET.get("redirect", default_redirect_value)
|
||||
|
||||
if redirect_value in self.all_redirect_types:
|
||||
# If the redirect value is a preexisting value in our enum, set it to that.
|
||||
self.redirect_type = self.RedirectType(redirect_value)
|
||||
else:
|
||||
# If the redirect type is undefined, then we assume that we are specifying a particular page to redirect to.
|
||||
self.redirect_type = self.RedirectType.TO_SPECIFIC_PAGE
|
||||
|
||||
# Store the page that we want to redirect to for later use
|
||||
request.session["redirect_viewname"] = str(redirect_value)
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Form submission posts to this view."""
|
||||
self._refresh_session_and_object(request)
|
||||
self.object = self.get_object()
|
||||
form = self.form_class(request.POST, instance=self.object)
|
||||
|
||||
# Get the current form and validate it
|
||||
if form.is_valid():
|
||||
self.redirect_page = False
|
||||
if "contact_setup_save_button" in request.POST:
|
||||
# Logic for when the 'Save' button is clicked
|
||||
self.redirect_type = self.RedirectType.COMPLETE_SETUP
|
||||
# Logic for when the 'Save' button is clicked, which indicates
|
||||
# user should stay on this page
|
||||
self.redirect_page = False
|
||||
elif "contact_setup_submit_button" in request.POST:
|
||||
specific_redirect = "redirect_viewname" in self.session
|
||||
self.redirect_type = self.RedirectType.TO_SPECIFIC_PAGE if specific_redirect else self.RedirectType.HOME
|
||||
# Logic for when the other button is clicked, which indicates
|
||||
# the user should be taken to the redirect page
|
||||
self.redirect_page = True
|
||||
return self.form_valid(form)
|
||||
else:
|
||||
return self.form_invalid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
"""Redirect to the nameservers page for the domain."""
|
||||
return self.get_redirect_url()
|
||||
|
||||
def get_redirect_url(self):
|
||||
"""
|
||||
Returns a URL string based on the current value of self.redirect_type.
|
||||
|
||||
Depending on self.redirect_type, constructs a base URL and appends a
|
||||
'redirect' query parameter. Handles different redirection types such as
|
||||
HOME, BACK_TO_SELF, COMPLETE_SETUP, and TO_SPECIFIC_PAGE.
|
||||
|
||||
Returns:
|
||||
str: The full URL with the appropriate query parameters.
|
||||
"""
|
||||
|
||||
# These redirect types redirect to the same page
|
||||
self_redirect = [self.RedirectType.BACK_TO_SELF, self.RedirectType.COMPLETE_SETUP]
|
||||
|
||||
# Maps the redirect type to a URL
|
||||
base_url = ""
|
||||
"""Redirect to the redirect page, or redirect to the current page"""
|
||||
try:
|
||||
if self.redirect_type in self_redirect:
|
||||
base_url = reverse("finish-user-profile-setup")
|
||||
elif self.redirect_type == self.RedirectType.TO_SPECIFIC_PAGE:
|
||||
# We only allow this session value to use viewnames,
|
||||
# because this restricts what can be redirected to.
|
||||
desired_view = self.session["redirect_viewname"]
|
||||
self.session.pop("redirect_viewname")
|
||||
base_url = reverse(desired_view)
|
||||
else:
|
||||
base_url = reverse("home")
|
||||
# Get the redirect parameter from the form submission
|
||||
redirect_param = self.request.POST.get("redirect", None)
|
||||
if self.redirect_page and redirect_param:
|
||||
return reverse(redirect_param)
|
||||
except NoReverseMatch as err:
|
||||
logger.error(f"get_redirect_url -> Could not find the specified page. Err: {err}")
|
||||
|
||||
query_params = {}
|
||||
|
||||
# Quote cleans up the value so that it can be used in a url
|
||||
if self.redirect_type and self.redirect_type.value:
|
||||
query_params["redirect"] = quote(self.redirect_type.value)
|
||||
|
||||
# Generate the full url from the given query params
|
||||
full_url = replace_url_queryparams(base_url, query_params)
|
||||
return full_url
|
||||
return super().get_success_url()
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue