Merge main

This commit is contained in:
Rachid Mrad 2024-02-27 18:47:38 -05:00
commit f8a95564a9
No known key found for this signature in database
30 changed files with 3007 additions and 1200 deletions

View file

@ -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"

View file

@ -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"

View file

@ -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")

View file

@ -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.
@ -41,8 +68,8 @@ function openInNewTab(el, removeAttribute = false){
let domainFormElement = document.getElementById("domain_form"); let domainFormElement = document.getElementById("domain_form");
let domainSubmitButton = document.getElementById("manageDomainSubmitButton"); let domainSubmitButton = document.getElementById("manageDomainSubmitButton");
if(domainSubmitButton && domainFormElement){ if(domainSubmitButton && domainFormElement){
domainSubmitButton.addEventListener("mouseover", () => openInNewTab(domainFormElement, true)); domainSubmitButton.addEventListener("mouseover", () => openInNewTab(domainFormElement, true));
domainSubmitButton.addEventListener("mouseout", () => openInNewTab(domainFormElement, false)); domainSubmitButton.addEventListener("mouseout", () => openInNewTab(domainFormElement, false));
} }
} }
@ -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" });
})();

View file

@ -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;
}
}

View file

@ -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

View file

@ -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,

View file

@ -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,
),
),
]

View file

@ -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:

View file

@ -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",

View file

@ -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)

View file

@ -33,11 +33,12 @@ 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)
try: if domain != "":
if not check_domain_available(domain): try:
raise errors.DomainUnavailableError() if not check_domain_available(domain):
except RegistryError as err: raise errors.DomainUnavailableError()
raise errors.RegistrySystemError() from err except RegistryError as err:
raise errors.RegistrySystemError() from err
return domain return domain
@staticmethod @staticmethod
@ -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

View 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")

View file

@ -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 %}

View file

@ -15,7 +15,15 @@
{% if filters %} {% if filters %}
filtered by filtered by
{% for filter_param in filters %} {% for filter_param in filters %}
{{ filter_param.parameter_name }} = {{ filter_param.parameter_value }} {% 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 }}
{% endif %}
{% if not forloop.last %}, {% endif %} {% if not forloop.last %}, {% endif %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}

View file

@ -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">
<input id="manageDomainSubmitButton" type="submit" value="Manage domain" name="_edit_domain"> <div class="flex-align-self-start button-list-mobile">
<input type="submit" value="Get registry status" name="_get_status"> <input id="manageDomainSubmitButton" type="submit" value="Manage domain" name="_edit_domain">
<div class="spacer"></div> {# Dja has margin styles defined on inputs as is. Lets work with it, rather than fight it. #}
{% if original.state == original.State.READY %} <span class="mini-spacer"></span>
<input type="submit" value="Place hold" name="_place_client_hold" class="custom-link-button"> <input type="submit" value="Get registry status" name="_get_status">
{% elif original.state == original.State.ON_HOLD %} </div>
<input type="submit" value="Remove hold" name="_remove_client_hold" class="custom-link-button"> <div class="desktop:flex-align-self-end">
{% endif %} {% if original.state != original.State.DELETED %}
{% if original.state == original.State.READY or original.state == original.State.ON_HOLD %} <a
<span> | </span> class="text-middle"
{% endif %} href="#toggle-extend-expiration-alert"
{% if original.state != original.State.DELETED %} aria-controls="toggle-extend-expiration-alert"
<input type="submit" value="Remove from registry" name="_delete_domain" class="custom-link-button"> 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 %}
<input type="submit" value="Place hold" name="_place_client_hold" class="custom-link-button">
{% elif original.state == original.State.ON_HOLD %}
<input type="submit" value="Remove hold" name="_remove_client_hold" class="custom-link-button">
{% endif %}
{% if original.state == original.State.READY or original.state == original.State.ON_HOLD %}
<span class="margin-left-05 margin-right-05 text-middle"> | </span>
{% endif %}
{% if original.state != original.State.DELETED %}
<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 %}

View file

@ -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 didnt 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 cant 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 dont believe youre 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 cant 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.

View file

@ -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"
> >

View file

@ -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,11 +1144,19 @@ class MockEppLib(TestCase):
res_data=[self.mockMaximumRenewedDomainExpDate], res_data=[self.mockMaximumRenewedDomainExpDate],
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY, code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
) )
else: elif getattr(_request, "name", None) == "fake.gov":
return MagicMock( period = getattr(_request, "period", None)
res_data=[self.mockRenewedDomainExpDate], extension_period = getattr(period, "length", None)
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY, if extension_period == 2:
) return MagicMock(
res_data=[self.mockButtonRenewedDomainExpDate],
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
)
else:
return MagicMock(
res_data=[self.mockRenewedDomainExpDate],
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
)
def mockInfoDomainCommands(self, _request, cleaned): def mockInfoDomainCommands(self, _request, cleaned):
request_name = getattr(_request, "name", None) request_name = getattr(_request, "name", None)
@ -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

View file

@ -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.")

View file

@ -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():

View file

