mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-05-20 11:29:25 +02:00
Merge main
This commit is contained in:
commit
f8a95564a9
30 changed files with 3007 additions and 1200 deletions
8
.github/workflows/deploy-stable.yaml
vendored
8
.github/workflows/deploy-stable.yaml
vendored
|
@ -37,3 +37,11 @@ jobs:
|
||||||
cf_org: cisa-dotgov
|
cf_org: cisa-dotgov
|
||||||
cf_space: stable
|
cf_space: stable
|
||||||
cf_manifest: "ops/manifests/manifest-stable.yaml"
|
cf_manifest: "ops/manifests/manifest-stable.yaml"
|
||||||
|
- name: Run Django migrations
|
||||||
|
uses: cloud-gov/cg-cli-tools@main
|
||||||
|
with:
|
||||||
|
cf_username: ${{ secrets.CF_STABLE_USERNAME }}
|
||||||
|
cf_password: ${{ secrets.CF_STABLE_PASSWORD }}
|
||||||
|
cf_org: cisa-dotgov
|
||||||
|
cf_space: stable
|
||||||
|
cf_command: "run-task getgov-stable --command 'python manage.py migrate' --name migrate"
|
8
.github/workflows/deploy-staging.yaml
vendored
8
.github/workflows/deploy-staging.yaml
vendored
|
@ -37,3 +37,11 @@ jobs:
|
||||||
cf_org: cisa-dotgov
|
cf_org: cisa-dotgov
|
||||||
cf_space: staging
|
cf_space: staging
|
||||||
cf_manifest: "ops/manifests/manifest-staging.yaml"
|
cf_manifest: "ops/manifests/manifest-staging.yaml"
|
||||||
|
- name: Run Django migrations
|
||||||
|
uses: cloud-gov/cg-cli-tools@main
|
||||||
|
with:
|
||||||
|
cf_username: ${{ secrets.CF_STAGING_USERNAME }}
|
||||||
|
cf_password: ${{ secrets.CF_STAGING_PASSWORD }}
|
||||||
|
cf_org: cisa-dotgov
|
||||||
|
cf_space: staging
|
||||||
|
cf_command: "run-task getgov-staging --command 'python manage.py migrate' --name migrate"
|
||||||
|
|
|
@ -1,34 +1,112 @@
|
||||||
|
from datetime import date
|
||||||
import logging
|
import logging
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.db.models import Avg, F
|
from django.db.models import Avg, F, Value, CharField, Q
|
||||||
from django.db.models.functions import Concat
|
from django.db.models.functions import Concat, Coalesce
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse, HttpResponseRedirect
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
from django_fsm import get_available_FIELD_transitions
|
from django_fsm import get_available_FIELD_transitions
|
||||||
from django.contrib import admin, messages
|
from django.contrib import admin, messages
|
||||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.http.response import HttpResponse, HttpResponseRedirect
|
|
||||||
from django.urls import path, reverse
|
from django.urls import path, reverse
|
||||||
|
from dateutil.relativedelta import relativedelta # type: ignore
|
||||||
from epplibwrapper.errors import ErrorCode, RegistryError
|
from epplibwrapper.errors import ErrorCode, RegistryError
|
||||||
from registrar.models.domain import Domain
|
from registrar.models import Contact, Domain, DomainApplication, DraftDomain, User, Website
|
||||||
from registrar.models.user import User
|
|
||||||
from registrar.utility import csv_export
|
from registrar.utility import csv_export
|
||||||
from registrar.views.utility.mixins import OrderableFieldsMixin
|
from registrar.views.utility.mixins import OrderableFieldsMixin
|
||||||
from django.contrib.admin.views.main import ORDER_VAR
|
from django.contrib.admin.views.main import ORDER_VAR
|
||||||
|
from registrar.widgets import NoAutocompleteFilteredSelectMultiple
|
||||||
from . import models
|
from . import models
|
||||||
from auditlog.models import LogEntry # type: ignore
|
from auditlog.models import LogEntry # type: ignore
|
||||||
from auditlog.admin import LogEntryAdmin # type: ignore
|
from auditlog.admin import LogEntryAdmin # type: ignore
|
||||||
from django_fsm import TransitionNotAllowed # type: ignore
|
from django_fsm import TransitionNotAllowed # type: ignore
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
|
from django.contrib.auth.forms import UserChangeForm, UsernameField
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MyUserAdminForm(UserChangeForm):
|
||||||
|
"""This form utilizes the custom widget for its class's ManyToMany UIs.
|
||||||
|
|
||||||
|
It inherits from UserChangeForm which has special handling for the password and username fields."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.User
|
||||||
|
fields = "__all__"
|
||||||
|
field_classes = {"username": UsernameField}
|
||||||
|
widgets = {
|
||||||
|
"groups": NoAutocompleteFilteredSelectMultiple("groups", False),
|
||||||
|
"user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DomainInformationAdminForm(forms.ModelForm):
|
||||||
|
"""This form utilizes the custom widget for its class's ManyToMany UIs."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.DomainInformation
|
||||||
|
fields = "__all__"
|
||||||
|
widgets = {
|
||||||
|
"other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DomainInformationInlineForm(forms.ModelForm):
|
||||||
|
"""This form utilizes the custom widget for its class's ManyToMany UIs."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.DomainInformation
|
||||||
|
fields = "__all__"
|
||||||
|
widgets = {
|
||||||
|
"other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DomainApplicationAdminForm(forms.ModelForm):
|
||||||
|
"""Custom form to limit transitions to available transitions.
|
||||||
|
This form utilizes the custom widget for its class's ManyToMany UIs."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.DomainApplication
|
||||||
|
fields = "__all__"
|
||||||
|
widgets = {
|
||||||
|
"current_websites": NoAutocompleteFilteredSelectMultiple("current_websites", False),
|
||||||
|
"alternative_domains": NoAutocompleteFilteredSelectMultiple("alternative_domains", False),
|
||||||
|
"other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
application = kwargs.get("instance")
|
||||||
|
if application and application.pk:
|
||||||
|
current_state = application.status
|
||||||
|
|
||||||
|
# first option in status transitions is current state
|
||||||
|
available_transitions = [(current_state, application.get_status_display())]
|
||||||
|
|
||||||
|
transitions = get_available_FIELD_transitions(
|
||||||
|
application, models.DomainApplication._meta.get_field("status")
|
||||||
|
)
|
||||||
|
|
||||||
|
for transition in transitions:
|
||||||
|
available_transitions.append((transition.target, transition.target.label))
|
||||||
|
|
||||||
|
# only set the available transitions if the user is not restricted
|
||||||
|
# from editing the domain application; otherwise, the form will be
|
||||||
|
# readonly and the status field will not have a widget
|
||||||
|
if not application.creator.is_restricted():
|
||||||
|
self.fields["status"].widget.choices = available_transitions
|
||||||
|
|
||||||
|
|
||||||
# Based off of this excellent example: https://djangosnippets.org/snippets/10471/
|
# Based off of this excellent example: https://djangosnippets.org/snippets/10471/
|
||||||
class MultiFieldSortableChangeList(admin.views.main.ChangeList):
|
class MultiFieldSortableChangeList(admin.views.main.ChangeList):
|
||||||
"""
|
"""
|
||||||
|
@ -121,41 +199,52 @@ class CustomLogEntryAdmin(LogEntryAdmin):
|
||||||
|
|
||||||
|
|
||||||
class AdminSortFields:
|
class AdminSortFields:
|
||||||
def get_queryset(db_field):
|
_name_sort = ["first_name", "last_name", "email"]
|
||||||
|
|
||||||
|
# Define a mapping of field names to model querysets and sort expressions.
|
||||||
|
# A dictionary is used for specificity, but the downside is some degree of repetition.
|
||||||
|
# To eliminate this, this list can be generated dynamically but the readability of that
|
||||||
|
# is impacted.
|
||||||
|
sort_mapping = {
|
||||||
|
# == Contact == #
|
||||||
|
"other_contacts": (Contact, _name_sort),
|
||||||
|
"authorizing_official": (Contact, _name_sort),
|
||||||
|
"submitter": (Contact, _name_sort),
|
||||||
|
# == User == #
|
||||||
|
"creator": (User, _name_sort),
|
||||||
|
"user": (User, _name_sort),
|
||||||
|
"investigator": (User, _name_sort),
|
||||||
|
# == Website == #
|
||||||
|
"current_websites": (Website, "website"),
|
||||||
|
"alternative_domains": (Website, "website"),
|
||||||
|
# == DraftDomain == #
|
||||||
|
"requested_domain": (DraftDomain, "name"),
|
||||||
|
# == DomainApplication == #
|
||||||
|
"domain_application": (DomainApplication, "requested_domain__name"),
|
||||||
|
# == Domain == #
|
||||||
|
"domain": (Domain, "name"),
|
||||||
|
"approved_domain": (Domain, "name"),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_queryset(cls, db_field):
|
||||||
"""This is a helper function for formfield_for_manytomany and formfield_for_foreignkey"""
|
"""This is a helper function for formfield_for_manytomany and formfield_for_foreignkey"""
|
||||||
# customize sorting
|
queryset_info = cls.sort_mapping.get(db_field.name, None)
|
||||||
if db_field.name in (
|
if queryset_info is None:
|
||||||
"other_contacts",
|
|
||||||
"authorizing_official",
|
|
||||||
"submitter",
|
|
||||||
):
|
|
||||||
# Sort contacts by first_name, then last_name, then email
|
|
||||||
return models.Contact.objects.all().order_by(Concat("first_name", "last_name", "email"))
|
|
||||||
elif db_field.name in ("current_websites", "alternative_domains"):
|
|
||||||
# sort web sites
|
|
||||||
return models.Website.objects.all().order_by("website")
|
|
||||||
elif db_field.name in (
|
|
||||||
"creator",
|
|
||||||
"user",
|
|
||||||
"investigator",
|
|
||||||
):
|
|
||||||
# Sort users by first_name, then last_name, then email
|
|
||||||
return models.User.objects.all().order_by(Concat("first_name", "last_name", "email"))
|
|
||||||
elif db_field.name in (
|
|
||||||
"domain",
|
|
||||||
"approved_domain",
|
|
||||||
):
|
|
||||||
# Sort domains by name
|
|
||||||
return models.Domain.objects.all().order_by("name")
|
|
||||||
elif db_field.name in ("requested_domain",):
|
|
||||||
# Sort draft domains by name
|
|
||||||
return models.DraftDomain.objects.all().order_by("name")
|
|
||||||
elif db_field.name in ("domain_application",):
|
|
||||||
# Sort domain applications by name
|
|
||||||
return models.DomainApplication.objects.all().order_by("requested_domain__name")
|
|
||||||
else:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Grab the model we want to order, and grab how we want to order it
|
||||||
|
model, order_by = queryset_info
|
||||||
|
match db_field.name:
|
||||||
|
case "investigator":
|
||||||
|
# We should only return users who are staff.
|
||||||
|
return model.objects.filter(is_staff=True).order_by(*order_by)
|
||||||
|
case _:
|
||||||
|
if isinstance(order_by, list) or isinstance(order_by, tuple):
|
||||||
|
return model.objects.order_by(*order_by)
|
||||||
|
else:
|
||||||
|
return model.objects.order_by(order_by)
|
||||||
|
|
||||||
|
|
||||||
class AuditedAdmin(admin.ModelAdmin):
|
class AuditedAdmin(admin.ModelAdmin):
|
||||||
"""Custom admin to make auditing easier."""
|
"""Custom admin to make auditing easier."""
|
||||||
|
@ -173,9 +262,14 @@ class AuditedAdmin(admin.ModelAdmin):
|
||||||
def formfield_for_manytomany(self, db_field, request, **kwargs):
|
def formfield_for_manytomany(self, db_field, request, **kwargs):
|
||||||
"""customize the behavior of formfields with manytomany relationships. the customized
|
"""customize the behavior of formfields with manytomany relationships. the customized
|
||||||
behavior includes sorting of objects in lists as well as customizing helper text"""
|
behavior includes sorting of objects in lists as well as customizing helper text"""
|
||||||
|
|
||||||
|
# Define a queryset. Note that in the super of this,
|
||||||
|
# a new queryset will only be generated if one does not exist.
|
||||||
|
# Thus, the order in which we define queryset matters.
|
||||||
queryset = AdminSortFields.get_queryset(db_field)
|
queryset = AdminSortFields.get_queryset(db_field)
|
||||||
if queryset:
|
if queryset:
|
||||||
kwargs["queryset"] = queryset
|
kwargs["queryset"] = queryset
|
||||||
|
|
||||||
formfield = super().formfield_for_manytomany(db_field, request, **kwargs)
|
formfield = super().formfield_for_manytomany(db_field, request, **kwargs)
|
||||||
# customize the help text for all formfields for manytomany
|
# customize the help text for all formfields for manytomany
|
||||||
formfield.help_text = (
|
formfield.help_text = (
|
||||||
|
@ -185,11 +279,16 @@ class AuditedAdmin(admin.ModelAdmin):
|
||||||
return formfield
|
return formfield
|
||||||
|
|
||||||
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
||||||
"""customize the behavior of formfields with foreign key relationships. this will customize
|
"""Customize the behavior of formfields with foreign key relationships. This will customize
|
||||||
the behavior of selects. customized behavior includes sorting of objects in list"""
|
the behavior of selects. Customized behavior includes sorting of objects in list."""
|
||||||
|
|
||||||
|
# Define a queryset. Note that in the super of this,
|
||||||
|
# a new queryset will only be generated if one does not exist.
|
||||||
|
# Thus, the order in which we define queryset matters.
|
||||||
queryset = AdminSortFields.get_queryset(db_field)
|
queryset = AdminSortFields.get_queryset(db_field)
|
||||||
if queryset:
|
if queryset:
|
||||||
kwargs["queryset"] = queryset
|
kwargs["queryset"] = queryset
|
||||||
|
|
||||||
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@ -224,7 +323,6 @@ class ListHeaderAdmin(AuditedAdmin, OrderableFieldsMixin):
|
||||||
parameter_value: string}
|
parameter_value: string}
|
||||||
TODO: convert investigator id to investigator username
|
TODO: convert investigator id to investigator username
|
||||||
"""
|
"""
|
||||||
|
|
||||||
filters = []
|
filters = []
|
||||||
# Retrieve the filter parameters
|
# Retrieve the filter parameters
|
||||||
for param in request.GET.keys():
|
for param in request.GET.keys():
|
||||||
|
@ -268,6 +366,16 @@ class UserContactInline(admin.StackedInline):
|
||||||
class MyUserAdmin(BaseUserAdmin):
|
class MyUserAdmin(BaseUserAdmin):
|
||||||
"""Custom user admin class to use our inlines."""
|
"""Custom user admin class to use our inlines."""
|
||||||
|
|
||||||
|
form = MyUserAdminForm
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""Contains meta information about this class"""
|
||||||
|
|
||||||
|
model = models.User
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
_meta = Meta()
|
||||||
|
|
||||||
inlines = [UserContactInline]
|
inlines = [UserContactInline]
|
||||||
|
|
||||||
list_display = (
|
list_display = (
|
||||||
|
@ -429,6 +537,41 @@ class MyUserAdmin(BaseUserAdmin):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return render(request, "admin/analytics.html", context)
|
return render(request, "admin/analytics.html", context)
|
||||||
|
def get_search_results(self, request, queryset, search_term):
|
||||||
|
"""
|
||||||
|
Override for get_search_results. This affects any upstream model using autocomplete_fields,
|
||||||
|
such as DomainApplication. This is because autocomplete_fields uses an API call to fetch data,
|
||||||
|
and this fetch comes from this method.
|
||||||
|
"""
|
||||||
|
# Custom filtering logic
|
||||||
|
queryset, use_distinct = super().get_search_results(request, queryset, search_term)
|
||||||
|
|
||||||
|
# If we aren't given a request to modify, we shouldn't try to
|
||||||
|
if request is None or not hasattr(request, "GET"):
|
||||||
|
return queryset, use_distinct
|
||||||
|
|
||||||
|
# Otherwise, lets modify it!
|
||||||
|
request_get = request.GET
|
||||||
|
|
||||||
|
# The request defines model name and field name.
|
||||||
|
# For instance, model_name could be "DomainApplication"
|
||||||
|
# and field_name could be "investigator".
|
||||||
|
model_name = request_get.get("model_name", None)
|
||||||
|
field_name = request_get.get("field_name", None)
|
||||||
|
|
||||||
|
# Make sure we're only modifying requests from these models.
|
||||||
|
models_to_target = {"domainapplication"}
|
||||||
|
if model_name in models_to_target:
|
||||||
|
# Define rules per field
|
||||||
|
match field_name:
|
||||||
|
case "investigator":
|
||||||
|
# We should not display investigators who don't have a staff role
|
||||||
|
queryset = queryset.filter(is_staff=True)
|
||||||
|
case _:
|
||||||
|
# In the default case, do nothing
|
||||||
|
pass
|
||||||
|
|
||||||
|
return queryset, use_distinct
|
||||||
|
|
||||||
# Let's define First group
|
# Let's define First group
|
||||||
# (which should in theory be the ONLY group)
|
# (which should in theory be the ONLY group)
|
||||||
|
@ -494,6 +637,9 @@ class ContactAdmin(ListHeaderAdmin):
|
||||||
"contact",
|
"contact",
|
||||||
"email",
|
"email",
|
||||||
]
|
]
|
||||||
|
# this ordering effects the ordering of results
|
||||||
|
# in autocomplete_fields for user
|
||||||
|
ordering = ["first_name", "last_name", "email"]
|
||||||
|
|
||||||
# We name the custom prop 'contact' because linter
|
# We name the custom prop 'contact' because linter
|
||||||
# is not allowing a short_description attr on it
|
# is not allowing a short_description attr on it
|
||||||
|
@ -680,6 +826,8 @@ class DomainInvitationAdmin(ListHeaderAdmin):
|
||||||
class DomainInformationAdmin(ListHeaderAdmin):
|
class DomainInformationAdmin(ListHeaderAdmin):
|
||||||
"""Customize domain information admin class."""
|
"""Customize domain information admin class."""
|
||||||
|
|
||||||
|
form = DomainInformationAdminForm
|
||||||
|
|
||||||
# Columns
|
# Columns
|
||||||
list_display = [
|
list_display = [
|
||||||
"domain",
|
"domain",
|
||||||
|
@ -765,6 +913,14 @@ class DomainInformationAdmin(ListHeaderAdmin):
|
||||||
# to activate the edit/delete/view buttons
|
# to activate the edit/delete/view buttons
|
||||||
filter_horizontal = ("other_contacts",)
|
filter_horizontal = ("other_contacts",)
|
||||||
|
|
||||||
|
autocomplete_fields = [
|
||||||
|
"creator",
|
||||||
|
"domain_application",
|
||||||
|
"authorizing_official",
|
||||||
|
"domain",
|
||||||
|
"submitter",
|
||||||
|
]
|
||||||
|
|
||||||
# Table ordering
|
# Table ordering
|
||||||
ordering = ["domain__name"]
|
ordering = ["domain__name"]
|
||||||
|
|
||||||
|
@ -784,40 +940,11 @@ class DomainInformationAdmin(ListHeaderAdmin):
|
||||||
return readonly_fields # Read-only fields for analysts
|
return readonly_fields # Read-only fields for analysts
|
||||||
|
|
||||||
|
|
||||||
class DomainApplicationAdminForm(forms.ModelForm):
|
|
||||||
"""Custom form to limit transitions to available transitions"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.DomainApplication
|
|
||||||
fields = "__all__"
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
application = kwargs.get("instance")
|
|
||||||
if application and application.pk:
|
|
||||||
current_state = application.status
|
|
||||||
|
|
||||||
# first option in status transitions is current state
|
|
||||||
available_transitions = [(current_state, application.get_status_display())]
|
|
||||||
|
|
||||||
transitions = get_available_FIELD_transitions(
|
|
||||||
application, models.DomainApplication._meta.get_field("status")
|
|
||||||
)
|
|
||||||
|
|
||||||
for transition in transitions:
|
|
||||||
available_transitions.append((transition.target, transition.target.label))
|
|
||||||
|
|
||||||
# only set the available transitions if the user is not restricted
|
|
||||||
# from editing the domain application; otherwise, the form will be
|
|
||||||
# readonly and the status field will not have a widget
|
|
||||||
if not application.creator.is_restricted():
|
|
||||||
self.fields["status"].widget.choices = available_transitions
|
|
||||||
|
|
||||||
|
|
||||||
class DomainApplicationAdmin(ListHeaderAdmin):
|
class DomainApplicationAdmin(ListHeaderAdmin):
|
||||||
"""Custom domain applications admin class."""
|
"""Custom domain applications admin class."""
|
||||||
|
|
||||||
|
form = DomainApplicationAdminForm
|
||||||
|
|
||||||
class InvestigatorFilter(admin.SimpleListFilter):
|
class InvestigatorFilter(admin.SimpleListFilter):
|
||||||
"""Custom investigator filter that only displays users with the manager role"""
|
"""Custom investigator filter that only displays users with the manager role"""
|
||||||
|
|
||||||
|
@ -829,8 +956,29 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
||||||
"""Lookup reimplementation, gets users of is_staff.
|
"""Lookup reimplementation, gets users of is_staff.
|
||||||
Returns a list of tuples consisting of (user.id, user)
|
Returns a list of tuples consisting of (user.id, user)
|
||||||
"""
|
"""
|
||||||
privileged_users = User.objects.filter(is_staff=True).order_by("first_name", "last_name", "email")
|
# Select all investigators that are staff, then order by name and email
|
||||||
return [(user.id, user) for user in privileged_users]
|
privileged_users = (
|
||||||
|
DomainApplication.objects.select_related("investigator")
|
||||||
|
.filter(investigator__is_staff=True)
|
||||||
|
.order_by("investigator__first_name", "investigator__last_name", "investigator__email")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Annotate the full name and return a values list that lookups can use
|
||||||
|
privileged_users_annotated = (
|
||||||
|
privileged_users.annotate(
|
||||||
|
full_name=Coalesce(
|
||||||
|
Concat(
|
||||||
|
"investigator__first_name", Value(" "), "investigator__last_name", output_field=CharField()
|
||||||
|
),
|
||||||
|
"investigator__email",
|
||||||
|
output_field=CharField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values_list("investigator__id", "full_name")
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
return privileged_users_annotated
|
||||||
|
|
||||||
def queryset(self, request, queryset):
|
def queryset(self, request, queryset):
|
||||||
"""Custom queryset implementation, filters by investigator"""
|
"""Custom queryset implementation, filters by investigator"""
|
||||||
|
@ -839,11 +987,35 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
||||||
else:
|
else:
|
||||||
return queryset.filter(investigator__id__exact=self.value())
|
return queryset.filter(investigator__id__exact=self.value())
|
||||||
|
|
||||||
|
class ElectionOfficeFilter(admin.SimpleListFilter):
|
||||||
|
"""Define a custom filter for is_election_board"""
|
||||||
|
|
||||||
|
title = _("election office")
|
||||||
|
parameter_name = "is_election_board"
|
||||||
|
|
||||||
|
def lookups(self, request, model_admin):
|
||||||
|
return (
|
||||||
|
("1", _("Yes")),
|
||||||
|
("0", _("No")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def queryset(self, request, queryset):
|
||||||
|
if self.value() == "1":
|
||||||
|
return queryset.filter(is_election_board=True)
|
||||||
|
if self.value() == "0":
|
||||||
|
return queryset.filter(Q(is_election_board=False) | Q(is_election_board=None))
|
||||||
|
|
||||||
# Columns
|
# Columns
|
||||||
list_display = [
|
list_display = [
|
||||||
"requested_domain",
|
"requested_domain",
|
||||||
"status",
|
"status",
|
||||||
"organization_type",
|
"organization_type",
|
||||||
|
"federal_type",
|
||||||
|
"federal_agency",
|
||||||
|
"organization_name",
|
||||||
|
"custom_election_board",
|
||||||
|
"city",
|
||||||
|
"state_territory",
|
||||||
"created_at",
|
"created_at",
|
||||||
"submitter",
|
"submitter",
|
||||||
"investigator",
|
"investigator",
|
||||||
|
@ -855,8 +1027,21 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
||||||
("investigator", ["first_name", "last_name"]),
|
("investigator", ["first_name", "last_name"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def custom_election_board(self, obj):
|
||||||
|
return "Yes" if obj.is_election_board else "No"
|
||||||
|
|
||||||
|
custom_election_board.admin_order_field = "is_election_board" # type: ignore
|
||||||
|
custom_election_board.short_description = "Election office" # type: ignore
|
||||||
|
|
||||||
# Filters
|
# Filters
|
||||||
list_filter = ("status", "organization_type", InvestigatorFilter)
|
list_filter = (
|
||||||
|
"status",
|
||||||
|
"organization_type",
|
||||||
|
"federal_type",
|
||||||
|
ElectionOfficeFilter,
|
||||||
|
"rejection_reason",
|
||||||
|
InvestigatorFilter,
|
||||||
|
)
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
search_fields = [
|
search_fields = [
|
||||||
|
@ -867,10 +1052,8 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
||||||
]
|
]
|
||||||
search_help_text = "Search by domain or submitter."
|
search_help_text = "Search by domain or submitter."
|
||||||
|
|
||||||
# Detail view
|
|
||||||
form = DomainApplicationAdminForm
|
|
||||||
fieldsets = [
|
fieldsets = [
|
||||||
(None, {"fields": ["status", "investigator", "creator", "approved_domain", "notes"]}),
|
(None, {"fields": ["status", "rejection_reason", "investigator", "creator", "approved_domain", "notes"]}),
|
||||||
(
|
(
|
||||||
"Type of organization",
|
"Type of organization",
|
||||||
{
|
{
|
||||||
|
@ -930,26 +1113,19 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
||||||
"anything_else",
|
"anything_else",
|
||||||
"is_policy_acknowledged",
|
"is_policy_acknowledged",
|
||||||
]
|
]
|
||||||
|
autocomplete_fields = [
|
||||||
|
"approved_domain",
|
||||||
|
"requested_domain",
|
||||||
|
"submitter",
|
||||||
|
"creator",
|
||||||
|
"authorizing_official",
|
||||||
|
"investigator",
|
||||||
|
]
|
||||||
filter_horizontal = ("current_websites", "alternative_domains", "other_contacts")
|
filter_horizontal = ("current_websites", "alternative_domains", "other_contacts")
|
||||||
|
|
||||||
# Table ordering
|
# Table ordering
|
||||||
ordering = ["requested_domain__name"]
|
ordering = ["requested_domain__name"]
|
||||||
|
|
||||||
# lists in filter_horizontal are not sorted properly, sort them
|
|
||||||
# by website
|
|
||||||
def formfield_for_manytomany(self, db_field, request, **kwargs):
|
|
||||||
if db_field.name in ("current_websites", "alternative_domains"):
|
|
||||||
kwargs["queryset"] = models.Website.objects.all().order_by("website") # Sort websites
|
|
||||||
return super().formfield_for_manytomany(db_field, request, **kwargs)
|
|
||||||
|
|
||||||
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
|
||||||
# Removes invalid investigator options from the investigator dropdown
|
|
||||||
if db_field.name == "investigator":
|
|
||||||
kwargs["queryset"] = User.objects.filter(is_staff=True)
|
|
||||||
return db_field.formfield(**kwargs)
|
|
||||||
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
|
||||||
|
|
||||||
# Trigger action when a fieldset is changed
|
# Trigger action when a fieldset is changed
|
||||||
def save_model(self, request, obj, form, change):
|
def save_model(self, request, obj, form, change):
|
||||||
if obj and obj.creator.status != models.User.RESTRICTED:
|
if obj and obj.creator.status != models.User.RESTRICTED:
|
||||||
|
@ -978,6 +1154,23 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
||||||
"This action is not permitted. The domain is already active.",
|
"This action is not permitted. The domain is already active.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
elif (
|
||||||
|
obj
|
||||||
|
and obj.status == models.DomainApplication.ApplicationStatus.REJECTED
|
||||||
|
and not obj.rejection_reason
|
||||||
|
):
|
||||||
|
# This condition should never be triggered.
|
||||||
|
# The opposite of this condition is acceptable (rejected -> other status and rejection_reason)
|
||||||
|
# because we clean up the rejection reason in the transition in the model.
|
||||||
|
|
||||||
|
# Clear the success message
|
||||||
|
messages.set_level(request, messages.ERROR)
|
||||||
|
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
"A rejection reason is required.",
|
||||||
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if obj.status != original_obj.status:
|
if obj.status != original_obj.status:
|
||||||
status_method_mapping = {
|
status_method_mapping = {
|
||||||
|
@ -1017,7 +1210,6 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
||||||
admin user permissions and the application creator's status, so
|
admin user permissions and the application creator's status, so
|
||||||
we'll use the baseline readonly_fields and extend it as needed.
|
we'll use the baseline readonly_fields and extend it as needed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
readonly_fields = list(self.readonly_fields)
|
readonly_fields = list(self.readonly_fields)
|
||||||
|
|
||||||
# Check if the creator is restricted
|
# Check if the creator is restricted
|
||||||
|
@ -1072,6 +1264,8 @@ class DomainInformationInline(admin.StackedInline):
|
||||||
classes conflict, so we'll just pull what we need
|
classes conflict, so we'll just pull what we need
|
||||||
from DomainInformationAdmin"""
|
from DomainInformationAdmin"""
|
||||||
|
|
||||||
|
form = DomainInformationInlineForm
|
||||||
|
|
||||||
model = models.DomainInformation
|
model = models.DomainInformation
|
||||||
|
|
||||||
fieldsets = DomainInformationAdmin.fieldsets
|
fieldsets = DomainInformationAdmin.fieldsets
|
||||||
|
@ -1080,6 +1274,14 @@ class DomainInformationInline(admin.StackedInline):
|
||||||
# to activate the edit/delete/view buttons
|
# to activate the edit/delete/view buttons
|
||||||
filter_horizontal = ("other_contacts",)
|
filter_horizontal = ("other_contacts",)
|
||||||
|
|
||||||
|
autocomplete_fields = [
|
||||||
|
"creator",
|
||||||
|
"domain_application",
|
||||||
|
"authorizing_official",
|
||||||
|
"domain",
|
||||||
|
"submitter",
|
||||||
|
]
|
||||||
|
|
||||||
def formfield_for_manytomany(self, db_field, request, **kwargs):
|
def formfield_for_manytomany(self, db_field, request, **kwargs):
|
||||||
"""customize the behavior of formfields with manytomany relationships. the customized
|
"""customize the behavior of formfields with manytomany relationships. the customized
|
||||||
behavior includes sorting of objects in lists as well as customizing helper text"""
|
behavior includes sorting of objects in lists as well as customizing helper text"""
|
||||||
|
@ -1095,8 +1297,8 @@ class DomainInformationInline(admin.StackedInline):
|
||||||
return formfield
|
return formfield
|
||||||
|
|
||||||
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
||||||
"""customize the behavior of formfields with foreign key relationships. this will customize
|
"""Customize the behavior of formfields with foreign key relationships. This will customize
|
||||||
the behavior of selects. customized behavior includes sorting of objects in list"""
|
the behavior of selects. Customized behavior includes sorting of objects in list."""
|
||||||
queryset = AdminSortFields.get_queryset(db_field)
|
queryset = AdminSortFields.get_queryset(db_field)
|
||||||
if queryset:
|
if queryset:
|
||||||
kwargs["queryset"] = queryset
|
kwargs["queryset"] = queryset
|
||||||
|
@ -1109,12 +1311,37 @@ class DomainInformationInline(admin.StackedInline):
|
||||||
class DomainAdmin(ListHeaderAdmin):
|
class DomainAdmin(ListHeaderAdmin):
|
||||||
"""Custom domain admin class to add extra buttons."""
|
"""Custom domain admin class to add extra buttons."""
|
||||||
|
|
||||||
|
class ElectionOfficeFilter(admin.SimpleListFilter):
|
||||||
|
"""Define a custom filter for is_election_board"""
|
||||||
|
|
||||||
|
title = _("election office")
|
||||||
|
parameter_name = "is_election_board"
|
||||||
|
|
||||||
|
def lookups(self, request, model_admin):
|
||||||
|
return (
|
||||||
|
("1", _("Yes")),
|
||||||
|
("0", _("No")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def queryset(self, request, queryset):
|
||||||
|
logger.debug(self.value())
|
||||||
|
if self.value() == "1":
|
||||||
|
return queryset.filter(domain_info__is_election_board=True)
|
||||||
|
if self.value() == "0":
|
||||||
|
return queryset.filter(Q(domain_info__is_election_board=False) | Q(domain_info__is_election_board=None))
|
||||||
|
|
||||||
inlines = [DomainInformationInline]
|
inlines = [DomainInformationInline]
|
||||||
|
|
||||||
# Columns
|
# Columns
|
||||||
list_display = [
|
list_display = [
|
||||||
"name",
|
"name",
|
||||||
"organization_type",
|
"organization_type",
|
||||||
|
"federal_type",
|
||||||
|
"federal_agency",
|
||||||
|
"organization_name",
|
||||||
|
"custom_election_board",
|
||||||
|
"city",
|
||||||
|
"state_territory",
|
||||||
"state",
|
"state",
|
||||||
"expiration_date",
|
"expiration_date",
|
||||||
"created_at",
|
"created_at",
|
||||||
|
@ -1138,8 +1365,42 @@ class DomainAdmin(ListHeaderAdmin):
|
||||||
|
|
||||||
organization_type.admin_order_field = "domain_info__organization_type" # type: ignore
|
organization_type.admin_order_field = "domain_info__organization_type" # type: ignore
|
||||||
|
|
||||||
|
def federal_agency(self, obj):
|
||||||
|
return obj.domain_info.federal_agency if obj.domain_info else None
|
||||||
|
|
||||||
|
federal_agency.admin_order_field = "domain_info__federal_agency" # type: ignore
|
||||||
|
|
||||||
|
def federal_type(self, obj):
|
||||||
|
return obj.domain_info.federal_type if obj.domain_info else None
|
||||||
|
|
||||||
|
federal_type.admin_order_field = "domain_info__federal_type" # type: ignore
|
||||||
|
|
||||||
|
def organization_name(self, obj):
|
||||||
|
return obj.domain_info.organization_name if obj.domain_info else None
|
||||||
|
|
||||||
|
organization_name.admin_order_field = "domain_info__organization_name" # type: ignore
|
||||||
|
|
||||||
|
def custom_election_board(self, obj):
|
||||||
|
domain_info = getattr(obj, "domain_info", None)
|
||||||
|
if domain_info:
|
||||||
|
return "Yes" if domain_info.is_election_board else "No"
|
||||||
|
return "No"
|
||||||
|
|
||||||
|
custom_election_board.admin_order_field = "domain_info__is_election_board" # type: ignore
|
||||||
|
custom_election_board.short_description = "Election office" # type: ignore
|
||||||
|
|
||||||
|
def city(self, obj):
|
||||||
|
return obj.domain_info.city if obj.domain_info else None
|
||||||
|
|
||||||
|
city.admin_order_field = "domain_info__city" # type: ignore
|
||||||
|
|
||||||
|
def state_territory(self, obj):
|
||||||
|
return obj.domain_info.state_territory if obj.domain_info else None
|
||||||
|
|
||||||
|
state_territory.admin_order_field = "domain_info__state_territory" # type: ignore
|
||||||
|
|
||||||
# Filters
|
# Filters
|
||||||
list_filter = ["domain_info__organization_type", "state"]
|
list_filter = ["domain_info__organization_type", "domain_info__federal_type", ElectionOfficeFilter, "state"]
|
||||||
|
|
||||||
search_fields = ["name"]
|
search_fields = ["name"]
|
||||||
search_help_text = "Search by domain name."
|
search_help_text = "Search by domain name."
|
||||||
|
@ -1149,6 +1410,33 @@ class DomainAdmin(ListHeaderAdmin):
|
||||||
# Table ordering
|
# Table ordering
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
|
||||||
|
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
||||||
|
"""Custom changeform implementation to pass in context information"""
|
||||||
|
if extra_context is None:
|
||||||
|
extra_context = {}
|
||||||
|
|
||||||
|
# Pass in what the an extended expiration date would be for the expiration date modal
|
||||||
|
if object_id is not None:
|
||||||
|
domain = Domain.objects.get(pk=object_id)
|
||||||
|
years_to_extend_by = self._get_calculated_years_for_exp_date(domain)
|
||||||
|
|
||||||
|
try:
|
||||||
|
curr_exp_date = domain.registry_expiration_date
|
||||||
|
except KeyError:
|
||||||
|
# No expiration date was found. Return none.
|
||||||
|
extra_context["extended_expiration_date"] = None
|
||||||
|
return super().changeform_view(request, object_id, form_url, extra_context)
|
||||||
|
|
||||||
|
if curr_exp_date < date.today():
|
||||||
|
extra_context["extended_expiration_date"] = date.today() + relativedelta(years=years_to_extend_by)
|
||||||
|
else:
|
||||||
|
new_date = domain.registry_expiration_date + relativedelta(years=years_to_extend_by)
|
||||||
|
extra_context["extended_expiration_date"] = new_date
|
||||||
|
else:
|
||||||
|
extra_context["extended_expiration_date"] = None
|
||||||
|
|
||||||
|
return super().changeform_view(request, object_id, form_url, extra_context)
|
||||||
|
|
||||||
def response_change(self, request, obj):
|
def response_change(self, request, obj):
|
||||||
# Create dictionary of action functions
|
# Create dictionary of action functions
|
||||||
ACTION_FUNCTIONS = {
|
ACTION_FUNCTIONS = {
|
||||||
|
@ -1157,6 +1445,7 @@ class DomainAdmin(ListHeaderAdmin):
|
||||||
"_edit_domain": self.do_edit_domain,
|
"_edit_domain": self.do_edit_domain,
|
||||||
"_delete_domain": self.do_delete_domain,
|
"_delete_domain": self.do_delete_domain,
|
||||||
"_get_status": self.do_get_status,
|
"_get_status": self.do_get_status,
|
||||||
|
"_extend_expiration_date": self.do_extend_expiration_date,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check which action button was pressed and call the corresponding function
|
# Check which action button was pressed and call the corresponding function
|
||||||
|
@ -1167,6 +1456,81 @@ class DomainAdmin(ListHeaderAdmin):
|
||||||
# If no matching action button is found, return the super method
|
# If no matching action button is found, return the super method
|
||||||
return super().response_change(request, obj)
|
return super().response_change(request, obj)
|
||||||
|
|
||||||
|
def do_extend_expiration_date(self, request, obj):
|
||||||
|
"""Extends a domains expiration date by one year from the current date"""
|
||||||
|
|
||||||
|
# Make sure we're dealing with a Domain
|
||||||
|
if not isinstance(obj, Domain):
|
||||||
|
self.message_user(request, "Object is not of type Domain.", messages.ERROR)
|
||||||
|
return None
|
||||||
|
|
||||||
|
years = self._get_calculated_years_for_exp_date(obj)
|
||||||
|
|
||||||
|
# Renew the domain.
|
||||||
|
try:
|
||||||
|
obj.renew_domain(length=years)
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
"Successfully extended the expiration date.",
|
||||||
|
)
|
||||||
|
except RegistryError as err:
|
||||||
|
if err.is_connection_error():
|
||||||
|
error_message = "Error connecting to the registry."
|
||||||
|
else:
|
||||||
|
error_message = f"Error extending this domain: {err}."
|
||||||
|
self.message_user(request, error_message, messages.ERROR)
|
||||||
|
except KeyError:
|
||||||
|
# In normal code flow, a keyerror can only occur when
|
||||||
|
# fresh data can't be pulled from the registry, and thus there is no cache.
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
"Error connecting to the registry. No expiration date was found.",
|
||||||
|
messages.ERROR,
|
||||||
|
)
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(err, stack_info=True)
|
||||||
|
self.message_user(request, "Could not delete: An unspecified error occured", messages.ERROR)
|
||||||
|
|
||||||
|
return HttpResponseRedirect(".")
|
||||||
|
|
||||||
|
def _get_calculated_years_for_exp_date(self, obj, extension_period: int = 1):
|
||||||
|
"""Given the current date, an extension period, and a registry_expiration_date
|
||||||
|
on the domain object, calculate the number of years needed to extend the
|
||||||
|
current expiration date by the extension period.
|
||||||
|
"""
|
||||||
|
# Get the date we want to update to
|
||||||
|
desired_date = self._get_current_date() + relativedelta(years=extension_period)
|
||||||
|
|
||||||
|
# Grab the current expiration date
|
||||||
|
try:
|
||||||
|
exp_date = obj.registry_expiration_date
|
||||||
|
except KeyError:
|
||||||
|
# if no expiration date from registry, set it to today
|
||||||
|
logger.warning("current expiration date not set; setting to today")
|
||||||
|
exp_date = self._get_current_date()
|
||||||
|
|
||||||
|
# If the expiration date is super old (2020, for example), we need to
|
||||||
|
# "catch up" to the current year, so we add the difference.
|
||||||
|
# If both years match, then lets just proceed as normal.
|
||||||
|
calculated_exp_date = exp_date + relativedelta(years=extension_period)
|
||||||
|
|
||||||
|
year_difference = desired_date.year - exp_date.year
|
||||||
|
|
||||||
|
years = extension_period
|
||||||
|
if desired_date > calculated_exp_date:
|
||||||
|
# Max probably isn't needed here (no code flow), but it guards against negative and 0.
|
||||||
|
# In both of those cases, we just want to extend by the extension_period.
|
||||||
|
years = max(extension_period, year_difference)
|
||||||
|
|
||||||
|
return years
|
||||||
|
|
||||||
|
# Workaround for unit tests, as we cannot mock date directly.
|
||||||
|
# it is immutable. Rather than dealing with a convoluted workaround,
|
||||||
|
# lets wrap this in a function.
|
||||||
|
def _get_current_date(self):
|
||||||
|
"""Gets the current date"""
|
||||||
|
return date.today()
|
||||||
|
|
||||||
def do_delete_domain(self, request, obj):
|
def do_delete_domain(self, request, obj):
|
||||||
if not isinstance(obj, Domain):
|
if not isinstance(obj, Domain):
|
||||||
# Could be problematic if the type is similar,
|
# Could be problematic if the type is similar,
|
||||||
|
@ -1327,6 +1691,10 @@ class DraftDomainAdmin(ListHeaderAdmin):
|
||||||
search_fields = ["name"]
|
search_fields = ["name"]
|
||||||
search_help_text = "Search by draft domain name."
|
search_help_text = "Search by draft domain name."
|
||||||
|
|
||||||
|
# this ordering effects the ordering of results
|
||||||
|
# in autocomplete_fields for user
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
|
||||||
class VerifiedByStaffAdmin(ListHeaderAdmin):
|
class VerifiedByStaffAdmin(ListHeaderAdmin):
|
||||||
list_display = ("email", "requestor", "truncated_notes", "created_at")
|
list_display = ("email", "requestor", "truncated_notes", "created_at")
|
||||||
|
|
|
@ -23,6 +23,33 @@ function openInNewTab(el, removeAttribute = false){
|
||||||
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
|
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
|
||||||
// Initialization code.
|
// Initialization code.
|
||||||
|
|
||||||
|
/** An IIFE for pages in DjangoAdmin that use modals.
|
||||||
|
* Dja strips out form elements, and modals generate their content outside
|
||||||
|
* of the current form scope, so we need to "inject" these inputs.
|
||||||
|
*/
|
||||||
|
(function (){
|
||||||
|
function createPhantomModalFormButtons(){
|
||||||
|
let submitButtons = document.querySelectorAll('.usa-modal button[type="submit"]');
|
||||||
|
form = document.querySelector("form")
|
||||||
|
submitButtons.forEach((button) => {
|
||||||
|
|
||||||
|
let input = document.createElement("input");
|
||||||
|
input.type = "submit";
|
||||||
|
input.name = button.name;
|
||||||
|
input.value = button.value;
|
||||||
|
input.style.display = "none"
|
||||||
|
|
||||||
|
// Add the hidden input to the form
|
||||||
|
form.appendChild(input);
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
console.log("clicking")
|
||||||
|
input.click();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createPhantomModalFormButtons();
|
||||||
|
})();
|
||||||
/** An IIFE for pages in DjangoAdmin which may need custom JS implementation.
|
/** An IIFE for pages in DjangoAdmin which may need custom JS implementation.
|
||||||
* Currently only appends target="_blank" to the domain_form object,
|
* Currently only appends target="_blank" to the domain_form object,
|
||||||
* but this can be expanded.
|
* but this can be expanded.
|
||||||
|
@ -135,7 +162,11 @@ function initializeWidgetOnToList(toList, toListId) {
|
||||||
'websites': '/admin/registrar/website/__fk__/change/?_to_field=id',
|
'websites': '/admin/registrar/website/__fk__/change/?_to_field=id',
|
||||||
'alternative_domains': '/admin/registrar/website/__fk__/change/?_to_field=id',
|
'alternative_domains': '/admin/registrar/website/__fk__/change/?_to_field=id',
|
||||||
},
|
},
|
||||||
false,
|
// NOTE: If we open view in the same window then use the back button
|
||||||
|
// to go back, the 'chosen' list will fail to initialize correctly in
|
||||||
|
// sandbozes (but will work fine on local). This is related to how the
|
||||||
|
// Django JS runs (SelectBox.js) and is probably due to a race condition.
|
||||||
|
true,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -312,3 +343,46 @@ function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk,
|
||||||
}
|
}
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
/** An IIFE for admin in DjangoAdmin to listen to changes on the domain request
|
||||||
|
* status select amd to show/hide the rejection reason
|
||||||
|
*/
|
||||||
|
(function (){
|
||||||
|
let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason')
|
||||||
|
|
||||||
|
if (rejectionReasonFormGroup) {
|
||||||
|
let statusSelect = document.getElementById('id_status')
|
||||||
|
|
||||||
|
// Initial handling of rejectionReasonFormGroup display
|
||||||
|
if (statusSelect.value != 'rejected')
|
||||||
|
rejectionReasonFormGroup.style.display = 'none';
|
||||||
|
|
||||||
|
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
|
||||||
|
statusSelect.addEventListener('change', function() {
|
||||||
|
if (statusSelect.value == 'rejected') {
|
||||||
|
rejectionReasonFormGroup.style.display = 'block';
|
||||||
|
sessionStorage.removeItem('hideRejectionReason');
|
||||||
|
} else {
|
||||||
|
rejectionReasonFormGroup.style.display = 'none';
|
||||||
|
sessionStorage.setItem('hideRejectionReason', 'true');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage
|
||||||
|
|
||||||
|
// When you navigate using forward/back after changing status but not saving, when you land back on the DA page the
|
||||||
|
// status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide
|
||||||
|
// accurately for this edge case, we use cache and test for the back/forward navigation.
|
||||||
|
const observer = new PerformanceObserver((list) => {
|
||||||
|
list.getEntries().forEach((entry) => {
|
||||||
|
if (entry.type === "back_forward") {
|
||||||
|
if (sessionStorage.getItem('hideRejectionReason'))
|
||||||
|
document.querySelector('.field-rejection_reason').style.display = 'none';
|
||||||
|
else
|
||||||
|
document.querySelector('.field-rejection_reason').style.display = 'block';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
observer.observe({ type: "navigation" });
|
||||||
|
})();
|
||||||
|
|
|
@ -184,6 +184,10 @@ h1, h2, h3,
|
||||||
.submit-row div.spacer {
|
.submit-row div.spacer {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
.submit-row .mini-spacer{
|
||||||
|
margin-left: 2px;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
.submit-row span {
|
.submit-row span {
|
||||||
margin-top: units(1);
|
margin-top: units(1);
|
||||||
}
|
}
|
||||||
|
@ -271,3 +275,31 @@ h1, h2, h3,
|
||||||
margin: 0!important;
|
margin: 0!important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hides the "clear" button on autocomplete, as we already have one to use
|
||||||
|
.select2-selection__clear {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixes a display issue where the list was entirely white, or had too much whitespace
|
||||||
|
.select2-dropdown {
|
||||||
|
display: inline-grid !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.admin-confirm-button {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button groups in /admin incorrectly have bullets.
|
||||||
|
// Remove that!
|
||||||
|
.usa-modal__footer .usa-button-group__item {
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// USWDS media checks are overzealous in this situation,
|
||||||
|
// we should manually define this behaviour.
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.button-list-mobile {
|
||||||
|
display: contents !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -286,6 +286,7 @@ AWS_MAX_ATTEMPTS = 3
|
||||||
BOTO_CONFIG = Config(retries={"mode": AWS_RETRY_MODE, "max_attempts": AWS_MAX_ATTEMPTS})
|
BOTO_CONFIG = Config(retries={"mode": AWS_RETRY_MODE, "max_attempts": AWS_MAX_ATTEMPTS})
|
||||||
|
|
||||||
# email address to use for various automated correspondence
|
# email address to use for various automated correspondence
|
||||||
|
# also used as a default to and bcc email
|
||||||
DEFAULT_FROM_EMAIL = "help@get.gov <help@get.gov>"
|
DEFAULT_FROM_EMAIL = "help@get.gov <help@get.gov>"
|
||||||
|
|
||||||
# connect to an (external) SMTP server for sending email
|
# connect to an (external) SMTP server for sending email
|
||||||
|
|
|
@ -284,6 +284,7 @@ class OrganizationContactForm(RegistrarForm):
|
||||||
message="Enter a zip code in the form of 12345 or 12345-6789.",
|
message="Enter a zip code in the form of 12345 or 12345-6789.",
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
error_messages={"required": ("Enter a zip code in the form of 12345 or 12345-6789.")},
|
||||||
)
|
)
|
||||||
urbanization = forms.CharField(
|
urbanization = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Generated by Django 4.2.7 on 2024-02-26 22:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0069_alter_contact_email_alter_contact_first_name_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domainapplication",
|
||||||
|
name="rejection_reason",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("purpose_not_met", "Purpose requirements not met"),
|
||||||
|
("requestor_not_eligible", "Requestor not eligible to make request"),
|
||||||
|
("org_has_domain", "Org already has a .gov domain"),
|
||||||
|
("contacts_not_verified", "Org contacts couldn't be verified"),
|
||||||
|
("org_not_eligible", "Org not eligible for a .gov domain"),
|
||||||
|
("naming_not_met", "Naming requirements not met"),
|
||||||
|
("other", "Other/Unspecified"),
|
||||||
|
],
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -13,6 +13,7 @@ from typing import Any
|
||||||
from registrar.models.host import Host
|
from registrar.models.host import Host
|
||||||
from registrar.models.host_ip import HostIP
|
from registrar.models.host_ip import HostIP
|
||||||
from registrar.utility.enums import DefaultEmail
|
from registrar.utility.enums import DefaultEmail
|
||||||
|
from registrar.utility import errors
|
||||||
|
|
||||||
from registrar.utility.errors import (
|
from registrar.utility.errors import (
|
||||||
ActionNotAllowed,
|
ActionNotAllowed,
|
||||||
|
@ -192,9 +193,17 @@ class Domain(TimeStampedModel, DomainHelper):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def available(cls, domain: str) -> bool:
|
def available(cls, domain: str) -> bool:
|
||||||
"""Check if a domain is available."""
|
"""Check if a domain is available.
|
||||||
|
This is called by the availablility api and
|
||||||
|
is called in the validate function on the request/domain page
|
||||||
|
|
||||||
|
throws- RegistryError or InvalidDomainError"""
|
||||||
if not cls.string_could_be_domain(domain):
|
if not cls.string_could_be_domain(domain):
|
||||||
raise ValueError("Not a valid domain: %s" % str(domain))
|
logger.warning("Not a valid domain: %s" % str(domain))
|
||||||
|
# throw invalid domain error so that it can be caught in
|
||||||
|
# validate_and_handle_errors in domain_helper
|
||||||
|
raise errors.InvalidDomainError()
|
||||||
|
|
||||||
domain_name = domain.lower()
|
domain_name = domain.lower()
|
||||||
req = commands.CheckDomain([domain_name])
|
req = commands.CheckDomain([domain_name])
|
||||||
return registry.send(req, cleaned=True).res_data[0].avail
|
return registry.send(req, cleaned=True).res_data[0].avail
|
||||||
|
@ -265,15 +274,17 @@ class Domain(TimeStampedModel, DomainHelper):
|
||||||
|
|
||||||
Default length and unit of time are 1 year.
|
Default length and unit of time are 1 year.
|
||||||
"""
|
"""
|
||||||
# if no expiration date from registry, set to today
|
|
||||||
|
# If no date is specified, grab the registry_expiration_date
|
||||||
try:
|
try:
|
||||||
cur_exp_date = self.registry_expiration_date
|
exp_date = self.registry_expiration_date
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
# if no expiration date from registry, set it to today
|
||||||
logger.warning("current expiration date not set; setting to today")
|
logger.warning("current expiration date not set; setting to today")
|
||||||
cur_exp_date = date.today()
|
exp_date = date.today()
|
||||||
|
|
||||||
# create RenewDomain request
|
# create RenewDomain request
|
||||||
request = commands.RenewDomain(name=self.name, cur_exp_date=cur_exp_date, period=epp.Period(length, unit))
|
request = commands.RenewDomain(name=self.name, cur_exp_date=exp_date, period=epp.Period(length, unit))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# update expiration date in registry, and set the updated
|
# update expiration date in registry, and set the updated
|
||||||
|
@ -427,7 +438,6 @@ class Domain(TimeStampedModel, DomainHelper):
|
||||||
raise NameserverError(code=nsErrorCodes.INVALID_HOST, nameserver=nameserver)
|
raise NameserverError(code=nsErrorCodes.INVALID_HOST, nameserver=nameserver)
|
||||||
elif cls.isSubdomain(name, nameserver) and (ip is None or ip == []):
|
elif cls.isSubdomain(name, nameserver) and (ip is None or ip == []):
|
||||||
raise NameserverError(code=nsErrorCodes.MISSING_IP, nameserver=nameserver)
|
raise NameserverError(code=nsErrorCodes.MISSING_IP, nameserver=nameserver)
|
||||||
|
|
||||||
elif not cls.isSubdomain(name, nameserver) and (ip is not None and ip != []):
|
elif not cls.isSubdomain(name, nameserver) and (ip is not None and ip != []):
|
||||||
raise NameserverError(code=nsErrorCodes.GLUE_RECORD_NOT_ALLOWED, nameserver=nameserver, ip=ip)
|
raise NameserverError(code=nsErrorCodes.GLUE_RECORD_NOT_ALLOWED, nameserver=nameserver, ip=ip)
|
||||||
elif ip is not None and ip != []:
|
elif ip is not None and ip != []:
|
||||||
|
@ -1778,6 +1788,10 @@ class Domain(TimeStampedModel, DomainHelper):
|
||||||
for cleaned_host in cleaned_hosts:
|
for cleaned_host in cleaned_hosts:
|
||||||
# Check if the cleaned_host already exists
|
# Check if the cleaned_host already exists
|
||||||
host_in_db, host_created = Host.objects.get_or_create(domain=self, name=cleaned_host["name"])
|
host_in_db, host_created = Host.objects.get_or_create(domain=self, name=cleaned_host["name"])
|
||||||
|
# Check if the nameserver is a subdomain of the current domain
|
||||||
|
# If it is NOT a subdomain, we remove the IP address
|
||||||
|
if not Domain.isSubdomain(self.name, cleaned_host["name"]):
|
||||||
|
cleaned_host["addrs"] = []
|
||||||
# Get cleaned list of ips for update
|
# Get cleaned list of ips for update
|
||||||
cleaned_ips = cleaned_host["addrs"]
|
cleaned_ips = cleaned_host["addrs"]
|
||||||
if not host_created:
|
if not host_created:
|
||||||
|
|
|
@ -4,6 +4,7 @@ from typing import Union
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django_fsm import FSMField, transition # type: ignore
|
from django_fsm import FSMField, transition # type: ignore
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
@ -350,12 +351,34 @@ class DomainApplication(TimeStampedModel):
|
||||||
]
|
]
|
||||||
AGENCY_CHOICES = [(v, v) for v in AGENCIES]
|
AGENCY_CHOICES = [(v, v) for v in AGENCIES]
|
||||||
|
|
||||||
|
class RejectionReasons(models.TextChoices):
|
||||||
|
DOMAIN_PURPOSE = "purpose_not_met", "Purpose requirements not met"
|
||||||
|
REQUESTOR = "requestor_not_eligible", "Requestor not eligible to make request"
|
||||||
|
SECOND_DOMAIN_REASONING = (
|
||||||
|
"org_has_domain",
|
||||||
|
"Org already has a .gov domain",
|
||||||
|
)
|
||||||
|
CONTACTS_OR_ORGANIZATION_LEGITIMACY = (
|
||||||
|
"contacts_not_verified",
|
||||||
|
"Org contacts couldn't be verified",
|
||||||
|
)
|
||||||
|
ORGANIZATION_ELIGIBILITY = "org_not_eligible", "Org not eligible for a .gov domain"
|
||||||
|
NAMING_REQUIREMENTS = "naming_not_met", "Naming requirements not met"
|
||||||
|
OTHER = "other", "Other/Unspecified"
|
||||||
|
|
||||||
# #### Internal fields about the application #####
|
# #### Internal fields about the application #####
|
||||||
status = FSMField(
|
status = FSMField(
|
||||||
choices=ApplicationStatus.choices, # possible states as an array of constants
|
choices=ApplicationStatus.choices, # possible states as an array of constants
|
||||||
default=ApplicationStatus.STARTED, # sensible default
|
default=ApplicationStatus.STARTED, # sensible default
|
||||||
protected=False, # can change state directly, particularly in Django admin
|
protected=False, # can change state directly, particularly in Django admin
|
||||||
)
|
)
|
||||||
|
|
||||||
|
rejection_reason = models.TextField(
|
||||||
|
choices=RejectionReasons.choices,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
# This is the application user who created this application. The contact
|
# This is the application user who created this application. The contact
|
||||||
# information that they gave is in the `submitter` field
|
# information that they gave is in the `submitter` field
|
||||||
creator = models.ForeignKey(
|
creator = models.ForeignKey(
|
||||||
|
@ -363,6 +386,7 @@ class DomainApplication(TimeStampedModel):
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name="applications_created",
|
related_name="applications_created",
|
||||||
)
|
)
|
||||||
|
|
||||||
investigator = models.ForeignKey(
|
investigator = models.ForeignKey(
|
||||||
"registrar.User",
|
"registrar.User",
|
||||||
null=True,
|
null=True,
|
||||||
|
@ -588,7 +612,9 @@ class DomainApplication(TimeStampedModel):
|
||||||
logger.error(err)
|
logger.error(err)
|
||||||
logger.error(f"Can't query an approved domain while attempting {called_from}")
|
logger.error(f"Can't query an approved domain while attempting {called_from}")
|
||||||
|
|
||||||
def _send_status_update_email(self, new_status, email_template, email_template_subject, send_email=True):
|
def _send_status_update_email(
|
||||||
|
self, new_status, email_template, email_template_subject, send_email=True, bcc_address=""
|
||||||
|
):
|
||||||
"""Send a status update email to the submitter.
|
"""Send a status update email to the submitter.
|
||||||
|
|
||||||
The email goes to the email address that the submitter gave as their
|
The email goes to the email address that the submitter gave as their
|
||||||
|
@ -613,6 +639,7 @@ class DomainApplication(TimeStampedModel):
|
||||||
email_template_subject,
|
email_template_subject,
|
||||||
self.submitter.email,
|
self.submitter.email,
|
||||||
context={"application": self},
|
context={"application": self},
|
||||||
|
bcc_address=bcc_address,
|
||||||
)
|
)
|
||||||
logger.info(f"The {new_status} email sent to: {self.submitter.email}")
|
logger.info(f"The {new_status} email sent to: {self.submitter.email}")
|
||||||
except EmailSendingError:
|
except EmailSendingError:
|
||||||
|
@ -654,11 +681,17 @@ class DomainApplication(TimeStampedModel):
|
||||||
# Limit email notifications to transitions from Started and Withdrawn
|
# Limit email notifications to transitions from Started and Withdrawn
|
||||||
limited_statuses = [self.ApplicationStatus.STARTED, self.ApplicationStatus.WITHDRAWN]
|
limited_statuses = [self.ApplicationStatus.STARTED, self.ApplicationStatus.WITHDRAWN]
|
||||||
|
|
||||||
|
bcc_address = ""
|
||||||
|
if settings.IS_PRODUCTION:
|
||||||
|
bcc_address = settings.DEFAULT_FROM_EMAIL
|
||||||
|
|
||||||
if self.status in limited_statuses:
|
if self.status in limited_statuses:
|
||||||
self._send_status_update_email(
|
self._send_status_update_email(
|
||||||
"submission confirmation",
|
"submission confirmation",
|
||||||
"emails/submission_confirmation.txt",
|
"emails/submission_confirmation.txt",
|
||||||
"emails/submission_confirmation_subject.txt",
|
"emails/submission_confirmation_subject.txt",
|
||||||
|
True,
|
||||||
|
bcc_address,
|
||||||
)
|
)
|
||||||
|
|
||||||
@transition(
|
@transition(
|
||||||
|
@ -678,12 +711,17 @@ class DomainApplication(TimeStampedModel):
|
||||||
|
|
||||||
This action is logged.
|
This action is logged.
|
||||||
|
|
||||||
|
This action cleans up the rejection status if moving away from rejected.
|
||||||
|
|
||||||
As side effects this will delete the domain and domain_information
|
As side effects this will delete the domain and domain_information
|
||||||
(will cascade) when they exist."""
|
(will cascade) when they exist."""
|
||||||
|
|
||||||
if self.status == self.ApplicationStatus.APPROVED:
|
if self.status == self.ApplicationStatus.APPROVED:
|
||||||
self.delete_and_clean_up_domain("in_review")
|
self.delete_and_clean_up_domain("in_review")
|
||||||
|
|
||||||
|
if self.status == self.ApplicationStatus.REJECTED:
|
||||||
|
self.rejection_reason = None
|
||||||
|
|
||||||
literal = DomainApplication.ApplicationStatus.IN_REVIEW
|
literal = DomainApplication.ApplicationStatus.IN_REVIEW
|
||||||
# Check if the tuple exists, then grab its value
|
# Check if the tuple exists, then grab its value
|
||||||
in_review = literal if literal is not None else "In Review"
|
in_review = literal if literal is not None else "In Review"
|
||||||
|
@ -705,12 +743,17 @@ class DomainApplication(TimeStampedModel):
|
||||||
|
|
||||||
This action is logged.
|
This action is logged.
|
||||||
|
|
||||||
|
This action cleans up the rejection status if moving away from rejected.
|
||||||
|
|
||||||
As side effects this will delete the domain and domain_information
|
As side effects this will delete the domain and domain_information
|
||||||
(will cascade) when they exist."""
|
(will cascade) when they exist."""
|
||||||
|
|
||||||
if self.status == self.ApplicationStatus.APPROVED:
|
if self.status == self.ApplicationStatus.APPROVED:
|
||||||
self.delete_and_clean_up_domain("reject_with_prejudice")
|
self.delete_and_clean_up_domain("reject_with_prejudice")
|
||||||
|
|
||||||
|
if self.status == self.ApplicationStatus.REJECTED:
|
||||||
|
self.rejection_reason = None
|
||||||
|
|
||||||
literal = DomainApplication.ApplicationStatus.ACTION_NEEDED
|
literal = DomainApplication.ApplicationStatus.ACTION_NEEDED
|
||||||
# Check if the tuple is setup correctly, then grab its value
|
# Check if the tuple is setup correctly, then grab its value
|
||||||
action_needed = literal if literal is not None else "Action Needed"
|
action_needed = literal if literal is not None else "Action Needed"
|
||||||
|
@ -729,6 +772,8 @@ class DomainApplication(TimeStampedModel):
|
||||||
def approve(self, send_email=True):
|
def approve(self, send_email=True):
|
||||||
"""Approve an application that has been submitted.
|
"""Approve an application that has been submitted.
|
||||||
|
|
||||||
|
This action cleans up the rejection status if moving away from rejected.
|
||||||
|
|
||||||
This has substantial side-effects because it creates another database
|
This has substantial side-effects because it creates another database
|
||||||
object for the approved Domain and makes the user who created the
|
object for the approved Domain and makes the user who created the
|
||||||
application into an admin on that domain. It also triggers an email
|
application into an admin on that domain. It also triggers an email
|
||||||
|
@ -751,6 +796,9 @@ class DomainApplication(TimeStampedModel):
|
||||||
user=self.creator, domain=created_domain, role=UserDomainRole.Roles.MANAGER
|
user=self.creator, domain=created_domain, role=UserDomainRole.Roles.MANAGER
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self.status == self.ApplicationStatus.REJECTED:
|
||||||
|
self.rejection_reason = None
|
||||||
|
|
||||||
self._send_status_update_email(
|
self._send_status_update_email(
|
||||||
"application approved",
|
"application approved",
|
||||||
"emails/status_change_approved.txt",
|
"emails/status_change_approved.txt",
|
||||||
|
|
|
@ -255,6 +255,14 @@ class DomainInformation(TimeStampedModel):
|
||||||
else:
|
else:
|
||||||
da_many_to_many_dict[field] = getattr(domain_application, field).all()
|
da_many_to_many_dict[field] = getattr(domain_application, field).all()
|
||||||
|
|
||||||
|
# This will not happen in normal code flow, but having some redundancy doesn't hurt.
|
||||||
|
# da_dict should not have "id" under any circumstances.
|
||||||
|
# If it does have it, then this indicates that common_fields is overzealous in the data
|
||||||
|
# that it is returning. Try looking in DomainHelper.get_common_fields.
|
||||||
|
if "id" in da_dict:
|
||||||
|
logger.warning("create_from_da() -> Found attribute 'id' when trying to create")
|
||||||
|
da_dict.pop("id", None)
|
||||||
|
|
||||||
# Create a placeholder DomainInformation object
|
# Create a placeholder DomainInformation object
|
||||||
domain_info = DomainInformation(**da_dict)
|
domain_info = DomainInformation(**da_dict)
|
||||||
|
|
||||||
|
|
|
@ -33,6 +33,7 @@ class DomainHelper:
|
||||||
# Split into pieces for the linter
|
# Split into pieces for the linter
|
||||||
domain = cls._validate_domain_string(domain, blank_ok)
|
domain = cls._validate_domain_string(domain, blank_ok)
|
||||||
|
|
||||||
|
if domain != "":
|
||||||
try:
|
try:
|
||||||
if not check_domain_available(domain):
|
if not check_domain_available(domain):
|
||||||
raise errors.DomainUnavailableError()
|
raise errors.DomainUnavailableError()
|
||||||
|
@ -180,8 +181,8 @@ class DomainHelper:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Get a list of the existing fields on model_1 and model_2
|
# Get a list of the existing fields on model_1 and model_2
|
||||||
model_1_fields = set(field.name for field in model_1._meta.get_fields() if field != "id")
|
model_1_fields = set(field.name for field in model_1._meta.get_fields() if field.name != "id")
|
||||||
model_2_fields = set(field.name for field in model_2._meta.get_fields() if field != "id")
|
model_2_fields = set(field.name for field in model_2._meta.get_fields() if field.name != "id")
|
||||||
|
|
||||||
# Get the fields that exist on both DomainApplication and DomainInformation
|
# Get the fields that exist on both DomainApplication and DomainInformation
|
||||||
common_fields = model_1_fields & model_2_fields
|
common_fields = model_1_fields & model_2_fields
|
||||||
|
|
37
src/registrar/models/utility/generic_helper.py
Normal file
37
src/registrar/models/utility/generic_helper.py
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
"""This file contains general purpose helpers that don't belong in any specific location"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Timer:
|
||||||
|
"""
|
||||||
|
This class is used to measure execution time for performance profiling.
|
||||||
|
__enter__ and __exit__ is used such that you can wrap any code you want
|
||||||
|
around a with statement. After this exits, logger.info will print
|
||||||
|
the execution time in seconds.
|
||||||
|
|
||||||
|
Note that this class does not account for general randomness as more
|
||||||
|
robust libraries do, so there is some tiny amount of latency involved
|
||||||
|
in using this, but it is minimal enough that for most applications it is not
|
||||||
|
noticable.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
with Timer():
|
||||||
|
...some code
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
"""Starts the timer"""
|
||||||
|
self.start = time.time()
|
||||||
|
# This allows usage of the instance within the with block
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
"""Ends the timer and logs what happened"""
|
||||||
|
self.end = time.time()
|
||||||
|
self.duration = self.end - self.start
|
||||||
|
logger.info(f"Execution time: {self.duration} seconds")
|
|
@ -18,11 +18,12 @@
|
||||||
<link rel="apple-touch-icon" size="180x180"
|
<link rel="apple-touch-icon" size="180x180"
|
||||||
href="{% static 'img/registrar/favicons/favicon-180.png' %}"
|
href="{% static 'img/registrar/favicons/favicon-180.png' %}"
|
||||||
>
|
>
|
||||||
|
<script src="{% static 'js/uswds-init.min.js' %}" defer></script>
|
||||||
|
<script src="{% static 'js/uswds.min.js' %}" defer></script>
|
||||||
<script type="application/javascript" src="{% static 'js/get-gov-admin.js' %}" defer></script>
|
<script type="application/javascript" src="{% static 'js/get-gov-admin.js' %}" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
|
{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
|
||||||
|
|
||||||
{% block extrastyle %}{{ block.super }}
|
{% block extrastyle %}{{ block.super }}
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'css/styles.css' %}" />
|
<link rel="stylesheet" type="text/css" href="{% static 'css/styles.css' %}" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -15,7 +15,15 @@
|
||||||
{% if filters %}
|
{% if filters %}
|
||||||
filtered by
|
filtered by
|
||||||
{% for filter_param in filters %}
|
{% for filter_param in filters %}
|
||||||
|
{% if filter_param.parameter_name == 'is_election_board' %}
|
||||||
|
{%if filter_param.parameter_value == '0' %}
|
||||||
|
election office = No
|
||||||
|
{% else %}
|
||||||
|
election office = Yes
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
{{ filter_param.parameter_name }} = {{ filter_param.parameter_value }}
|
{{ filter_param.parameter_name }} = {{ filter_param.parameter_value }}
|
||||||
|
{% endif %}
|
||||||
{% if not forloop.last %}, {% endif %}
|
{% if not forloop.last %}, {% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -2,21 +2,117 @@
|
||||||
{% load i18n static %}
|
{% load i18n static %}
|
||||||
|
|
||||||
{% block field_sets %}
|
{% block field_sets %}
|
||||||
<div class="submit-row">
|
<div class="display-flex flex-row flex-justify submit-row">
|
||||||
|
<div class="flex-align-self-start button-list-mobile">
|
||||||
<input id="manageDomainSubmitButton" type="submit" value="Manage domain" name="_edit_domain">
|
<input id="manageDomainSubmitButton" type="submit" value="Manage domain" name="_edit_domain">
|
||||||
|
{# Dja has margin styles defined on inputs as is. Lets work with it, rather than fight it. #}
|
||||||
|
<span class="mini-spacer"></span>
|
||||||
<input type="submit" value="Get registry status" name="_get_status">
|
<input type="submit" value="Get registry status" name="_get_status">
|
||||||
<div class="spacer"></div>
|
</div>
|
||||||
|
<div class="desktop:flex-align-self-end">
|
||||||
|
{% if original.state != original.State.DELETED %}
|
||||||
|
<a
|
||||||
|
class="text-middle"
|
||||||
|
href="#toggle-extend-expiration-alert"
|
||||||
|
aria-controls="toggle-extend-expiration-alert"
|
||||||
|
data-open-modal
|
||||||
|
>
|
||||||
|
Extend expiration date
|
||||||
|
</a>
|
||||||
|
<span class="margin-left-05 margin-right-05 text-middle"> | </span>
|
||||||
|
{% endif %}
|
||||||
{% if original.state == original.State.READY %}
|
{% if original.state == original.State.READY %}
|
||||||
<input type="submit" value="Place hold" name="_place_client_hold" class="custom-link-button">
|
<input type="submit" value="Place hold" name="_place_client_hold" class="custom-link-button">
|
||||||
{% elif original.state == original.State.ON_HOLD %}
|
{% elif original.state == original.State.ON_HOLD %}
|
||||||
<input type="submit" value="Remove hold" name="_remove_client_hold" class="custom-link-button">
|
<input type="submit" value="Remove hold" name="_remove_client_hold" class="custom-link-button">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if original.state == original.State.READY or original.state == original.State.ON_HOLD %}
|
{% if original.state == original.State.READY or original.state == original.State.ON_HOLD %}
|
||||||
<span> | </span>
|
<span class="margin-left-05 margin-right-05 text-middle"> | </span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if original.state != original.State.DELETED %}
|
{% if original.state != original.State.DELETED %}
|
||||||
<input type="submit" value="Remove from registry" name="_delete_domain" class="custom-link-button">
|
<input type="submit" value="Remove from registry" name="_delete_domain" class="custom-link-button">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block submit_buttons_bottom %}
|
||||||
|
{% comment %}
|
||||||
|
Modals behave very weirdly in django admin.
|
||||||
|
They tend to "strip out" any injected form elements, leaving only the main form.
|
||||||
|
In addition, USWDS handles modals by first destroying the element, then repopulating it toward the end of the page.
|
||||||
|
In effect, this means that the modal is not, and cannot, be surrounded by any form element at compile time.
|
||||||
|
|
||||||
|
The current workaround for this is to use javascript to inject a hidden input, and bind submit of that
|
||||||
|
element to the click of the confirmation button within this modal.
|
||||||
|
|
||||||
|
This is controlled by the class `dja-form-placeholder` on the button.
|
||||||
|
|
||||||
|
In addition, the modal element MUST be placed low in the DOM. The script loads slower on DJA than on other portions
|
||||||
|
of the application, so this means that it will briefly "populate", causing unintended visual effects.
|
||||||
|
{% endcomment %}
|
||||||
|
<div
|
||||||
|
class="usa-modal"
|
||||||
|
id="toggle-extend-expiration-alert"
|
||||||
|
aria-labelledby="Are you sure you want to extend the expiration date?"
|
||||||
|
aria-describedby="This expiration date will be extended."
|
||||||
|
>
|
||||||
|
<div class="usa-modal__content">
|
||||||
|
<div class="usa-modal__main">
|
||||||
|
<h2 class="usa-modal__heading" id="modal-1-heading">
|
||||||
|
Are you sure you want to extend the expiration date?
|
||||||
|
</h2>
|
||||||
|
<div class="usa-prose">
|
||||||
|
<p>
|
||||||
|
This will extend the expiration date by one year.
|
||||||
|
{# Acts as a <br> #}
|
||||||
|
<div class="display-inline"></div>
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Domain: <b>{{ original.name }}</b>
|
||||||
|
{# Acts as a <br> #}
|
||||||
|
<div class="display-inline"></div>
|
||||||
|
New expiration date: <b>{{ extended_expiration_date }}</b>
|
||||||
|
{{test}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="usa-modal__footer">
|
||||||
|
<ul class="usa-button-group">
|
||||||
|
<li class="usa-button-group__item">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="usa-button dja-form-placeholder"
|
||||||
|
name="_extend_expiration_date"
|
||||||
|
>
|
||||||
|
Yes, extend date
|
||||||
|
</button>
|
||||||
|
</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="{%static 'img/sprite.svg'%}#close"></use>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ block.super }}
|
||||||
|
{% endblock %}
|
|
@ -8,7 +8,56 @@ REQUEST RECEIVED ON: {{ application.submission_date|date }}
|
||||||
STATUS: Rejected
|
STATUS: Rejected
|
||||||
|
|
||||||
----------------------------------------------------------------
|
----------------------------------------------------------------
|
||||||
|
{% if application.rejection_reason != 'other' %}
|
||||||
|
REJECTION REASON{% endif %}{% if application.rejection_reason == 'purpose_not_met' %}
|
||||||
|
Your domain request was rejected because the purpose you provided did not meet our
|
||||||
|
requirements. You didn’t provide enough information about how you intend to use the
|
||||||
|
domain.
|
||||||
|
|
||||||
|
Learn more about:
|
||||||
|
- Eligibility for a .gov domain <https://get.gov/domains/eligibility>
|
||||||
|
- What you can and can’t do with .gov domains <https://get.gov/domains/requirements/>
|
||||||
|
|
||||||
|
If you have questions or comments, reply to this email.{% elif application.rejection_reason == 'requestor_not_eligible' %}
|
||||||
|
Your domain request was rejected because we don’t believe you’re eligible to request a
|
||||||
|
.gov domain on behalf of {{ application.organization_name }}. You must be a government employee, or be
|
||||||
|
working on behalf of a government organization, to request a .gov domain.
|
||||||
|
|
||||||
|
|
||||||
|
DEMONSTRATE ELIGIBILITY
|
||||||
|
If you can provide more information that demonstrates your eligibility, or you want to
|
||||||
|
discuss further, reply to this email.{% elif application.rejection_reason == 'org_has_domain' %}
|
||||||
|
Your domain request was rejected because {{ application.organization_name }} has a .gov domain. Our
|
||||||
|
practice is to approve one domain per online service per government organization. We
|
||||||
|
evaluate additional requests on a case-by-case basis. You did not provide sufficient
|
||||||
|
justification for an additional domain.
|
||||||
|
|
||||||
|
Read more about our practice of approving one domain per online service
|
||||||
|
<https://get.gov/domains/before/#one-domain-per-service>.
|
||||||
|
|
||||||
|
If you have questions or comments, reply to this email.{% elif application.rejection_reason == 'contacts_not_verified' %}
|
||||||
|
Your domain request was rejected because we could not verify the organizational
|
||||||
|
contacts you provided. If you have questions or comments, reply to this email.{% elif application.rejection_reason == 'org_not_eligible' %}
|
||||||
|
Your domain request was rejected because we determined that {{ application.organization_name }} is not
|
||||||
|
eligible for a .gov domain. .Gov domains are only available to official U.S.-based
|
||||||
|
government organizations.
|
||||||
|
|
||||||
|
|
||||||
|
DEMONSTRATE ELIGIBILITY
|
||||||
|
If you can provide documentation that demonstrates your eligibility, reply to this email.
|
||||||
|
This can include links to (or copies of) your authorizing legislation, your founding
|
||||||
|
charter or bylaws, or other similar documentation. Without this, we can’t approve a
|
||||||
|
.gov domain for your organization. Learn more about eligibility for .gov domains
|
||||||
|
<https://get.gov/domains/eligibility/>.{% elif application.rejection_reason == 'naming_not_met' %}
|
||||||
|
Your domain request was rejected because it does not meet our naming requirements.
|
||||||
|
Domains should uniquely identify a government organization and be clear to the
|
||||||
|
general public. Learn more about naming requirements for your type of organization
|
||||||
|
<https://get.gov/domains/choosing/>.
|
||||||
|
|
||||||
|
|
||||||
|
YOU CAN SUBMIT A NEW REQUEST
|
||||||
|
We encourage you to request a domain that meets our requirements. If you have
|
||||||
|
questions or want to discuss potential domain names, reply to this email.{% elif application.rejection_reason == 'other' %}
|
||||||
YOU CAN SUBMIT A NEW REQUEST
|
YOU CAN SUBMIT A NEW REQUEST
|
||||||
If your organization is eligible for a .gov domain and you meet our other requirements, you can submit a new request.
|
If your organization is eligible for a .gov domain and you meet our other requirements, you can submit a new request.
|
||||||
|
|
||||||
|
@ -19,7 +68,7 @@ Learn more about:
|
||||||
|
|
||||||
NEED ASSISTANCE?
|
NEED ASSISTANCE?
|
||||||
If you have questions about this domain request or need help choosing a new domain name, reply to this email.
|
If you have questions about this domain request or need help choosing a new domain name, reply to this email.
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
THANK YOU
|
THANK YOU
|
||||||
.Gov helps the public identify official, trusted information. Thank you for requesting a .gov domain.
|
.Gov helps the public identify official, trusted information. Thank you for requesting a .gov domain.
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<h1>Manage your domains</h2>
|
<h1>Manage your domains</h2>
|
||||||
|
|
||||||
|
|
||||||
<p class="margin-top-4">
|
<p class="margin-top-4">
|
||||||
<a href="{% url 'application:' %}" class="usa-button"
|
<a href="{% url 'application:' %}" class="usa-button"
|
||||||
>
|
>
|
||||||
|
|
|
@ -231,6 +231,7 @@ class AuditedAdminMockData:
|
||||||
first_name="{} first_name:{}".format(item_name, short_hand),
|
first_name="{} first_name:{}".format(item_name, short_hand),
|
||||||
last_name="{} last_name:{}".format(item_name, short_hand),
|
last_name="{} last_name:{}".format(item_name, short_hand),
|
||||||
username="{} username:{}".format(item_name + str(uuid.uuid4())[:8], short_hand),
|
username="{} username:{}".format(item_name + str(uuid.uuid4())[:8], short_hand),
|
||||||
|
is_staff=True,
|
||||||
)[0]
|
)[0]
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
@ -691,6 +692,56 @@ class MockEppLib(TestCase):
|
||||||
],
|
],
|
||||||
ex_date=datetime.date(2023, 5, 25),
|
ex_date=datetime.date(2023, 5, 25),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
mockDataInfoDomainSubdomain = fakedEppObject(
|
||||||
|
"fakePw",
|
||||||
|
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
|
||||||
|
contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)],
|
||||||
|
hosts=["fake.meoward.gov"],
|
||||||
|
statuses=[
|
||||||
|
common.Status(state="serverTransferProhibited", description="", lang="en"),
|
||||||
|
common.Status(state="inactive", description="", lang="en"),
|
||||||
|
],
|
||||||
|
ex_date=datetime.date(2023, 5, 25),
|
||||||
|
)
|
||||||
|
|
||||||
|
mockDataInfoDomainSubdomainAndIPAddress = fakedEppObject(
|
||||||
|
"fakePw",
|
||||||
|
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
|
||||||
|
contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)],
|
||||||
|
hosts=["fake.meow.gov"],
|
||||||
|
statuses=[
|
||||||
|
common.Status(state="serverTransferProhibited", description="", lang="en"),
|
||||||
|
common.Status(state="inactive", description="", lang="en"),
|
||||||
|
],
|
||||||
|
ex_date=datetime.date(2023, 5, 25),
|
||||||
|
addrs=[common.Ip(addr="2.0.0.8")],
|
||||||
|
)
|
||||||
|
|
||||||
|
mockDataInfoDomainNotSubdomainNoIP = fakedEppObject(
|
||||||
|
"fakePw",
|
||||||
|
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
|
||||||
|
contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)],
|
||||||
|
hosts=["fake.meow.com"],
|
||||||
|
statuses=[
|
||||||
|
common.Status(state="serverTransferProhibited", description="", lang="en"),
|
||||||
|
common.Status(state="inactive", description="", lang="en"),
|
||||||
|
],
|
||||||
|
ex_date=datetime.date(2023, 5, 25),
|
||||||
|
)
|
||||||
|
|
||||||
|
mockDataInfoDomainSubdomainNoIP = fakedEppObject(
|
||||||
|
"fakePw",
|
||||||
|
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
|
||||||
|
contacts=[common.DomainContact(contact="123", type=PublicContact.ContactTypeChoices.SECURITY)],
|
||||||
|
hosts=["fake.subdomainwoip.gov"],
|
||||||
|
statuses=[
|
||||||
|
common.Status(state="serverTransferProhibited", description="", lang="en"),
|
||||||
|
common.Status(state="inactive", description="", lang="en"),
|
||||||
|
],
|
||||||
|
ex_date=datetime.date(2023, 5, 25),
|
||||||
|
)
|
||||||
|
|
||||||
mockDataExtensionDomain = fakedEppObject(
|
mockDataExtensionDomain = fakedEppObject(
|
||||||
"fakePw",
|
"fakePw",
|
||||||
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
|
cr_date=make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)),
|
||||||
|
@ -828,6 +879,24 @@ class MockEppLib(TestCase):
|
||||||
addrs=[common.Ip(addr="1.2.3.4"), common.Ip(addr="2.3.4.5")],
|
addrs=[common.Ip(addr="1.2.3.4"), common.Ip(addr="2.3.4.5")],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
mockDataInfoHosts1IP = fakedEppObject(
|
||||||
|
"lastPw",
|
||||||
|
cr_date=make_aware(datetime.datetime(2023, 8, 25, 19, 45, 35)),
|
||||||
|
addrs=[common.Ip(addr="2.0.0.8")],
|
||||||
|
)
|
||||||
|
|
||||||
|
mockDataInfoHostsNotSubdomainNoIP = fakedEppObject(
|
||||||
|
"lastPw",
|
||||||
|
cr_date=make_aware(datetime.datetime(2023, 8, 26, 19, 45, 35)),
|
||||||
|
addrs=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
mockDataInfoHostsSubdomainNoIP = fakedEppObject(
|
||||||
|
"lastPw",
|
||||||
|
cr_date=make_aware(datetime.datetime(2023, 8, 27, 19, 45, 35)),
|
||||||
|
addrs=[],
|
||||||
|
)
|
||||||
|
|
||||||
mockDataHostChange = fakedEppObject("lastPw", cr_date=make_aware(datetime.datetime(2023, 8, 25, 19, 45, 35)))
|
mockDataHostChange = fakedEppObject("lastPw", cr_date=make_aware(datetime.datetime(2023, 8, 25, 19, 45, 35)))
|
||||||
addDsData1 = {
|
addDsData1 = {
|
||||||
"keyTag": 1234,
|
"keyTag": 1234,
|
||||||
|
@ -921,6 +990,11 @@ class MockEppLib(TestCase):
|
||||||
ex_date=datetime.date(2023, 5, 25),
|
ex_date=datetime.date(2023, 5, 25),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
mockButtonRenewedDomainExpDate = fakedEppObject(
|
||||||
|
"fake.gov",
|
||||||
|
ex_date=datetime.date(2025, 5, 25),
|
||||||
|
)
|
||||||
|
|
||||||
mockDnsNeededRenewedDomainExpDate = fakedEppObject(
|
mockDnsNeededRenewedDomainExpDate = fakedEppObject(
|
||||||
"fakeneeded.gov",
|
"fakeneeded.gov",
|
||||||
ex_date=datetime.date(2023, 2, 15),
|
ex_date=datetime.date(2023, 2, 15),
|
||||||
|
@ -989,6 +1063,8 @@ class MockEppLib(TestCase):
|
||||||
return self.mockDeleteDomainCommands(_request, cleaned)
|
return self.mockDeleteDomainCommands(_request, cleaned)
|
||||||
case commands.RenewDomain:
|
case commands.RenewDomain:
|
||||||
return self.mockRenewDomainCommand(_request, cleaned)
|
return self.mockRenewDomainCommand(_request, cleaned)
|
||||||
|
case commands.InfoHost:
|
||||||
|
return self.mockInfoHostCommmands(_request, cleaned)
|
||||||
case _:
|
case _:
|
||||||
return MagicMock(res_data=[self.mockDataInfoHosts])
|
return MagicMock(res_data=[self.mockDataInfoHosts])
|
||||||
|
|
||||||
|
@ -1003,6 +1079,25 @@ class MockEppLib(TestCase):
|
||||||
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
|
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def mockInfoHostCommmands(self, _request, cleaned):
|
||||||
|
request_name = getattr(_request, "name", None)
|
||||||
|
|
||||||
|
# Define a dictionary to map request names to data and extension values
|
||||||
|
request_mappings = {
|
||||||
|
"fake.meow.gov": (self.mockDataInfoHosts1IP, None), # is subdomain and has ip
|
||||||
|
"fake.meow.com": (self.mockDataInfoHostsNotSubdomainNoIP, None), # not subdomain w no ip
|
||||||
|
"fake.subdomainwoip.gov": (self.mockDataInfoHostsSubdomainNoIP, None), # subdomain w no ip
|
||||||
|
}
|
||||||
|
|
||||||
|
# Retrieve the corresponding values from the dictionary
|
||||||
|
default_mapping = (self.mockDataInfoHosts, None)
|
||||||
|
res_data, extensions = request_mappings.get(request_name, default_mapping)
|
||||||
|
|
||||||
|
return MagicMock(
|
||||||
|
res_data=[res_data],
|
||||||
|
extensions=[extensions] if extensions is not None else [],
|
||||||
|
)
|
||||||
|
|
||||||
def mockUpdateHostCommands(self, _request, cleaned):
|
def mockUpdateHostCommands(self, _request, cleaned):
|
||||||
test_ws_ip = common.Ip(addr="1.1. 1.1")
|
test_ws_ip = common.Ip(addr="1.1. 1.1")
|
||||||
addrs_submitted = getattr(_request, "addrs", [])
|
addrs_submitted = getattr(_request, "addrs", [])
|
||||||
|
@ -1049,6 +1144,14 @@ class MockEppLib(TestCase):
|
||||||
res_data=[self.mockMaximumRenewedDomainExpDate],
|
res_data=[self.mockMaximumRenewedDomainExpDate],
|
||||||
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
|
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
|
||||||
)
|
)
|
||||||
|
elif getattr(_request, "name", None) == "fake.gov":
|
||||||
|
period = getattr(_request, "period", None)
|
||||||
|
extension_period = getattr(period, "length", None)
|
||||||
|
if extension_period == 2:
|
||||||
|
return MagicMock(
|
||||||
|
res_data=[self.mockButtonRenewedDomainExpDate],
|
||||||
|
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return MagicMock(
|
return MagicMock(
|
||||||
res_data=[self.mockRenewedDomainExpDate],
|
res_data=[self.mockRenewedDomainExpDate],
|
||||||
|
@ -1083,6 +1186,10 @@ class MockEppLib(TestCase):
|
||||||
"adomain2.gov": (self.InfoDomainWithVerisignSecurityContact, None),
|
"adomain2.gov": (self.InfoDomainWithVerisignSecurityContact, None),
|
||||||
"defaulttechnical.gov": (self.InfoDomainWithDefaultTechnicalContact, None),
|
"defaulttechnical.gov": (self.InfoDomainWithDefaultTechnicalContact, None),
|
||||||
"justnameserver.com": (self.justNameserver, None),
|
"justnameserver.com": (self.justNameserver, None),
|
||||||
|
"meoward.gov": (self.mockDataInfoDomainSubdomain, None),
|
||||||
|
"meow.gov": (self.mockDataInfoDomainSubdomainAndIPAddress, None),
|
||||||
|
"fakemeow.gov": (self.mockDataInfoDomainNotSubdomainNoIP, None),
|
||||||
|
"subdomainwoip.gov": (self.mockDataInfoDomainSubdomainNoIP, None),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Retrieve the corresponding values from the dictionary
|
# Retrieve the corresponding values from the dictionary
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,31 +0,0 @@
|
||||||
from django.test import Client, TestCase, override_settings
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
|
|
||||||
|
|
||||||
class MyTestCase(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.client = Client()
|
|
||||||
username = "test_user"
|
|
||||||
first_name = "First"
|
|
||||||
last_name = "Last"
|
|
||||||
email = "info@example.com"
|
|
||||||
self.user = get_user_model().objects.create(
|
|
||||||
username=username, first_name=first_name, last_name=last_name, email=email
|
|
||||||
)
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
super().tearDown()
|
|
||||||
self.user.delete()
|
|
||||||
|
|
||||||
@override_settings(IS_PRODUCTION=True)
|
|
||||||
def test_production_environment(self):
|
|
||||||
"""No banner on prod."""
|
|
||||||
home_page = self.client.get("/")
|
|
||||||
self.assertNotContains(home_page, "You are on a test site.")
|
|
||||||
|
|
||||||
@override_settings(IS_PRODUCTION=False)
|
|
||||||
def test_non_production_environment(self):
|
|
||||||
"""Banner on non-prod."""
|
|
||||||
home_page = self.client.get("/")
|
|
||||||
self.assertContains(home_page, "You are on a test site.")
|
|
|
@ -574,6 +574,56 @@ class TestDomainApplication(TestCase):
|
||||||
with self.assertRaises(TransitionNotAllowed):
|
with self.assertRaises(TransitionNotAllowed):
|
||||||
self.approved_application.reject_with_prejudice()
|
self.approved_application.reject_with_prejudice()
|
||||||
|
|
||||||
|
def test_approve_from_rejected_clears_rejection_reason(self):
|
||||||
|
"""When transitioning from rejected to approved on a domain request,
|
||||||
|
the rejection_reason is cleared."""
|
||||||
|
|
||||||
|
with less_console_noise():
|
||||||
|
# Create a sample application
|
||||||
|
application = completed_application(status=DomainApplication.ApplicationStatus.REJECTED)
|
||||||
|
application.rejection_reason = DomainApplication.RejectionReasons.DOMAIN_PURPOSE
|
||||||
|
|
||||||
|
# Approve
|
||||||
|
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
||||||
|
application.approve()
|
||||||
|
|
||||||
|
self.assertEqual(application.status, DomainApplication.ApplicationStatus.APPROVED)
|
||||||
|
self.assertEqual(application.rejection_reason, None)
|
||||||
|
|
||||||
|
def test_in_review_from_rejected_clears_rejection_reason(self):
|
||||||
|
"""When transitioning from rejected to in_review on a domain request,
|
||||||
|
the rejection_reason is cleared."""
|
||||||
|
|
||||||
|
with less_console_noise():
|
||||||
|
# Create a sample application
|
||||||
|
application = completed_application(status=DomainApplication.ApplicationStatus.REJECTED)
|
||||||
|
application.domain_is_not_active = True
|
||||||
|
application.rejection_reason = DomainApplication.RejectionReasons.DOMAIN_PURPOSE
|
||||||
|
|
||||||
|
# Approve
|
||||||
|
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
||||||
|
application.in_review()
|
||||||
|
|
||||||
|
self.assertEqual(application.status, DomainApplication.ApplicationStatus.IN_REVIEW)
|
||||||
|
self.assertEqual(application.rejection_reason, None)
|
||||||
|
|
||||||
|
def test_action_needed_from_rejected_clears_rejection_reason(self):
|
||||||
|
"""When transitioning from rejected to action_needed on a domain request,
|
||||||
|
the rejection_reason is cleared."""
|
||||||
|
|
||||||
|
with less_console_noise():
|
||||||
|
# Create a sample application
|
||||||
|
application = completed_application(status=DomainApplication.ApplicationStatus.REJECTED)
|
||||||
|
application.domain_is_not_active = True
|
||||||
|
application.rejection_reason = DomainApplication.RejectionReasons.DOMAIN_PURPOSE
|
||||||
|
|
||||||
|
# Approve
|
||||||
|
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
||||||
|
application.action_needed()
|
||||||
|
|
||||||
|
self.assertEqual(application.status, DomainApplication.ApplicationStatus.ACTION_NEEDED)
|
||||||
|
self.assertEqual(application.rejection_reason, None)
|
||||||
|
|
||||||
def test_has_rationale_returns_true(self):
|
def test_has_rationale_returns_true(self):
|
||||||
"""has_rationale() returns true when an application has no_other_contacts_rationale"""
|
"""has_rationale() returns true when an application has no_other_contacts_rationale"""
|
||||||
with less_console_noise():
|
with less_console_noise():
|
||||||
|
|
|
@ -20,7 +20,7 @@ from registrar.models.user import User
|
||||||
from registrar.utility.errors import ActionNotAllowed, NameserverError
|
from registrar.utility.errors import ActionNotAllowed, NameserverError
|
||||||
|
|
||||||
from registrar.models.utility.contact_error import ContactError, ContactErrorCodes
|
from registrar.models.utility.contact_error import ContactError, ContactErrorCodes
|
||||||
|
from registrar.utility import errors
|
||||||
|
|
||||||
from django_fsm import TransitionNotAllowed # type: ignore
|
from django_fsm import TransitionNotAllowed # type: ignore
|
||||||
from epplibwrapper import (
|
from epplibwrapper import (
|
||||||
|
@ -96,7 +96,7 @@ class TestDomainCache(MockEppLib):
|
||||||
|
|
||||||
self.mockedSendFunction.assert_has_calls(expectedCalls)
|
self.mockedSendFunction.assert_has_calls(expectedCalls)
|
||||||
|
|
||||||
def test_cache_nested_elements(self):
|
def test_cache_nested_elements_not_subdomain(self):
|
||||||
"""Cache works correctly with the nested objects cache and hosts"""
|
"""Cache works correctly with the nested objects cache and hosts"""
|
||||||
with less_console_noise():
|
with less_console_noise():
|
||||||
domain, _ = Domain.objects.get_or_create(name="igorville.gov")
|
domain, _ = Domain.objects.get_or_create(name="igorville.gov")
|
||||||
|
@ -113,7 +113,7 @@ class TestDomainCache(MockEppLib):
|
||||||
}
|
}
|
||||||
expectedHostsDict = {
|
expectedHostsDict = {
|
||||||
"name": self.mockDataInfoDomain.hosts[0],
|
"name": self.mockDataInfoDomain.hosts[0],
|
||||||
"addrs": [item.addr for item in self.mockDataInfoHosts.addrs],
|
"addrs": [], # should return empty bc fake.host.com is not a subdomain of igorville.gov
|
||||||
"cr_date": self.mockDataInfoHosts.cr_date,
|
"cr_date": self.mockDataInfoHosts.cr_date,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -138,6 +138,59 @@ class TestDomainCache(MockEppLib):
|
||||||
# invalidate cache
|
# invalidate cache
|
||||||
domain._cache = {}
|
domain._cache = {}
|
||||||
|
|
||||||
|
# get host
|
||||||
|
domain._get_property("hosts")
|
||||||
|
# Should return empty bc fake.host.com is not a subdomain of igorville.gov
|
||||||
|
self.assertEqual(domain._cache["hosts"], [expectedHostsDict])
|
||||||
|
|
||||||
|
# get contacts
|
||||||
|
domain._get_property("contacts")
|
||||||
|
self.assertEqual(domain._cache["hosts"], [expectedHostsDict])
|
||||||
|
self.assertEqual(domain._cache["contacts"], expectedContactsDict)
|
||||||
|
|
||||||
|
def test_cache_nested_elements_is_subdomain(self):
|
||||||
|
"""Cache works correctly with the nested objects cache and hosts"""
|
||||||
|
with less_console_noise():
|
||||||
|
domain, _ = Domain.objects.get_or_create(name="meoward.gov")
|
||||||
|
|
||||||
|
# The contact list will initially contain objects of type 'DomainContact'
|
||||||
|
# this is then transformed into PublicContact, and cache should NOT
|
||||||
|
# hold onto the DomainContact object
|
||||||
|
expectedUnfurledContactsList = [
|
||||||
|
common.DomainContact(contact="123", type="security"),
|
||||||
|
]
|
||||||
|
expectedContactsDict = {
|
||||||
|
PublicContact.ContactTypeChoices.ADMINISTRATIVE: None,
|
||||||
|
PublicContact.ContactTypeChoices.SECURITY: "123",
|
||||||
|
PublicContact.ContactTypeChoices.TECHNICAL: None,
|
||||||
|
}
|
||||||
|
expectedHostsDict = {
|
||||||
|
"name": self.mockDataInfoDomainSubdomain.hosts[0],
|
||||||
|
"addrs": [item.addr for item in self.mockDataInfoHosts.addrs],
|
||||||
|
"cr_date": self.mockDataInfoHosts.cr_date,
|
||||||
|
}
|
||||||
|
|
||||||
|
# this can be changed when the getter for contacts is implemented
|
||||||
|
domain._get_property("contacts")
|
||||||
|
|
||||||
|
# check domain info is still correct and not overridden
|
||||||
|
self.assertEqual(domain._cache["auth_info"], self.mockDataInfoDomainSubdomain.auth_info)
|
||||||
|
self.assertEqual(domain._cache["cr_date"], self.mockDataInfoDomainSubdomain.cr_date)
|
||||||
|
|
||||||
|
# check contacts
|
||||||
|
self.assertEqual(domain._cache["_contacts"], self.mockDataInfoDomainSubdomain.contacts)
|
||||||
|
# The contact list should not contain what is sent by the registry by default,
|
||||||
|
# as _fetch_cache will transform the type to PublicContact
|
||||||
|
self.assertNotEqual(domain._cache["contacts"], expectedUnfurledContactsList)
|
||||||
|
self.assertEqual(domain._cache["contacts"], expectedContactsDict)
|
||||||
|
|
||||||
|
# get and check hosts is set correctly
|
||||||
|
domain._get_property("hosts")
|
||||||
|
self.assertEqual(domain._cache["hosts"], [expectedHostsDict])
|
||||||
|
self.assertEqual(domain._cache["contacts"], expectedContactsDict)
|
||||||
|
# invalidate cache
|
||||||
|
domain._cache = {}
|
||||||
|
|
||||||
# get host
|
# get host
|
||||||
domain._get_property("hosts")
|
domain._get_property("hosts")
|
||||||
self.assertEqual(domain._cache["hosts"], [expectedHostsDict])
|
self.assertEqual(domain._cache["hosts"], [expectedHostsDict])
|
||||||
|
@ -268,13 +321,13 @@ class TestDomainCreation(MockEppLib):
|
||||||
Then a Domain exists in the database with the same `name`
|
Then a Domain exists in the database with the same `name`
|
||||||
But a domain object does not exist in the registry
|
But a domain object does not exist in the registry
|
||||||
"""
|
"""
|
||||||
|
with less_console_noise():
|
||||||
draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
|
draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
|
||||||
user, _ = User.objects.get_or_create()
|
user, _ = User.objects.get_or_create()
|
||||||
application = DomainApplication.objects.create(creator=user, requested_domain=draft_domain)
|
application = DomainApplication.objects.create(creator=user, requested_domain=draft_domain)
|
||||||
|
|
||||||
mock_client = MockSESClient()
|
mock_client = MockSESClient()
|
||||||
with boto3_mocking.clients.handler_for("sesv2", mock_client):
|
with boto3_mocking.clients.handler_for("sesv2", mock_client):
|
||||||
with less_console_noise():
|
|
||||||
# skip using the submit method
|
# skip using the submit method
|
||||||
application.status = DomainApplication.ApplicationStatus.SUBMITTED
|
application.status = DomainApplication.ApplicationStatus.SUBMITTED
|
||||||
# transition to approve state
|
# transition to approve state
|
||||||
|
@ -293,6 +346,7 @@ class TestDomainCreation(MockEppLib):
|
||||||
And `domain.state` is set to `UNKNOWN`
|
And `domain.state` is set to `UNKNOWN`
|
||||||
And `domain.is_active()` returns False
|
And `domain.is_active()` returns False
|
||||||
"""
|
"""
|
||||||
|
with less_console_noise():
|
||||||
domain = Domain.objects.create(name="beef-tongue.gov")
|
domain = Domain.objects.create(name="beef-tongue.gov")
|
||||||
# trigger getter
|
# trigger getter
|
||||||
_ = domain.statuses
|
_ = domain.statuses
|
||||||
|
@ -329,6 +383,7 @@ class TestDomainCreation(MockEppLib):
|
||||||
|
|
||||||
def test_minimal_creation(self):
|
def test_minimal_creation(self):
|
||||||
"""Can create with just a name."""
|
"""Can create with just a name."""
|
||||||
|
with less_console_noise():
|
||||||
Domain.objects.create(name="igorville.gov")
|
Domain.objects.create(name="igorville.gov")
|
||||||
|
|
||||||
@skip("assertion broken with mock addition")
|
@skip("assertion broken with mock addition")
|
||||||
|
@ -451,6 +506,7 @@ class TestDomainAvailable(MockEppLib):
|
||||||
res_data=[responses.check.CheckDomainResultData(name="available.gov", avail=True, reason=None)],
|
res_data=[responses.check.CheckDomainResultData(name="available.gov", avail=True, reason=None)],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with less_console_noise():
|
||||||
patcher = patch("registrar.models.domain.registry.send")
|
patcher = patch("registrar.models.domain.registry.send")
|
||||||
mocked_send = patcher.start()
|
mocked_send = patcher.start()
|
||||||
mocked_send.side_effect = side_effect
|
mocked_send.side_effect = side_effect
|
||||||
|
@ -484,6 +540,7 @@ class TestDomainAvailable(MockEppLib):
|
||||||
res_data=[responses.check.CheckDomainResultData(name="unavailable.gov", avail=False, reason="In Use")],
|
res_data=[responses.check.CheckDomainResultData(name="unavailable.gov", avail=False, reason="In Use")],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with less_console_noise():
|
||||||
patcher = patch("registrar.models.domain.registry.send")
|
patcher = patch("registrar.models.domain.registry.send")
|
||||||
mocked_send = patcher.start()
|
mocked_send = patcher.start()
|
||||||
mocked_send.side_effect = side_effect
|
mocked_send.side_effect = side_effect
|
||||||
|
@ -502,16 +559,28 @@ class TestDomainAvailable(MockEppLib):
|
||||||
self.assertFalse(available)
|
self.assertFalse(available)
|
||||||
patcher.stop()
|
patcher.stop()
|
||||||
|
|
||||||
def test_domain_available_with_value_error(self):
|
def test_domain_available_with_invalid_error(self):
|
||||||
"""
|
"""
|
||||||
Scenario: Testing whether an invalid domain is available
|
Scenario: Testing whether an invalid domain is available
|
||||||
Should throw ValueError
|
Should throw InvalidDomainError
|
||||||
|
|
||||||
Validate ValueError is raised
|
Validate InvalidDomainError is raised
|
||||||
"""
|
"""
|
||||||
with self.assertRaises(ValueError):
|
with less_console_noise():
|
||||||
|
with self.assertRaises(errors.InvalidDomainError):
|
||||||
Domain.available("invalid-string")
|
Domain.available("invalid-string")
|
||||||
|
|
||||||
|
def test_domain_available_with_empty_string(self):
|
||||||
|
"""
|
||||||
|
Scenario: Testing whether an empty string domain name is available
|
||||||
|
Should throw InvalidDomainError
|
||||||
|
|
||||||
|
Validate InvalidDomainError is raised
|
||||||
|
"""
|
||||||
|
with less_console_noise():
|
||||||
|
with self.assertRaises(errors.InvalidDomainError):
|
||||||
|
Domain.available("")
|
||||||
|
|
||||||
def test_domain_available_unsuccessful(self):
|
def test_domain_available_unsuccessful(self):
|
||||||
"""
|
"""
|
||||||
Scenario: Testing behavior when registry raises a RegistryError
|
Scenario: Testing behavior when registry raises a RegistryError
|
||||||
|
@ -522,6 +591,7 @@ class TestDomainAvailable(MockEppLib):
|
||||||
def side_effect(_request, cleaned):
|
def side_effect(_request, cleaned):
|
||||||
raise RegistryError(code=ErrorCode.COMMAND_SYNTAX_ERROR)
|
raise RegistryError(code=ErrorCode.COMMAND_SYNTAX_ERROR)
|
||||||
|
|
||||||
|
with less_console_noise():
|
||||||
patcher = patch("registrar.models.domain.registry.send")
|
patcher = patch("registrar.models.domain.registry.send")
|
||||||
mocked_send = patcher.start()
|
mocked_send = patcher.start()
|
||||||
mocked_send.side_effect = side_effect
|
mocked_send.side_effect = side_effect
|
||||||
|
@ -1572,31 +1642,100 @@ class TestRegistrantNameservers(MockEppLib):
|
||||||
self.assertEqual(nameservers[0][1], ["1.1.1.1"])
|
self.assertEqual(nameservers[0][1], ["1.1.1.1"])
|
||||||
patcher.stop()
|
patcher.stop()
|
||||||
|
|
||||||
def test_nameservers_stored_on_fetch_cache(self):
|
def test_nameservers_stored_on_fetch_cache_a_subdomain_with_ip(self):
|
||||||
|
"""
|
||||||
|
#1: Nameserver is a subdomain, and has an IP address
|
||||||
|
referenced by mockDataInfoDomainSubdomainAndIPAddress
|
||||||
|
"""
|
||||||
|
with less_console_noise():
|
||||||
|
# make the domain
|
||||||
|
domain, _ = Domain.objects.get_or_create(name="meow.gov", state=Domain.State.READY)
|
||||||
|
|
||||||
|
# mock the get_or_create methods for Host and HostIP
|
||||||
|
with patch.object(Host.objects, "get_or_create") as mock_host_get_or_create, patch.object(
|
||||||
|
HostIP.objects, "get_or_create"
|
||||||
|
) as mock_host_ip_get_or_create:
|
||||||
|
mock_host_get_or_create.return_value = (Host(domain=domain), True)
|
||||||
|
mock_host_ip_get_or_create.return_value = (HostIP(), True)
|
||||||
|
|
||||||
|
# force fetch_cache to be called, which will return above documented mocked hosts
|
||||||
|
domain.nameservers
|
||||||
|
|
||||||
|
mock_host_get_or_create.assert_called_once_with(domain=domain, name="fake.meow.gov")
|
||||||
|
# Retrieve the mocked_host from the return value of the mock
|
||||||
|
actual_mocked_host, _ = mock_host_get_or_create.return_value
|
||||||
|
mock_host_ip_get_or_create.assert_called_with(address="2.0.0.8", host=actual_mocked_host)
|
||||||
|
self.assertEqual(mock_host_ip_get_or_create.call_count, 1)
|
||||||
|
|
||||||
|
def test_nameservers_stored_on_fetch_cache_a_subdomain_without_ip(self):
|
||||||
|
"""
|
||||||
|
#2: Nameserver is a subdomain, but doesn't have an IP address associated
|
||||||
|
referenced by mockDataInfoDomainSubdomainNoIP
|
||||||
|
"""
|
||||||
|
with less_console_noise():
|
||||||
|
# make the domain
|
||||||
|
domain, _ = Domain.objects.get_or_create(name="subdomainwoip.gov", state=Domain.State.READY)
|
||||||
|
|
||||||
|
# mock the get_or_create methods for Host and HostIP
|
||||||
|
with patch.object(Host.objects, "get_or_create") as mock_host_get_or_create, patch.object(
|
||||||
|
HostIP.objects, "get_or_create"
|
||||||
|
) as mock_host_ip_get_or_create:
|
||||||
|
mock_host_get_or_create.return_value = (Host(domain=domain), True)
|
||||||
|
mock_host_ip_get_or_create.return_value = (HostIP(), True)
|
||||||
|
|
||||||
|
# force fetch_cache to be called, which will return above documented mocked hosts
|
||||||
|
domain.nameservers
|
||||||
|
|
||||||
|
mock_host_get_or_create.assert_called_once_with(domain=domain, name="fake.subdomainwoip.gov")
|
||||||
|
mock_host_ip_get_or_create.assert_not_called()
|
||||||
|
self.assertEqual(mock_host_ip_get_or_create.call_count, 0)
|
||||||
|
|
||||||
|
def test_nameservers_stored_on_fetch_cache_not_subdomain_with_ip(self):
|
||||||
"""
|
"""
|
||||||
Scenario: Nameservers are stored in db when they are retrieved from fetch_cache.
|
Scenario: Nameservers are stored in db when they are retrieved from fetch_cache.
|
||||||
Verify the success of this by asserting get_or_create calls to db.
|
Verify the success of this by asserting get_or_create calls to db.
|
||||||
The mocked data for the EPP calls returns a host name
|
The mocked data for the EPP calls returns a host name
|
||||||
of 'fake.host.com' from InfoDomain and an array of 2 IPs: 1.2.3.4 and 2.3.4.5
|
of 'fake.host.com' from InfoDomain and an array of 2 IPs: 1.2.3.4 and 2.3.4.5
|
||||||
from InfoHost
|
from InfoHost
|
||||||
|
|
||||||
|
#3: Nameserver is not a subdomain, but it does have an IP address returned
|
||||||
|
due to how we set up our defaults
|
||||||
"""
|
"""
|
||||||
with less_console_noise():
|
with less_console_noise():
|
||||||
domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
|
domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
|
||||||
# mock the get_or_create methods for Host and HostIP
|
|
||||||
with patch.object(Host.objects, "get_or_create") as mock_host_get_or_create, patch.object(
|
with patch.object(Host.objects, "get_or_create") as mock_host_get_or_create, patch.object(
|
||||||
HostIP.objects, "get_or_create"
|
HostIP.objects, "get_or_create"
|
||||||
) as mock_host_ip_get_or_create:
|
) as mock_host_ip_get_or_create:
|
||||||
# Set the return value for the mocks
|
mock_host_get_or_create.return_value = (Host(domain=domain), True)
|
||||||
mock_host_get_or_create.return_value = (Host(), True)
|
|
||||||
mock_host_ip_get_or_create.return_value = (HostIP(), True)
|
mock_host_ip_get_or_create.return_value = (HostIP(), True)
|
||||||
|
|
||||||
# force fetch_cache to be called, which will return above documented mocked hosts
|
# force fetch_cache to be called, which will return above documented mocked hosts
|
||||||
domain.nameservers
|
domain.nameservers
|
||||||
# assert that the mocks are called
|
|
||||||
mock_host_get_or_create.assert_called_once_with(domain=domain, name="fake.host.com")
|
mock_host_get_or_create.assert_called_once_with(domain=domain, name="fake.host.com")
|
||||||
# Retrieve the mocked_host from the return value of the mock
|
mock_host_ip_get_or_create.assert_not_called()
|
||||||
actual_mocked_host, _ = mock_host_get_or_create.return_value
|
self.assertEqual(mock_host_ip_get_or_create.call_count, 0)
|
||||||
mock_host_ip_get_or_create.assert_called_with(address="2.3.4.5", host=actual_mocked_host)
|
|
||||||
self.assertEqual(mock_host_ip_get_or_create.call_count, 2)
|
def test_nameservers_stored_on_fetch_cache_not_subdomain_without_ip(self):
|
||||||
|
"""
|
||||||
|
#4: Nameserver is not a subdomain and doesn't have an associated IP address
|
||||||
|
referenced by self.mockDataInfoDomainNotSubdomainNoIP
|
||||||
|
"""
|
||||||
|
with less_console_noise():
|
||||||
|
domain, _ = Domain.objects.get_or_create(name="fakemeow.gov", state=Domain.State.READY)
|
||||||
|
|
||||||
|
with patch.object(Host.objects, "get_or_create") as mock_host_get_or_create, patch.object(
|
||||||
|
HostIP.objects, "get_or_create"
|
||||||
|
) as mock_host_ip_get_or_create:
|
||||||
|
mock_host_get_or_create.return_value = (Host(domain=domain), True)
|
||||||
|
mock_host_ip_get_or_create.return_value = (HostIP(), True)
|
||||||
|
|
||||||
|
# force fetch_cache to be called, which will return above documented mocked hosts
|
||||||
|
domain.nameservers
|
||||||
|
mock_host_get_or_create.assert_called_once_with(domain=domain, name="fake.meow.com")
|
||||||
|
mock_host_ip_get_or_create.assert_not_called()
|
||||||
|
self.assertEqual(mock_host_ip_get_or_create.call_count, 0)
|
||||||
|
|
||||||
@skip("not implemented yet")
|
@skip("not implemented yet")
|
||||||
def test_update_is_unsuccessful(self):
|
def test_update_is_unsuccessful(self):
|
||||||
|
|
|
@ -7,13 +7,14 @@ from registrar.models.domain import Domain
|
||||||
from registrar.models.public_contact import PublicContact
|
from registrar.models.public_contact import PublicContact
|
||||||
from registrar.models.user import User
|
from registrar.models.user import User
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from registrar.models.user_domain_role import UserDomainRole
|
||||||
from registrar.tests.common import MockEppLib
|
from registrar.tests.common import MockEppLib
|
||||||
from registrar.utility.csv_export import (
|
from registrar.utility.csv_export import (
|
||||||
write_header,
|
write_csv,
|
||||||
write_body,
|
|
||||||
get_default_start_date,
|
get_default_start_date,
|
||||||
get_default_end_date,
|
get_default_end_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from unittest.mock import MagicMock, call, mock_open, patch
|
from unittest.mock import MagicMock, call, mock_open, patch
|
||||||
from api.views import get_current_federal, get_current_full
|
from api.views import get_current_federal, get_current_full
|
||||||
|
@ -336,11 +337,30 @@ class ExportDataTest(MockEppLib):
|
||||||
federal_agency="Armed Forces Retirement Home",
|
federal_agency="Armed Forces Retirement Home",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
meoward_user = get_user_model().objects.create(
|
||||||
|
username="meoward_username", first_name="first_meoward", last_name="last_meoward", email="meoward@rocks.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test for more than 1 domain manager
|
||||||
|
_, created = UserDomainRole.objects.get_or_create(
|
||||||
|
user=meoward_user, domain=self.domain_1, role=UserDomainRole.Roles.MANAGER
|
||||||
|
)
|
||||||
|
|
||||||
|
_, created = UserDomainRole.objects.get_or_create(
|
||||||
|
user=self.user, domain=self.domain_1, role=UserDomainRole.Roles.MANAGER
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test for just 1 domain manager
|
||||||
|
_, created = UserDomainRole.objects.get_or_create(
|
||||||
|
user=meoward_user, domain=self.domain_2, role=UserDomainRole.Roles.MANAGER
|
||||||
|
)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
PublicContact.objects.all().delete()
|
PublicContact.objects.all().delete()
|
||||||
Domain.objects.all().delete()
|
Domain.objects.all().delete()
|
||||||
DomainInformation.objects.all().delete()
|
DomainInformation.objects.all().delete()
|
||||||
User.objects.all().delete()
|
User.objects.all().delete()
|
||||||
|
UserDomainRole.objects.all().delete()
|
||||||
super().tearDown()
|
super().tearDown()
|
||||||
|
|
||||||
def test_export_domains_to_writer_security_emails(self):
|
def test_export_domains_to_writer_security_emails(self):
|
||||||
|
@ -383,8 +403,10 @@ class ExportDataTest(MockEppLib):
|
||||||
}
|
}
|
||||||
self.maxDiff = None
|
self.maxDiff = None
|
||||||
# Call the export functions
|
# Call the export functions
|
||||||
write_header(writer, columns)
|
write_csv(
|
||||||
write_body(writer, columns, sort_fields, filter_condition)
|
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
|
||||||
|
)
|
||||||
|
|
||||||
# Reset the CSV file's position to the beginning
|
# Reset the CSV file's position to the beginning
|
||||||
csv_file.seek(0)
|
csv_file.seek(0)
|
||||||
# Read the content into a variable
|
# Read the content into a variable
|
||||||
|
@ -405,7 +427,7 @@ class ExportDataTest(MockEppLib):
|
||||||
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
|
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
|
||||||
self.assertEqual(csv_content, expected_content)
|
self.assertEqual(csv_content, expected_content)
|
||||||
|
|
||||||
def test_write_body(self):
|
def test_write_csv(self):
|
||||||
"""Test that write_body returns the
|
"""Test that write_body returns the
|
||||||
existing domain, test that sort by domain name works,
|
existing domain, test that sort by domain name works,
|
||||||
test that filter works"""
|
test that filter works"""
|
||||||
|
@ -440,8 +462,9 @@ class ExportDataTest(MockEppLib):
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
# Call the export functions
|
# Call the export functions
|
||||||
write_header(writer, columns)
|
write_csv(
|
||||||
write_body(writer, columns, sort_fields, filter_condition)
|
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
|
||||||
|
)
|
||||||
# Reset the CSV file's position to the beginning
|
# Reset the CSV file's position to the beginning
|
||||||
csv_file.seek(0)
|
csv_file.seek(0)
|
||||||
# Read the content into a variable
|
# Read the content into a variable
|
||||||
|
@ -489,8 +512,9 @@ class ExportDataTest(MockEppLib):
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
# Call the export functions
|
# Call the export functions
|
||||||
write_header(writer, columns)
|
write_csv(
|
||||||
write_body(writer, columns, sort_fields, filter_condition)
|
writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True
|
||||||
|
)
|
||||||
# Reset the CSV file's position to the beginning
|
# Reset the CSV file's position to the beginning
|
||||||
csv_file.seek(0)
|
csv_file.seek(0)
|
||||||
# Read the content into a variable
|
# Read the content into a variable
|
||||||
|
@ -567,20 +591,22 @@ class ExportDataTest(MockEppLib):
|
||||||
}
|
}
|
||||||
|
|
||||||
# Call the export functions
|
# Call the export functions
|
||||||
write_header(writer, columns)
|
write_csv(
|
||||||
write_body(
|
|
||||||
writer,
|
writer,
|
||||||
columns,
|
columns,
|
||||||
sort_fields,
|
sort_fields,
|
||||||
filter_condition,
|
filter_condition,
|
||||||
|
get_domain_managers=False,
|
||||||
|
should_write_header=True,
|
||||||
)
|
)
|
||||||
write_body(
|
write_csv(
|
||||||
writer,
|
writer,
|
||||||
columns,
|
columns,
|
||||||
sort_fields_for_deleted_domains,
|
sort_fields_for_deleted_domains,
|
||||||
filter_conditions_for_deleted_domains,
|
filter_conditions_for_deleted_domains,
|
||||||
|
get_domain_managers=False,
|
||||||
|
should_write_header=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Reset the CSV file's position to the beginning
|
# Reset the CSV file's position to the beginning
|
||||||
csv_file.seek(0)
|
csv_file.seek(0)
|
||||||
|
|
||||||
|
@ -606,6 +632,64 @@ class ExportDataTest(MockEppLib):
|
||||||
|
|
||||||
self.assertEqual(csv_content, expected_content)
|
self.assertEqual(csv_content, expected_content)
|
||||||
|
|
||||||
|
def test_export_domains_to_writer_domain_managers(self):
|
||||||
|
"""Test that export_domains_to_writer returns the
|
||||||
|
expected domain managers"""
|
||||||
|
with less_console_noise():
|
||||||
|
# Create a CSV file in memory
|
||||||
|
csv_file = StringIO()
|
||||||
|
writer = csv.writer(csv_file)
|
||||||
|
# Define columns, sort fields, and filter condition
|
||||||
|
|
||||||
|
columns = [
|
||||||
|
"Domain name",
|
||||||
|
"Status",
|
||||||
|
"Expiration date",
|
||||||
|
"Domain type",
|
||||||
|
"Agency",
|
||||||
|
"Organization name",
|
||||||
|
"City",
|
||||||
|
"State",
|
||||||
|
"AO",
|
||||||
|
"AO email",
|
||||||
|
"Security contact email",
|
||||||
|
]
|
||||||
|
sort_fields = ["domain__name"]
|
||||||
|
filter_condition = {
|
||||||
|
"domain__state__in": [
|
||||||
|
Domain.State.READY,
|
||||||
|
Domain.State.DNS_NEEDED,
|
||||||
|
Domain.State.ON_HOLD,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
self.maxDiff = None
|
||||||
|
# Call the export functions
|
||||||
|
write_csv(
|
||||||
|
writer, columns, sort_fields, filter_condition, get_domain_managers=True, should_write_header=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reset the CSV file's position to the beginning
|
||||||
|
csv_file.seek(0)
|
||||||
|
# Read the content into a variable
|
||||||
|
csv_content = csv_file.read()
|
||||||
|
# We expect READY domains,
|
||||||
|
# sorted alphabetially by domain name
|
||||||
|
expected_content = (
|
||||||
|
"Domain name,Status,Expiration date,Domain type,Agency,"
|
||||||
|
"Organization name,City,State,AO,AO email,"
|
||||||
|
"Security contact email,Domain manager email 1,Domain manager email 2,\n"
|
||||||
|
"adomain10.gov,Ready,,Federal,Armed Forces Retirement Home,,,, , ,\n"
|
||||||
|
"adomain2.gov,Dns needed,,Interstate,,,,, , , ,meoward@rocks.com\n"
|
||||||
|
"cdomain1.gov,Ready,,Federal - Executive,World War I Centennial Commission,,,"
|
||||||
|
", , , ,meoward@rocks.com,info@example.com\n"
|
||||||
|
"ddomain3.gov,On hold,,Federal,Armed Forces Retirement Home,,,, , , ,,\n"
|
||||||
|
)
|
||||||
|
# Normalize line endings and remove commas,
|
||||||
|
# spaces and leading/trailing whitespace
|
||||||
|
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
|
||||||
|
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
|
||||||
|
self.assertEqual(csv_content, expected_content)
|
||||||
|
|
||||||
|
|
||||||
class HelperFunctions(TestCase):
|
class HelperFunctions(TestCase):
|
||||||
"""This asserts that 1=1. Its limited usefulness lies in making sure the helper methods stay healthy."""
|
"""This asserts that 1=1. Its limited usefulness lies in making sure the helper methods stay healthy."""
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase, override_settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
from .common import MockEppLib # type: ignore
|
from .common import MockEppLib # type: ignore
|
||||||
|
@ -50,3 +50,32 @@ class TestWithUser(MockEppLib):
|
||||||
DomainApplication.objects.all().delete()
|
DomainApplication.objects.all().delete()
|
||||||
DomainInformation.objects.all().delete()
|
DomainInformation.objects.all().delete()
|
||||||
self.user.delete()
|
self.user.delete()
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnvironmentVariablesEffects(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client()
|
||||||
|
username = "test_user"
|
||||||
|
first_name = "First"
|
||||||
|
last_name = "Last"
|
||||||
|
email = "info@example.com"
|
||||||
|
self.user = get_user_model().objects.create(
|
||||||
|
username=username, first_name=first_name, last_name=last_name, email=email
|
||||||
|
)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
super().tearDown()
|
||||||
|
self.user.delete()
|
||||||
|
|
||||||
|
@override_settings(IS_PRODUCTION=True)
|
||||||
|
def test_production_environment(self):
|
||||||
|
"""No banner on prod."""
|
||||||
|
home_page = self.client.get("/")
|
||||||
|
self.assertNotContains(home_page, "You are on a test site.")
|
||||||
|
|
||||||
|
@override_settings(IS_PRODUCTION=False)
|
||||||
|
def test_non_production_environment(self):
|
||||||
|
"""Banner on non-prod."""
|
||||||
|
home_page = self.client.get("/")
|
||||||
|
self.assertContains(home_page, "You are on a test site.")
|
||||||
|
|
|
@ -821,14 +821,15 @@ class TestDomainNameservers(TestDomainOverview):
|
||||||
nameserver1 = "ns1.igorville.gov"
|
nameserver1 = "ns1.igorville.gov"
|
||||||
nameserver2 = "ns2.igorville.gov"
|
nameserver2 = "ns2.igorville.gov"
|
||||||
valid_ip = "1.1. 1.1"
|
valid_ip = "1.1. 1.1"
|
||||||
# initial nameservers page has one server with two ips
|
valid_ip_2 = "2.2. 2.2"
|
||||||
# have to throw an error in order to test that the whitespace has been stripped from ip
|
# have to throw an error in order to test that the whitespace has been stripped from ip
|
||||||
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}))
|
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}))
|
||||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
# attempt to submit the form without one host and an ip with whitespace
|
# attempt to submit the form without one host and an ip with whitespace
|
||||||
nameservers_page.form["form-0-server"] = nameserver1
|
nameservers_page.form["form-0-server"] = nameserver1
|
||||||
nameservers_page.form["form-1-ip"] = valid_ip
|
nameservers_page.form["form-0-ip"] = valid_ip
|
||||||
|
nameservers_page.form["form-1-ip"] = valid_ip_2
|
||||||
nameservers_page.form["form-1-server"] = nameserver2
|
nameservers_page.form["form-1-server"] = nameserver2
|
||||||
with less_console_noise(): # swallow log warning message
|
with less_console_noise(): # swallow log warning message
|
||||||
result = nameservers_page.form.submit()
|
result = nameservers_page.form.submit()
|
||||||
|
@ -937,15 +938,14 @@ class TestDomainNameservers(TestDomainOverview):
|
||||||
nameserver1 = "ns1.igorville.gov"
|
nameserver1 = "ns1.igorville.gov"
|
||||||
nameserver2 = "ns2.igorville.gov"
|
nameserver2 = "ns2.igorville.gov"
|
||||||
valid_ip = "127.0.0.1"
|
valid_ip = "127.0.0.1"
|
||||||
# initial nameservers page has one server with two ips
|
valid_ip_2 = "128.0.0.2"
|
||||||
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}))
|
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}))
|
||||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
# attempt to submit the form without two hosts, both subdomains,
|
|
||||||
# only one has ips
|
|
||||||
nameservers_page.form["form-0-server"] = nameserver1
|
nameservers_page.form["form-0-server"] = nameserver1
|
||||||
|
nameservers_page.form["form-0-ip"] = valid_ip
|
||||||
nameservers_page.form["form-1-server"] = nameserver2
|
nameservers_page.form["form-1-server"] = nameserver2
|
||||||
nameservers_page.form["form-1-ip"] = valid_ip
|
nameservers_page.form["form-1-ip"] = valid_ip_2
|
||||||
with less_console_noise(): # swallow log warning message
|
with less_console_noise(): # swallow log warning message
|
||||||
result = nameservers_page.form.submit()
|
result = nameservers_page.form.submit()
|
||||||
# form submission was a successful post, response should be a 302
|
# form submission was a successful post, response should be a 302
|
||||||
|
|
|
@ -17,8 +17,9 @@ logger = logging.getLogger(__name__)
|
||||||
def write_header(writer, columns):
|
def write_header(writer, columns):
|
||||||
"""
|
"""
|
||||||
Receives params from the parent methods and outputs a CSV with a header row.
|
Receives params from the parent methods and outputs a CSV with a header row.
|
||||||
Works with write_header as longas the same writer object is passed.
|
Works with write_header as long as the same writer object is passed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
writer.writerow(columns)
|
writer.writerow(columns)
|
||||||
|
|
||||||
|
|
||||||
|
@ -43,7 +44,7 @@ def get_domain_infos(filter_condition, sort_fields):
|
||||||
return domain_infos_cleaned
|
return domain_infos_cleaned
|
||||||
|
|
||||||
|
|
||||||
def parse_row(columns, domain_info: DomainInformation, security_emails_dict=None):
|
def parse_row(columns, domain_info: DomainInformation, security_emails_dict=None, get_domain_managers=False):
|
||||||
"""Given a set of columns, generate a new row from cleaned column data"""
|
"""Given a set of columns, generate a new row from cleaned column data"""
|
||||||
|
|
||||||
# Domain should never be none when parsing this information
|
# Domain should never be none when parsing this information
|
||||||
|
@ -77,6 +78,8 @@ def parse_row(columns, domain_info: DomainInformation, security_emails_dict=None
|
||||||
# create a dictionary of fields which can be included in output
|
# create a dictionary of fields which can be included in output
|
||||||
FIELDS = {
|
FIELDS = {
|
||||||
"Domain name": domain.name,
|
"Domain name": domain.name,
|
||||||
|
"Status": domain.get_state_display(),
|
||||||
|
"Expiration date": domain.expiration_date,
|
||||||
"Domain type": domain_type,
|
"Domain type": domain_type,
|
||||||
"Agency": domain_info.federal_agency,
|
"Agency": domain_info.federal_agency,
|
||||||
"Organization name": domain_info.organization_name,
|
"Organization name": domain_info.organization_name,
|
||||||
|
@ -85,39 +88,27 @@ def parse_row(columns, domain_info: DomainInformation, security_emails_dict=None
|
||||||
"AO": domain_info.ao, # type: ignore
|
"AO": domain_info.ao, # type: ignore
|
||||||
"AO email": domain_info.authorizing_official.email if domain_info.authorizing_official else " ",
|
"AO email": domain_info.authorizing_official.email if domain_info.authorizing_official else " ",
|
||||||
"Security contact email": security_email,
|
"Security contact email": security_email,
|
||||||
"Status": domain.get_state_display(),
|
|
||||||
"Expiration date": domain.expiration_date,
|
|
||||||
"Created at": domain.created_at,
|
"Created at": domain.created_at,
|
||||||
"First ready": domain.first_ready,
|
"First ready": domain.first_ready,
|
||||||
"Deleted": domain.deleted,
|
"Deleted": domain.deleted,
|
||||||
}
|
}
|
||||||
|
|
||||||
# user_emails = [user.email for user in domain.permissions]
|
if get_domain_managers:
|
||||||
|
# Get each domain managers email and add to list
|
||||||
|
dm_emails = [dm.user.email for dm in domain.permissions.all()]
|
||||||
|
|
||||||
# Dynamically add user emails to the FIELDS dictionary
|
# Set up the "matching header" + row field data
|
||||||
# for i, user_email in enumerate(user_emails, start=1):
|
for i, dm_email in enumerate(dm_emails, start=1):
|
||||||
# FIELDS[f"User{i} email"] = user_email
|
FIELDS[f"Domain manager email {i}"] = dm_email
|
||||||
|
|
||||||
row = [FIELDS.get(column, "") for column in columns]
|
row = [FIELDS.get(column, "") for column in columns]
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
def write_body(
|
def _get_security_emails(sec_contact_ids):
|
||||||
writer,
|
|
||||||
columns,
|
|
||||||
sort_fields,
|
|
||||||
filter_condition,
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Receives params from the parent methods and outputs a CSV with fltered and sorted domains.
|
Retrieve security contact emails for the given security contact IDs.
|
||||||
Works with write_header as longas the same writer object is passed.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Get the domainInfos
|
|
||||||
all_domain_infos = get_domain_infos(filter_condition, sort_fields)
|
|
||||||
|
|
||||||
# Store all security emails to avoid epp calls or excessive filters
|
|
||||||
sec_contact_ids = all_domain_infos.values_list("domain__security_contact_registry_id", flat=True)
|
|
||||||
security_emails_dict = {}
|
security_emails_dict = {}
|
||||||
public_contacts = (
|
public_contacts = (
|
||||||
PublicContact.objects.only("email", "domain__name")
|
PublicContact.objects.only("email", "domain__name")
|
||||||
|
@ -133,24 +124,55 @@ def write_body(
|
||||||
else:
|
else:
|
||||||
logger.warning("csv_export -> Domain was none for PublicContact")
|
logger.warning("csv_export -> Domain was none for PublicContact")
|
||||||
|
|
||||||
# all_user_nums = 0
|
return security_emails_dict
|
||||||
# for domain_info in all_domain_infos:
|
|
||||||
# user_num = len(domain_info.domain.permissions)
|
|
||||||
# all_user_nums.append(user_num)
|
|
||||||
|
|
||||||
# if user_num > highest_user_nums:
|
|
||||||
# highest_user_nums = user_num
|
|
||||||
|
|
||||||
# Build the header here passing to it highest_user_nums
|
def update_columns_with_domain_managers(columns, max_dm_count):
|
||||||
|
"""
|
||||||
|
Update the columns list to include "Domain manager email {#}" headers
|
||||||
|
based on the maximum domain manager count.
|
||||||
|
"""
|
||||||
|
for i in range(1, max_dm_count + 1):
|
||||||
|
columns.append(f"Domain manager email {i}")
|
||||||
|
|
||||||
|
|
||||||
|
def write_csv(
|
||||||
|
writer,
|
||||||
|
columns,
|
||||||
|
sort_fields,
|
||||||
|
filter_condition,
|
||||||
|
get_domain_managers=False,
|
||||||
|
should_write_header=True,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Receives params from the parent methods and outputs a CSV with fltered and sorted domains.
|
||||||
|
Works with write_header as longas the same writer object is passed.
|
||||||
|
get_domain_managers: Conditional bc we only use domain manager info for export_data_full_to_csv
|
||||||
|
should_write_header: Conditional bc export_data_growth_to_csv calls write_body twice
|
||||||
|
"""
|
||||||
|
|
||||||
|
all_domain_infos = get_domain_infos(filter_condition, sort_fields)
|
||||||
|
|
||||||
|
# Store all security emails to avoid epp calls or excessive filters
|
||||||
|
sec_contact_ids = all_domain_infos.values_list("domain__security_contact_registry_id", flat=True)
|
||||||
|
|
||||||
|
security_emails_dict = _get_security_emails(sec_contact_ids)
|
||||||
|
|
||||||
# Reduce the memory overhead when performing the write operation
|
# Reduce the memory overhead when performing the write operation
|
||||||
paginator = Paginator(all_domain_infos, 1000)
|
paginator = Paginator(all_domain_infos, 1000)
|
||||||
|
|
||||||
|
if get_domain_managers and len(all_domain_infos) > 0:
|
||||||
|
# We want to get the max amont of domain managers an
|
||||||
|
# account has to set the column header dynamically
|
||||||
|
max_dm_count = max(len(domain_info.domain.permissions.all()) for domain_info in all_domain_infos)
|
||||||
|
update_columns_with_domain_managers(columns, max_dm_count)
|
||||||
|
|
||||||
for page_num in paginator.page_range:
|
for page_num in paginator.page_range:
|
||||||
page = paginator.page(page_num)
|
page = paginator.page(page_num)
|
||||||
rows = []
|
rows = []
|
||||||
for domain_info in page.object_list:
|
for domain_info in page.object_list:
|
||||||
try:
|
try:
|
||||||
row = parse_row(columns, domain_info, security_emails_dict)
|
row = parse_row(columns, domain_info, security_emails_dict, get_domain_managers)
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# This should not happen. If it does, just skip this row.
|
# This should not happen. If it does, just skip this row.
|
||||||
|
@ -158,6 +180,9 @@ def write_body(
|
||||||
logger.error("csv_export -> Error when parsing row, domain was None")
|
logger.error("csv_export -> Error when parsing row, domain was None")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if should_write_header:
|
||||||
|
write_header(writer, columns)
|
||||||
|
|
||||||
writer.writerows(rows)
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
|
||||||
|
@ -168,6 +193,8 @@ def export_data_type_to_csv(csv_file):
|
||||||
# define columns to include in export
|
# define columns to include in export
|
||||||
columns = [
|
columns = [
|
||||||
"Domain name",
|
"Domain name",
|
||||||
|
"Status",
|
||||||
|
"Expiration date",
|
||||||
"Domain type",
|
"Domain type",
|
||||||
"Agency",
|
"Agency",
|
||||||
"Organization name",
|
"Organization name",
|
||||||
|
@ -176,9 +203,9 @@ def export_data_type_to_csv(csv_file):
|
||||||
"AO",
|
"AO",
|
||||||
"AO email",
|
"AO email",
|
||||||
"Security contact email",
|
"Security contact email",
|
||||||
"Status",
|
# For domain manager we are pass it in as a parameter below in write_body
|
||||||
"Expiration date",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Coalesce is used to replace federal_type of None with ZZZZZ
|
# Coalesce is used to replace federal_type of None with ZZZZZ
|
||||||
sort_fields = [
|
sort_fields = [
|
||||||
"organization_type",
|
"organization_type",
|
||||||
|
@ -193,8 +220,7 @@ def export_data_type_to_csv(csv_file):
|
||||||
Domain.State.ON_HOLD,
|
Domain.State.ON_HOLD,
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
write_header(writer, columns)
|
write_csv(writer, columns, sort_fields, filter_condition, get_domain_managers=True, should_write_header=True)
|
||||||
write_body(writer, columns, sort_fields, filter_condition)
|
|
||||||
|
|
||||||
|
|
||||||
def export_data_full_to_csv(csv_file):
|
def export_data_full_to_csv(csv_file):
|
||||||
|
@ -225,8 +251,7 @@ def export_data_full_to_csv(csv_file):
|
||||||
Domain.State.ON_HOLD,
|
Domain.State.ON_HOLD,
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
write_header(writer, columns)
|
write_csv(writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True)
|
||||||
write_body(writer, columns, sort_fields, filter_condition)
|
|
||||||
|
|
||||||
|
|
||||||
def export_data_federal_to_csv(csv_file):
|
def export_data_federal_to_csv(csv_file):
|
||||||
|
@ -258,8 +283,7 @@ def export_data_federal_to_csv(csv_file):
|
||||||
Domain.State.ON_HOLD,
|
Domain.State.ON_HOLD,
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
write_header(writer, columns)
|
write_csv(writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True)
|
||||||
write_body(writer, columns, sort_fields, filter_condition)
|
|
||||||
|
|
||||||
|
|
||||||
def get_default_start_date():
|
def get_default_start_date():
|
||||||
|
@ -326,6 +350,12 @@ def export_data_growth_to_csv(csv_file, start_date, end_date):
|
||||||
"domain__deleted__gte": start_date_formatted,
|
"domain__deleted__gte": start_date_formatted,
|
||||||
}
|
}
|
||||||
|
|
||||||
write_header(writer, columns)
|
write_csv(writer, columns, sort_fields, filter_condition, get_domain_managers=False, should_write_header=True)
|
||||||
write_body(writer, columns, sort_fields, filter_condition)
|
write_csv(
|
||||||
write_body(writer, columns, sort_fields_for_deleted_domains, filter_condition_for_deleted_domains)
|
writer,
|
||||||
|
columns,
|
||||||
|
sort_fields_for_deleted_domains,
|
||||||
|
filter_condition_for_deleted_domains,
|
||||||
|
get_domain_managers=False,
|
||||||
|
should_write_header=False,
|
||||||
|
)
|
||||||
|
|
|
@ -15,7 +15,7 @@ class EmailSendingError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def send_templated_email(template_name: str, subject_template_name: str, to_address: str, context={}):
|
def send_templated_email(template_name: str, subject_template_name: str, to_address: str, bcc_address="", context={}):
|
||||||
"""Send an email built from a template to one email address.
|
"""Send an email built from a template to one email address.
|
||||||
|
|
||||||
template_name and subject_template_name are relative to the same template
|
template_name and subject_template_name are relative to the same template
|
||||||
|
@ -40,10 +40,14 @@ def send_templated_email(template_name: str, subject_template_name: str, to_addr
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise EmailSendingError("Could not access the SES client.") from exc
|
raise EmailSendingError("Could not access the SES client.") from exc
|
||||||
|
|
||||||
|
destination = {"ToAddresses": [to_address]}
|
||||||
|
if bcc_address:
|
||||||
|
destination["BccAddresses"] = [bcc_address]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ses_client.send_email(
|
ses_client.send_email(
|
||||||
FromEmailAddress=settings.DEFAULT_FROM_EMAIL,
|
FromEmailAddress=settings.DEFAULT_FROM_EMAIL,
|
||||||
Destination={"ToAddresses": [to_address]},
|
Destination=destination,
|
||||||
Content={
|
Content={
|
||||||
"Simple": {
|
"Simple": {
|
||||||
"Subject": {"Data": subject},
|
"Subject": {"Data": subject},
|
||||||
|
|
|
@ -14,6 +14,7 @@ from django.http import HttpResponseRedirect
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.views.generic.edit import FormMixin
|
from django.views.generic.edit import FormMixin
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from registrar.models import (
|
from registrar.models import (
|
||||||
Domain,
|
Domain,
|
||||||
|
@ -707,7 +708,7 @@ class DomainAddUserView(DomainFormBaseView):
|
||||||
adding a success message to the view if the email sending succeeds"""
|
adding a success message to the view if the email sending succeeds"""
|
||||||
|
|
||||||
# Set a default email address to send to for staff
|
# Set a default email address to send to for staff
|
||||||
requestor_email = "help@get.gov"
|
requestor_email = settings.DEFAULT_FROM_EMAIL
|
||||||
|
|
||||||
# Check if the email requestor has a valid email address
|
# Check if the email requestor has a valid email address
|
||||||
if not requestor.is_staff and requestor.email is not None and requestor.email.strip() != "":
|
if not requestor.is_staff and requestor.email is not None and requestor.email.strip() != "":
|
||||||
|
|
16
src/registrar/widgets.py
Normal file
16
src/registrar/widgets.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
# widgets.py
|
||||||
|
|
||||||
|
from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
|
|
||||||
|
class NoAutocompleteFilteredSelectMultiple(FilteredSelectMultiple):
|
||||||
|
"""Firefox and Edge are unable to correctly initialize the source select in filter_horizontal
|
||||||
|
widgets. We add the attribute autocomplete=off to fix that."""
|
||||||
|
|
||||||
|
def render(self, name, value, attrs=None, renderer=None):
|
||||||
|
if attrs is None:
|
||||||
|
attrs = {}
|
||||||
|
attrs["autocomplete"] = "off"
|
||||||
|
output = super().render(name, value, attrs=attrs, renderer=renderer)
|
||||||
|
return mark_safe(output) # nosec
|
Loading…
Add table
Add a link
Reference in a new issue