@ -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,21 +321,21 @@ 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
""" """
draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov") with less_console_noise():
user, _ = User.objects.get_or_create() draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
application = DomainApplication.objects.create(creator=user, requested_domain=draft_domain) user, _ = User.objects.get_or_create()
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
application.approve() application.approve()
# should have information present for this domain # should have information present for this domain
domain = Domain.objects.get(name="igorville.gov") domain = Domain.objects.get(name="igorville.gov")
self.assertTrue(domain) self.assertTrue(domain)
self.mockedSendFunction.assert_not_called() self.mockedSendFunction.assert_not_called()
def test_accessing_domain_properties_creates_domain_in_registry(self): def test_accessing_domain_properties_creates_domain_in_registry(self):
""" """
@ -293,33 +346,34 @@ 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
""" """
domain = Domain.objects.create(name="beef-tongue.gov") with less_console_noise():
# trigger getter domain = Domain.objects.create(name="beef-tongue.gov")
_ = domain.statuses # trigger getter
_ = domain.statuses
# contacts = PublicContact.objects.filter(domain=domain, # contacts = PublicContact.objects.filter(domain=domain,
# type=PublicContact.ContactTypeChoices.REGISTRANT).get() # type=PublicContact.ContactTypeChoices.REGISTRANT).get()
# Called in _fetch_cache # Called in _fetch_cache
self.mockedSendFunction.assert_has_calls( self.mockedSendFunction.assert_has_calls(
[ [
# TODO: due to complexity of the test, will return to it in # TODO: due to complexity of the test, will return to it in
# a future ticket # a future ticket
# call( # call(
# commands.CreateDomain(name="beef-tongue.gov", # commands.CreateDomain(name="beef-tongue.gov",
# id=contact.registry_id, auth_info=None), # id=contact.registry_id, auth_info=None),
# cleaned=True, # cleaned=True,
# ), # ),
call( call(
commands.InfoDomain(name="beef-tongue.gov", auth_info=None), commands.InfoDomain(name="beef-tongue.gov", auth_info=None),
cleaned=True, cleaned=True,
), ),
], ],
any_order=False, # Ensure calls are in the specified order any_order=False, # Ensure calls are in the specified order
) )
self.assertEqual(domain.state, Domain.State.UNKNOWN) self.assertEqual(domain.state, Domain.State.UNKNOWN)
self.assertEqual(domain.is_active(), False) self.assertEqual(domain.is_active(), False)
@skip("assertion broken with mock addition") @skip("assertion broken with mock addition")
def test_empty_domain_creation(self): def test_empty_domain_creation(self):
@ -329,7 +383,8 @@ 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."""
Domain.objects.create(name="igorville.gov") with less_console_noise():
Domain.objects.create(name="igorville.gov")
@skip("assertion broken with mock addition") @skip("assertion broken with mock addition")
def test_duplicate_creation(self): def test_duplicate_creation(self):
@ -451,23 +506,24 @@ 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)],
) )
patcher = patch("registrar.models.domain.registry.send") with less_console_noise():
mocked_send = patcher.start() patcher = patch("registrar.models.domain.registry.send")
mocked_send.side_effect = side_effect mocked_send = patcher.start()
mocked_send.side_effect = side_effect
available = Domain.available("available.gov") available = Domain.available("available.gov")
mocked_send.assert_has_calls( mocked_send.assert_has_calls(
[ [
call( call(
commands.CheckDomain( commands.CheckDomain(
["available.gov"], ["available.gov"],
), ),
cleaned=True, cleaned=True,
) )
] ]
) )
self.assertTrue(available) self.assertTrue(available)
patcher.stop() patcher.stop()
def test_domain_unavailable(self): def test_domain_unavailable(self):
""" """
@ -484,33 +540,46 @@ 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")],
) )
patcher = patch("registrar.models.domain.registry.send") with less_console_noise():
mocked_send = patcher.start() patcher = patch("registrar.models.domain.registry.send")
mocked_send.side_effect = side_effect mocked_send = patcher.start()
mocked_send.side_effect = side_effect
available = Domain.available("unavailable.gov") available = Domain.available("unavailable.gov")
mocked_send.assert_has_calls( mocked_send.assert_has_calls(
[ [
call( call(
commands.CheckDomain( commands.CheckDomain(
["unavailable.gov"], ["unavailable.gov"],
), ),
cleaned=True, cleaned=True,
) )
] ]
) )
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():
Domain.available("invalid-string") with self.assertRaises(errors.InvalidDomainError):
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):
""" """
@ -522,13 +591,14 @@ 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)
patcher = patch("registrar.models.domain.registry.send") with less_console_noise():
mocked_send = patcher.start() patcher = patch("registrar.models.domain.registry.send")
mocked_send.side_effect = side_effect mocked_send = patcher.start()
mocked_send.side_effect = side_effect
with self.assertRaises(RegistryError): with self.assertRaises(RegistryError):
Domain.available("raises-error.gov") Domain.available("raises-error.gov")
patcher.stop() patcher.stop()
class TestRegistrantContacts(MockEppLib): class TestRegistrantContacts(MockEppLib):
@ -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):

View file

@ -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."""

View file

@ -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.")

View file

@ -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

View file

@ -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,7 +180,10 @@ 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
writer.writerows(rows) if should_write_header:
write_header(writer, columns)
writer.writerows(rows)
def export_data_type_to_csv(csv_file): def export_data_type_to_csv(csv_file):
@ -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,
)

View file

@ -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},

View file

@ -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
View 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