mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-03 09:43:33 +02:00
Merge remote-tracking branch 'origin' into rh/482-youve-been-added-to-domain
This commit is contained in:
commit
acf5a8715d
13 changed files with 899 additions and 374 deletions
|
@ -130,6 +130,7 @@ class MyUserAdmin(BaseUserAdmin):
|
|||
inlines = [UserContactInline]
|
||||
|
||||
list_display = (
|
||||
"username",
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
|
@ -159,10 +160,51 @@ class MyUserAdmin(BaseUserAdmin):
|
|||
("Important dates", {"fields": ("last_login", "date_joined")}),
|
||||
)
|
||||
|
||||
analyst_fieldsets = (
|
||||
(
|
||||
None,
|
||||
{"fields": ("password", "status")},
|
||||
),
|
||||
("Personal Info", {"fields": ("first_name", "last_name", "email")}),
|
||||
(
|
||||
"Permissions",
|
||||
{
|
||||
"fields": (
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
)
|
||||
},
|
||||
),
|
||||
("Important dates", {"fields": ("last_login", "date_joined")}),
|
||||
)
|
||||
|
||||
analyst_readonly_fields = [
|
||||
"password",
|
||||
"Personal Info",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email",
|
||||
"Permissions",
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"Important dates",
|
||||
"last_login",
|
||||
"date_joined",
|
||||
]
|
||||
|
||||
def get_list_display(self, request):
|
||||
if not request.user.is_superuser:
|
||||
# Customize the list display for staff users
|
||||
return ("email", "first_name", "last_name", "is_staff", "is_superuser")
|
||||
return (
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"status",
|
||||
)
|
||||
|
||||
# Use the default list display for non-staff users
|
||||
return super().get_list_display(request)
|
||||
|
@ -171,11 +213,18 @@ class MyUserAdmin(BaseUserAdmin):
|
|||
if not request.user.is_superuser:
|
||||
# If the user doesn't have permission to change the model,
|
||||
# show a read-only fieldset
|
||||
return ((None, {"fields": []}),)
|
||||
return self.analyst_fieldsets
|
||||
|
||||
# If the user has permission to change the model, show all fields
|
||||
return super().get_fieldsets(request, obj)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
if request.user.is_superuser:
|
||||
return () # No read-only fields for superusers
|
||||
elif request.user.is_staff:
|
||||
return self.analyst_readonly_fields # Read-only fields for staff
|
||||
return () # No read-only fields for other users
|
||||
|
||||
|
||||
class HostIPInline(admin.StackedInline):
|
||||
"""Edit an ip address on the host page."""
|
||||
|
@ -189,9 +238,428 @@ class MyHostAdmin(AuditedAdmin):
|
|||
inlines = [HostIPInline]
|
||||
|
||||
|
||||
class ContactAdmin(ListHeaderAdmin):
|
||||
"""Custom contact admin class to add search."""
|
||||
|
||||
search_fields = ["email", "first_name", "last_name"]
|
||||
search_help_text = "Search by firstname, lastname or email."
|
||||
list_display = [
|
||||
"contact",
|
||||
"email",
|
||||
]
|
||||
|
||||
# We name the custom prop 'contact' because linter
|
||||
# is not allowing a short_description attr on it
|
||||
# This gets around the linter limitation, for now.
|
||||
def contact(self, obj: models.Contact):
|
||||
"""Duplicate the contact _str_"""
|
||||
if obj.first_name or obj.last_name:
|
||||
return obj.get_formatted_name()
|
||||
elif obj.email:
|
||||
return obj.email
|
||||
elif obj.pk:
|
||||
return str(obj.pk)
|
||||
else:
|
||||
return ""
|
||||
|
||||
contact.admin_order_field = "first_name" # type: ignore
|
||||
|
||||
|
||||
class WebsiteAdmin(ListHeaderAdmin):
|
||||
"""Custom website admin class."""
|
||||
|
||||
# Search
|
||||
search_fields = [
|
||||
"website",
|
||||
]
|
||||
search_help_text = "Search by website."
|
||||
|
||||
|
||||
class UserDomainRoleAdmin(ListHeaderAdmin):
|
||||
"""Custom domain role admin class."""
|
||||
|
||||
# Columns
|
||||
list_display = [
|
||||
"user",
|
||||
"domain",
|
||||
"role",
|
||||
]
|
||||
|
||||
# Search
|
||||
search_fields = [
|
||||
"user__first_name",
|
||||
"user__last_name",
|
||||
"domain__name",
|
||||
"role",
|
||||
]
|
||||
search_help_text = "Search by user, domain, or role."
|
||||
|
||||
|
||||
class DomainInvitationAdmin(ListHeaderAdmin):
|
||||
"""Custom domain invitation admin class."""
|
||||
|
||||
# Columns
|
||||
list_display = [
|
||||
"email",
|
||||
"domain",
|
||||
"status",
|
||||
]
|
||||
|
||||
# Search
|
||||
search_fields = [
|
||||
"email",
|
||||
"domain__name",
|
||||
]
|
||||
search_help_text = "Search by email or domain."
|
||||
|
||||
|
||||
class DomainInformationAdmin(ListHeaderAdmin):
|
||||
"""Customize domain information admin class."""
|
||||
|
||||
# Columns
|
||||
list_display = [
|
||||
"domain",
|
||||
"organization_type",
|
||||
"created_at",
|
||||
"submitter",
|
||||
]
|
||||
|
||||
# Filters
|
||||
list_filter = ["organization_type"]
|
||||
|
||||
# Search
|
||||
search_fields = [
|
||||
"domain__name",
|
||||
]
|
||||
search_help_text = "Search by domain."
|
||||
|
||||
fieldsets = [
|
||||
(None, {"fields": ["creator", "domain_application"]}),
|
||||
(
|
||||
"Type of organization",
|
||||
{
|
||||
"fields": [
|
||||
"organization_type",
|
||||
"federally_recognized_tribe",
|
||||
"state_recognized_tribe",
|
||||
"tribe_name",
|
||||
"federal_agency",
|
||||
"federal_type",
|
||||
"is_election_board",
|
||||
"about_your_organization",
|
||||
]
|
||||
},
|
||||
),
|
||||
(
|
||||
"Organization name and mailing address",
|
||||
{
|
||||
"fields": [
|
||||
"organization_name",
|
||||
"address_line1",
|
||||
"address_line2",
|
||||
"city",
|
||||
"state_territory",
|
||||
"zipcode",
|
||||
"urbanization",
|
||||
]
|
||||
},
|
||||
),
|
||||
("Authorizing official", {"fields": ["authorizing_official"]}),
|
||||
(".gov domain", {"fields": ["domain"]}),
|
||||
("Your contact information", {"fields": ["submitter"]}),
|
||||
("Other employees from your organization?", {"fields": ["other_contacts"]}),
|
||||
(
|
||||
"No other employees from your organization?",
|
||||
{"fields": ["no_other_contacts_rationale"]},
|
||||
),
|
||||
("Anything else we should know?", {"fields": ["anything_else"]}),
|
||||
(
|
||||
"Requirements for operating .gov domains",
|
||||
{"fields": ["is_policy_acknowledged"]},
|
||||
),
|
||||
]
|
||||
|
||||
# Read only that we'll leverage for CISA Analysts
|
||||
analyst_readonly_fields = [
|
||||
"creator",
|
||||
"type_of_work",
|
||||
"more_organization_information",
|
||||
"address_line1",
|
||||
"address_line2",
|
||||
"zipcode",
|
||||
"domain",
|
||||
"submitter",
|
||||
"no_other_contacts_rationale",
|
||||
"anything_else",
|
||||
"is_policy_acknowledged",
|
||||
]
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""Set the read-only state on form elements.
|
||||
We have 1 conditions that determine which fields are read-only:
|
||||
admin user permissions.
|
||||
"""
|
||||
|
||||
readonly_fields = list(self.readonly_fields)
|
||||
|
||||
if request.user.is_superuser:
|
||||
return readonly_fields
|
||||
else:
|
||||
readonly_fields.extend([field for field in self.analyst_readonly_fields])
|
||||
return readonly_fields
|
||||
|
||||
|
||||
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, current_state)]
|
||||
|
||||
transitions = get_available_FIELD_transitions(
|
||||
application, models.DomainApplication._meta.get_field("status")
|
||||
)
|
||||
|
||||
for transition in transitions:
|
||||
available_transitions.append((transition.target, transition.target))
|
||||
|
||||
# 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):
|
||||
|
||||
"""Custom domain applications admin class."""
|
||||
|
||||
# Columns
|
||||
list_display = [
|
||||
"requested_domain",
|
||||
"status",
|
||||
"organization_type",
|
||||
"created_at",
|
||||
"submitter",
|
||||
"investigator",
|
||||
]
|
||||
|
||||
# Filters
|
||||
list_filter = ("status", "organization_type", "investigator")
|
||||
|
||||
# Search
|
||||
search_fields = [
|
||||
"requested_domain__name",
|
||||
"submitter__email",
|
||||
"submitter__first_name",
|
||||
"submitter__last_name",
|
||||
]
|
||||
search_help_text = "Search by domain or submitter."
|
||||
|
||||
# Detail view
|
||||
form = DomainApplicationAdminForm
|
||||
fieldsets = [
|
||||
(None, {"fields": ["status", "investigator", "creator", "approved_domain"]}),
|
||||
(
|
||||
"Type of organization",
|
||||
{
|
||||
"fields": [
|
||||
"organization_type",
|
||||
"federally_recognized_tribe",
|
||||
"state_recognized_tribe",
|
||||
"tribe_name",
|
||||
"federal_agency",
|
||||
"federal_type",
|
||||
"is_election_board",
|
||||
"about_your_organization",
|
||||
]
|
||||
},
|
||||
),
|
||||
(
|
||||
"Organization name and mailing address",
|
||||
{
|
||||
"fields": [
|
||||
"organization_name",
|
||||
"address_line1",
|
||||
"address_line2",
|
||||
"city",
|
||||
"state_territory",
|
||||
"zipcode",
|
||||
"urbanization",
|
||||
]
|
||||
},
|
||||
),
|
||||
("Authorizing official", {"fields": ["authorizing_official"]}),
|
||||
("Current websites", {"fields": ["current_websites"]}),
|
||||
(".gov domain", {"fields": ["requested_domain", "alternative_domains"]}),
|
||||
("Purpose of your domain", {"fields": ["purpose"]}),
|
||||
("Your contact information", {"fields": ["submitter"]}),
|
||||
("Other employees from your organization?", {"fields": ["other_contacts"]}),
|
||||
(
|
||||
"No other employees from your organization?",
|
||||
{"fields": ["no_other_contacts_rationale"]},
|
||||
),
|
||||
("Anything else we should know?", {"fields": ["anything_else"]}),
|
||||
(
|
||||
"Requirements for operating .gov domains",
|
||||
{"fields": ["is_policy_acknowledged"]},
|
||||
),
|
||||
]
|
||||
|
||||
# Read only that we'll leverage for CISA Analysts
|
||||
analyst_readonly_fields = [
|
||||
"creator",
|
||||
"about_your_organization",
|
||||
"address_line1",
|
||||
"address_line2",
|
||||
"zipcode",
|
||||
"requested_domain",
|
||||
"alternative_domains",
|
||||
"purpose",
|
||||
"submitter",
|
||||
"no_other_contacts_rationale",
|
||||
"anything_else",
|
||||
"is_policy_acknowledged",
|
||||
]
|
||||
|
||||
# Trigger action when a fieldset is changed
|
||||
def save_model(self, request, obj, form, change):
|
||||
if obj and obj.creator.status != models.User.RESTRICTED:
|
||||
if change: # Check if the application is being edited
|
||||
# Get the original application from the database
|
||||
original_obj = models.DomainApplication.objects.get(pk=obj.pk)
|
||||
|
||||
if (
|
||||
obj
|
||||
and original_obj.status == models.DomainApplication.APPROVED
|
||||
and (
|
||||
obj.status == models.DomainApplication.REJECTED
|
||||
or obj.status == models.DomainApplication.INELIGIBLE
|
||||
)
|
||||
and not obj.domain_is_not_active()
|
||||
):
|
||||
# If an admin tried to set an approved application to
|
||||
# rejected or ineligible and the related domain is already
|
||||
# active, shortcut the action and throw a friendly
|
||||
# error message. This action would still not go through
|
||||
# shortcut or not as the rules are duplicated on the model,
|
||||
# but the error would be an ugly Django error screen.
|
||||
|
||||
# Clear the success message
|
||||
messages.set_level(request, messages.ERROR)
|
||||
|
||||
messages.error(
|
||||
request,
|
||||
"This action is not permitted. The domain "
|
||||
+ "is already active.",
|
||||
)
|
||||
|
||||
else:
|
||||
if obj.status != original_obj.status:
|
||||
status_method_mapping = {
|
||||
models.DomainApplication.STARTED: None,
|
||||
models.DomainApplication.SUBMITTED: obj.submit,
|
||||
models.DomainApplication.IN_REVIEW: obj.in_review,
|
||||
models.DomainApplication.ACTION_NEEDED: obj.action_needed,
|
||||
models.DomainApplication.APPROVED: obj.approve,
|
||||
models.DomainApplication.WITHDRAWN: obj.withdraw,
|
||||
models.DomainApplication.REJECTED: obj.reject,
|
||||
models.DomainApplication.INELIGIBLE: (
|
||||
obj.reject_with_prejudice
|
||||
),
|
||||
}
|
||||
selected_method = status_method_mapping.get(obj.status)
|
||||
if selected_method is None:
|
||||
logger.warning("Unknown status selected in django admin")
|
||||
else:
|
||||
# This is an fsm in model which will throw an error if the
|
||||
# transition condition is violated, so we roll back the
|
||||
# status to what it was before the admin user changed it and
|
||||
# let the fsm method set it.
|
||||
obj.status = original_obj.status
|
||||
selected_method()
|
||||
|
||||
super().save_model(request, obj, form, change)
|
||||
else:
|
||||
# Clear the success message
|
||||
messages.set_level(request, messages.ERROR)
|
||||
|
||||
messages.error(
|
||||
request,
|
||||
"This action is not permitted for applications "
|
||||
+ "with a restricted creator.",
|
||||
)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""Set the read-only state on form elements.
|
||||
We have 2 conditions that determine which fields are read-only:
|
||||
admin user permissions and the application creator's status, so
|
||||
we'll use the baseline readonly_fields and extend it as needed.
|
||||
"""
|
||||
|
||||
readonly_fields = list(self.readonly_fields)
|
||||
|
||||
# Check if the creator is restricted
|
||||
if obj and obj.creator.status == models.User.RESTRICTED:
|
||||
# For fields like CharField, IntegerField, etc., the widget used is
|
||||
# straightforward and the readonly_fields list can control their behavior
|
||||
readonly_fields.extend([field.name for field in self.model._meta.fields])
|
||||
# Add the multi-select fields to readonly_fields:
|
||||
# Complex fields like ManyToManyField require special handling
|
||||
readonly_fields.extend(
|
||||
["current_websites", "other_contacts", "alternative_domains"]
|
||||
)
|
||||
|
||||
if request.user.is_superuser:
|
||||
return readonly_fields
|
||||
else:
|
||||
readonly_fields.extend([field for field in self.analyst_readonly_fields])
|
||||
return readonly_fields
|
||||
|
||||
def display_restricted_warning(self, request, obj):
|
||||
if obj and obj.creator.status == models.User.RESTRICTED:
|
||||
messages.warning(
|
||||
request,
|
||||
"Cannot edit an application with a restricted creator.",
|
||||
)
|
||||
|
||||
def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||
obj = self.get_object(request, object_id)
|
||||
self.display_restricted_warning(request, obj)
|
||||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
|
||||
|
||||
class DomainInformationInline(admin.StackedInline):
|
||||
"""Edit a domain information on the domain page.
|
||||
We had issues inheriting from both StackedInline
|
||||
and the source DomainInformationAdmin since these
|
||||
classes conflict, so we'll just pull what we need
|
||||
from DomainInformationAdmin"""
|
||||
|
||||
model = models.DomainInformation
|
||||
|
||||
fieldsets = DomainInformationAdmin.fieldsets
|
||||
analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
return DomainInformationAdmin.get_readonly_fields(self, request, obj=None)
|
||||
|
||||
|
||||
class DomainAdmin(ListHeaderAdmin):
|
||||
"""Custom domain admin class to add extra buttons."""
|
||||
|
||||
inlines = [DomainInformationInline]
|
||||
|
||||
# Columns
|
||||
list_display = [
|
||||
"name",
|
||||
|
@ -207,7 +675,7 @@ class DomainAdmin(ListHeaderAdmin):
|
|||
)
|
||||
|
||||
# Filters
|
||||
list_filter = ["domain_info__organization_type"]
|
||||
list_filter = ["domain_info__organization_type", "state"]
|
||||
|
||||
search_fields = ["name"]
|
||||
search_help_text = "Search by domain name."
|
||||
|
@ -312,306 +780,11 @@ class DomainAdmin(ListHeaderAdmin):
|
|||
return super().has_change_permission(request, obj)
|
||||
|
||||
|
||||
class ContactAdmin(ListHeaderAdmin):
|
||||
"""Custom contact admin class to add search."""
|
||||
class DraftDomainAdmin(ListHeaderAdmin):
|
||||
"""Custom draft domain admin class."""
|
||||
|
||||
search_fields = ["email", "first_name", "last_name"]
|
||||
search_help_text = "Search by firstname, lastname or email."
|
||||
list_display = [
|
||||
"contact",
|
||||
"email",
|
||||
]
|
||||
|
||||
# We name the custom prop 'contact' because linter
|
||||
# is not allowing a short_description attr on it
|
||||
# This gets around the linter limitation, for now.
|
||||
def contact(self, obj: models.Contact):
|
||||
"""Duplicate the contact _str_"""
|
||||
if obj.first_name or obj.last_name:
|
||||
return obj.get_formatted_name()
|
||||
elif obj.email:
|
||||
return obj.email
|
||||
elif obj.pk:
|
||||
return str(obj.pk)
|
||||
else:
|
||||
return ""
|
||||
|
||||
contact.admin_order_field = "first_name" # type: ignore
|
||||
|
||||
|
||||
class WebsiteAdmin(ListHeaderAdmin):
|
||||
"""Custom website admin class."""
|
||||
|
||||
# Search
|
||||
search_fields = [
|
||||
"website",
|
||||
]
|
||||
search_help_text = "Search by website."
|
||||
|
||||
|
||||
class UserDomainRoleAdmin(ListHeaderAdmin):
|
||||
"""Custom domain role admin class."""
|
||||
|
||||
# Columns
|
||||
list_display = [
|
||||
"user",
|
||||
"domain",
|
||||
"role",
|
||||
]
|
||||
|
||||
# Search
|
||||
search_fields = [
|
||||
"user__first_name",
|
||||
"user__last_name",
|
||||
"domain__name",
|
||||
"role",
|
||||
]
|
||||
search_help_text = "Search by user, domain, or role."
|
||||
|
||||
|
||||
class DomainInvitationAdmin(ListHeaderAdmin):
|
||||
"""Custom domain invitation admin class."""
|
||||
|
||||
# Columns
|
||||
list_display = [
|
||||
"email",
|
||||
"domain",
|
||||
"status",
|
||||
]
|
||||
|
||||
# Search
|
||||
search_fields = [
|
||||
"email",
|
||||
"domain__name",
|
||||
]
|
||||
search_help_text = "Search by email or domain."
|
||||
|
||||
|
||||
class DomainInformationAdmin(ListHeaderAdmin):
|
||||
"""Customize domain information admin class."""
|
||||
|
||||
# Columns
|
||||
list_display = [
|
||||
"domain",
|
||||
"organization_type",
|
||||
"created_at",
|
||||
"submitter",
|
||||
]
|
||||
|
||||
# Filters
|
||||
list_filter = ["organization_type"]
|
||||
|
||||
# Search
|
||||
search_fields = [
|
||||
"domain__name",
|
||||
]
|
||||
search_help_text = "Search by domain."
|
||||
|
||||
|
||||
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, current_state)]
|
||||
|
||||
transitions = get_available_FIELD_transitions(
|
||||
application, models.DomainApplication._meta.get_field("status")
|
||||
)
|
||||
|
||||
for transition in transitions:
|
||||
available_transitions.append((transition.target, transition.target))
|
||||
|
||||
# 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):
|
||||
|
||||
"""Custom domain applications admin class."""
|
||||
|
||||
# Set multi-selects 'read-only' (hide selects and show data)
|
||||
# based on user perms and application creator's status
|
||||
# form = DomainApplicationForm
|
||||
|
||||
# Columns
|
||||
list_display = [
|
||||
"requested_domain",
|
||||
"status",
|
||||
"organization_type",
|
||||
"created_at",
|
||||
"submitter",
|
||||
"investigator",
|
||||
]
|
||||
|
||||
# Filters
|
||||
list_filter = ("status", "organization_type", "investigator")
|
||||
|
||||
# Search
|
||||
search_fields = [
|
||||
"requested_domain__name",
|
||||
"submitter__email",
|
||||
"submitter__first_name",
|
||||
"submitter__last_name",
|
||||
]
|
||||
search_help_text = "Search by domain or submitter."
|
||||
|
||||
# Detail view
|
||||
form = DomainApplicationAdminForm
|
||||
fieldsets = [
|
||||
(None, {"fields": ["status", "investigator", "creator"]}),
|
||||
(
|
||||
"Type of organization",
|
||||
{
|
||||
"fields": [
|
||||
"organization_type",
|
||||
"federally_recognized_tribe",
|
||||
"state_recognized_tribe",
|
||||
"tribe_name",
|
||||
"federal_agency",
|
||||
"federal_type",
|
||||
"is_election_board",
|
||||
"about_your_organization",
|
||||
]
|
||||
},
|
||||
),
|
||||
(
|
||||
"Organization name and mailing address",
|
||||
{
|
||||
"fields": [
|
||||
"organization_name",
|
||||
"address_line1",
|
||||
"address_line2",
|
||||
"city",
|
||||
"state_territory",
|
||||
"zipcode",
|
||||
"urbanization",
|
||||
]
|
||||
},
|
||||
),
|
||||
("Authorizing official", {"fields": ["authorizing_official"]}),
|
||||
("Current websites", {"fields": ["current_websites"]}),
|
||||
(".gov domain", {"fields": ["requested_domain", "alternative_domains"]}),
|
||||
("Purpose of your domain", {"fields": ["purpose"]}),
|
||||
("Your contact information", {"fields": ["submitter"]}),
|
||||
("Other employees from your organization?", {"fields": ["other_contacts"]}),
|
||||
(
|
||||
"No other employees from your organization?",
|
||||
{"fields": ["no_other_contacts_rationale"]},
|
||||
),
|
||||
("Anything else we should know?", {"fields": ["anything_else"]}),
|
||||
(
|
||||
"Requirements for operating .gov domains",
|
||||
{"fields": ["is_policy_acknowledged"]},
|
||||
),
|
||||
]
|
||||
|
||||
# Read only that we'll leverage for CISA Analysts
|
||||
analyst_readonly_fields = [
|
||||
"creator",
|
||||
"about_your_organization",
|
||||
"address_line1",
|
||||
"address_line2",
|
||||
"zipcode",
|
||||
"requested_domain",
|
||||
"alternative_domains",
|
||||
"purpose",
|
||||
"submitter",
|
||||
"no_other_contacts_rationale",
|
||||
"anything_else",
|
||||
"is_policy_acknowledged",
|
||||
]
|
||||
|
||||
# Trigger action when a fieldset is changed
|
||||
def save_model(self, request, obj, form, change):
|
||||
if obj and obj.creator.status != models.User.RESTRICTED:
|
||||
if change: # Check if the application is being edited
|
||||
# Get the original application from the database
|
||||
original_obj = models.DomainApplication.objects.get(pk=obj.pk)
|
||||
|
||||
if obj.status != original_obj.status:
|
||||
status_method_mapping = {
|
||||
models.DomainApplication.STARTED: None,
|
||||
models.DomainApplication.SUBMITTED: obj.submit,
|
||||
models.DomainApplication.IN_REVIEW: obj.in_review,
|
||||
models.DomainApplication.ACTION_NEEDED: obj.action_needed,
|
||||
models.DomainApplication.APPROVED: obj.approve,
|
||||
models.DomainApplication.WITHDRAWN: obj.withdraw,
|
||||
models.DomainApplication.REJECTED: obj.reject,
|
||||
models.DomainApplication.INELIGIBLE: obj.reject_with_prejudice,
|
||||
}
|
||||
selected_method = status_method_mapping.get(obj.status)
|
||||
if selected_method is None:
|
||||
logger.warning("Unknown status selected in django admin")
|
||||
else:
|
||||
# This is an fsm in model which will throw an error if the
|
||||
# transition condition is violated, so we roll back the
|
||||
# status to what it was before the admin user changed it and
|
||||
# let the fsm method set it.
|
||||
obj.status = original_obj.status
|
||||
selected_method()
|
||||
|
||||
super().save_model(request, obj, form, change)
|
||||
else:
|
||||
# Clear the success message
|
||||
messages.set_level(request, messages.ERROR)
|
||||
|
||||
messages.error(
|
||||
request,
|
||||
"This action is not permitted for applications "
|
||||
+ "with a restricted creator.",
|
||||
)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""Set the read-only state on form elements.
|
||||
We have 2 conditions that determine which fields are read-only:
|
||||
admin user permissions and the application creator's status, so
|
||||
we'll use the baseline readonly_fields and extend it as needed.
|
||||
"""
|
||||
|
||||
readonly_fields = list(self.readonly_fields)
|
||||
|
||||
# Check if the creator is restricted
|
||||
if obj and obj.creator.status == models.User.RESTRICTED:
|
||||
# For fields like CharField, IntegerField, etc., the widget used is
|
||||
# straightforward and the readonly_fields list can control their behavior
|
||||
readonly_fields.extend([field.name for field in self.model._meta.fields])
|
||||
# Add the multi-select fields to readonly_fields:
|
||||
# Complex fields like ManyToManyField require special handling
|
||||
readonly_fields.extend(
|
||||
["current_websites", "other_contacts", "alternative_domains"]
|
||||
)
|
||||
|
||||
if request.user.is_superuser:
|
||||
return readonly_fields
|
||||
else:
|
||||
readonly_fields.extend([field for field in self.analyst_readonly_fields])
|
||||
return readonly_fields
|
||||
|
||||
def display_restricted_warning(self, request, obj):
|
||||
if obj and obj.creator.status == models.User.RESTRICTED:
|
||||
messages.warning(
|
||||
request,
|
||||
"Cannot edit an application with a restricted creator.",
|
||||
)
|
||||
|
||||
def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||
obj = self.get_object(request, object_id)
|
||||
self.display_restricted_warning(request, obj)
|
||||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
search_fields = ["name"]
|
||||
search_help_text = "Search by draft domain name."
|
||||
|
||||
|
||||
admin.site.unregister(LogEntry) # Unregister the default registration
|
||||
|
@ -622,6 +795,7 @@ admin.site.register(models.Contact, ContactAdmin)
|
|||
admin.site.register(models.DomainInvitation, DomainInvitationAdmin)
|
||||
admin.site.register(models.DomainInformation, DomainInformationAdmin)
|
||||
admin.site.register(models.Domain, DomainAdmin)
|
||||
admin.site.register(models.DraftDomain, DraftDomainAdmin)
|
||||
admin.site.register(models.Host, MyHostAdmin)
|
||||
admin.site.register(models.Nameserver, MyHostAdmin)
|
||||
admin.site.register(models.Website, WebsiteAdmin)
|
||||
|
|
|
@ -143,13 +143,23 @@ class UserFixture:
|
|||
"permissions": ["view_logentry"],
|
||||
},
|
||||
{"app_label": "registrar", "model": "contact", "permissions": ["view_contact"]},
|
||||
{
|
||||
"app_label": "registrar",
|
||||
"model": "domaininformation",
|
||||
"permissions": ["change_domaininformation"],
|
||||
},
|
||||
{
|
||||
"app_label": "registrar",
|
||||
"model": "domainapplication",
|
||||
"permissions": ["change_domainapplication"],
|
||||
},
|
||||
{"app_label": "registrar", "model": "domain", "permissions": ["view_domain"]},
|
||||
{"app_label": "registrar", "model": "user", "permissions": ["view_user"]},
|
||||
{
|
||||
"app_label": "registrar",
|
||||
"model": "draftdomain",
|
||||
"permissions": ["change_draftdomain"],
|
||||
},
|
||||
{"app_label": "registrar", "model": "user", "permissions": ["change_user"]},
|
||||
]
|
||||
|
||||
@classmethod
|
||||
|
|
|
@ -6,12 +6,12 @@ from phonenumber_field.formfields import PhoneNumberField # type: ignore
|
|||
|
||||
from django import forms
|
||||
from django.core.validators import RegexValidator, MaxLengthValidator
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from api.views import DOMAIN_API_MESSAGES
|
||||
|
||||
from registrar.models import Contact, DomainApplication, DraftDomain, Domain
|
||||
from registrar.templatetags.url_helpers import public_site_url
|
||||
from registrar.utility import errors
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -181,7 +181,6 @@ class TribalGovernmentForm(RegistrarForm):
|
|||
self.cleaned_data["federally_recognized_tribe"]
|
||||
or self.cleaned_data["state_recognized_tribe"]
|
||||
):
|
||||
todo_url = reverse("todo")
|
||||
raise forms.ValidationError(
|
||||
# no sec because we are using it to include an internal URL
|
||||
# into a link. There should be no user-facing input in the
|
||||
|
@ -190,10 +189,10 @@ class TribalGovernmentForm(RegistrarForm):
|
|||
"You can’t complete this application yet. "
|
||||
"Only tribes recognized by the U.S. federal government "
|
||||
"or by a U.S. state government are eligible for .gov "
|
||||
'domains. Please use our <a href="{}">contact form</a> to '
|
||||
'domains. Use our <a href="{}">contact form</a> to '
|
||||
"tell us more about your tribe and why you want a .gov "
|
||||
"domain. We’ll review your information and get back "
|
||||
"to you.".format(todo_url)
|
||||
"to you.".format(public_site_url("contact"))
|
||||
),
|
||||
code="invalid",
|
||||
)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
# Generated by Django 4.2.1 on 2023-09-13 22:25
|
||||
# Generated by Django 4.2.1 on 2023-09-15 21:05
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_fsm
|
||||
|
||||
|
||||
|
@ -10,6 +11,23 @@ class Migration(migrations.Migration):
|
|||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="domain",
|
||||
name="state",
|
||||
field=django_fsm.FSMField(
|
||||
choices=[
|
||||
("unknown", "Unknown"),
|
||||
("dns needed", "Dns Needed"),
|
||||
("ready", "Ready"),
|
||||
("on hold", "On Hold"),
|
||||
("deleted", "Deleted"),
|
||||
],
|
||||
default="unknown",
|
||||
help_text="Very basic info about the lifecycle of this domain object",
|
||||
max_length=21,
|
||||
protected=True,
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="TransitionDomain",
|
||||
fields=[
|
||||
|
@ -89,20 +107,27 @@ class Migration(migrations.Migration):
|
|||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="domain",
|
||||
name="state",
|
||||
field=django_fsm.FSMField(
|
||||
choices=[
|
||||
("unknown", "Unknown"),
|
||||
("dns needed", "Dns Needed"),
|
||||
("ready", "Ready"),
|
||||
("on hold", "On Hold"),
|
||||
("deleted", "Deleted"),
|
||||
],
|
||||
default="unknown",
|
||||
help_text="Very basic info about the lifecycle of this domain object",
|
||||
max_length=21,
|
||||
protected=True,
|
||||
model_name="domainapplication",
|
||||
name="approved_domain",
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
help_text="The approved domain",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="domain_application",
|
||||
to="registrar.domain",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="domaininformation",
|
||||
name="domain",
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
help_text="Domain to which this information belongs",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="domain_info",
|
||||
to="registrar.domain",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
|
|
|
@ -332,24 +332,23 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
@Cache
|
||||
def statuses(self) -> list[str]:
|
||||
"""
|
||||
Get or set the domain `status` elements from the registry.
|
||||
Get the domain `status` elements from the registry.
|
||||
|
||||
A domain's status indicates various properties. See Domain.Status.
|
||||
"""
|
||||
# implementation note: the Status object from EPP stores the string in
|
||||
# a dataclass property `state`, not to be confused with the `state` field here
|
||||
if "statuses" not in self._cache:
|
||||
self._fetch_cache()
|
||||
if "statuses" not in self._cache:
|
||||
raise Exception("Can't retreive status from domain info")
|
||||
else:
|
||||
return self._cache["statuses"]
|
||||
try:
|
||||
return self._get_property("statuses")
|
||||
except KeyError:
|
||||
logger.error("Can't retrieve status from domain info")
|
||||
return []
|
||||
|
||||
@statuses.setter # type: ignore
|
||||
def statuses(self, statuses: list[str]):
|
||||
# TODO: there are a long list of rules in the RFC about which statuses
|
||||
# can be combined; check that here and raise errors for invalid combinations -
|
||||
# some statuses cannot be set by the client at all
|
||||
"""
|
||||
We will not implement this. Statuses are set by the registry
|
||||
when we run delete and client hold, and these are the only statuses
|
||||
we will be triggering.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@Cache
|
||||
|
@ -610,6 +609,11 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
"""
|
||||
return self.state == self.State.READY
|
||||
|
||||
def delete_request(self):
|
||||
"""Delete from host. Possibly a duplicate of _delete_host?"""
|
||||
# TODO fix in ticket #901
|
||||
pass
|
||||
|
||||
def transfer(self):
|
||||
"""Going somewhere. Not implemented."""
|
||||
raise NotImplementedError()
|
||||
|
@ -663,9 +667,6 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
help_text="Very basic info about the lifecycle of this domain object",
|
||||
)
|
||||
|
||||
def isActive(self):
|
||||
return self.state == Domain.State.CREATED
|
||||
|
||||
# ForeignKey on UserDomainRole creates a "permissions" member for
|
||||
# all of the user-roles that are in place for this domain
|
||||
|
||||
|
|
|
@ -405,7 +405,7 @@ class DomainApplication(TimeStampedModel):
|
|||
blank=True,
|
||||
help_text="The approved domain",
|
||||
related_name="domain_application",
|
||||
on_delete=models.PROTECT,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
|
||||
requested_domain = models.OneToOneField(
|
||||
|
@ -471,6 +471,11 @@ class DomainApplication(TimeStampedModel):
|
|||
except Exception:
|
||||
return ""
|
||||
|
||||
def domain_is_not_active(self):
|
||||
if self.approved_domain:
|
||||
return not self.approved_domain.is_active()
|
||||
return True
|
||||
|
||||
def _send_status_update_email(
|
||||
self, new_status, email_template, email_template_subject
|
||||
):
|
||||
|
@ -594,11 +599,22 @@ class DomainApplication(TimeStampedModel):
|
|||
"emails/domain_request_withdrawn_subject.txt",
|
||||
)
|
||||
|
||||
@transition(field="status", source=[IN_REVIEW, APPROVED], target=REJECTED)
|
||||
@transition(
|
||||
field="status",
|
||||
source=[IN_REVIEW, APPROVED],
|
||||
target=REJECTED,
|
||||
conditions=[domain_is_not_active],
|
||||
)
|
||||
def reject(self):
|
||||
"""Reject an application that has been submitted.
|
||||
|
||||
As a side effect, an email notification is sent, similar to in_review"""
|
||||
As side effects this will delete the domain and domain_information
|
||||
(will cascade), and send an email notification."""
|
||||
|
||||
if self.status == self.APPROVED:
|
||||
self.approved_domain.delete_request()
|
||||
self.approved_domain.delete()
|
||||
self.approved_domain = None
|
||||
|
||||
self._send_status_update_email(
|
||||
"action needed",
|
||||
|
@ -606,14 +622,25 @@ class DomainApplication(TimeStampedModel):
|
|||
"emails/status_change_rejected_subject.txt",
|
||||
)
|
||||
|
||||
@transition(field="status", source=[IN_REVIEW, APPROVED], target=INELIGIBLE)
|
||||
@transition(
|
||||
field="status",
|
||||
source=[IN_REVIEW, APPROVED],
|
||||
target=INELIGIBLE,
|
||||
conditions=[domain_is_not_active],
|
||||
)
|
||||
def reject_with_prejudice(self):
|
||||
"""The applicant is a bad actor, reject with prejudice.
|
||||
|
||||
No email As a side effect, but we block the applicant from editing
|
||||
any existing domains/applications and from submitting new aplications.
|
||||
We do this by setting an ineligible status on the user, which the
|
||||
permissions classes test against"""
|
||||
permissions classes test against. This will also delete the domain
|
||||
and domain_information (will cascade) when they exist."""
|
||||
|
||||
if self.status == self.APPROVED:
|
||||
self.approved_domain.delete_request()
|
||||
self.approved_domain.delete()
|
||||
self.approved_domain = None
|
||||
|
||||
self.creator.restrict_user()
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ logger = logging.getLogger(__name__)
|
|||
class DomainInformation(TimeStampedModel):
|
||||
|
||||
"""A registrant's domain information for that domain, exported from
|
||||
DomainApplication. We use these field from DomainApplication with few exceptation
|
||||
DomainApplication. We use these field from DomainApplication with few exceptions
|
||||
which are 'removed' via pop at the bottom of this file. Most of design for domain
|
||||
management's user information are based on application, but we cannot change
|
||||
the application once approved, so copying them that way we can make changes
|
||||
|
@ -150,7 +150,7 @@ class DomainInformation(TimeStampedModel):
|
|||
|
||||
domain = models.OneToOneField(
|
||||
"registrar.Domain",
|
||||
on_delete=models.PROTECT,
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
# Access this information via Domain as "domain.domain_info"
|
||||
|
|
|
@ -45,7 +45,7 @@ class User(AbstractUser):
|
|||
def __str__(self):
|
||||
# this info is pulled from Login.gov
|
||||
if self.first_name or self.last_name:
|
||||
return f"{self.first_name or ''} {self.last_name or ''}"
|
||||
return f"{self.first_name or ''} {self.last_name or ''} {self.email or ''}"
|
||||
elif self.email:
|
||||
return self.email
|
||||
else:
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
<tbody>
|
||||
{% for permission in domain.permissions.all %}
|
||||
<tr>
|
||||
<th scope="row" role="rowheader" data-sort-value="{{ user.email }}" data-label="Email">
|
||||
<th scope="row" role="rowheader" data-sort-value="{{ permission.user.email }}" data-label="Email">
|
||||
{{ permission.user.email }}
|
||||
</th>
|
||||
<td data-label="Role">{{ permission.role|title }}</td>
|
||||
|
@ -57,7 +57,7 @@
|
|||
<tbody>
|
||||
{% for invitation in domain.invitations.all %}
|
||||
<tr>
|
||||
<th scope="row" role="rowheader" data-sort-value="{{ user.email }}" data-label="Email">
|
||||
<th scope="row" role="rowheader" data-sort-value="{{ invitation.user.email }}" data-label="Email">
|
||||
{{ invitation.email }}
|
||||
</th>
|
||||
<td data-sort-value="{{ invitation.created_at|date:"U" }}" data-label="Date created">{{ invitation.created_at|date }} </td>
|
||||
|
|
|
@ -548,17 +548,29 @@ class MockEppLib(TestCase):
|
|||
class fakedEppObject(object):
|
||||
""""""
|
||||
|
||||
def __init__(self, auth_info=..., cr_date=..., contacts=..., hosts=...):
|
||||
def __init__(
|
||||
self,
|
||||
auth_info=...,
|
||||
cr_date=...,
|
||||
contacts=...,
|
||||
hosts=...,
|
||||
statuses=...,
|
||||
):
|
||||
self.auth_info = auth_info
|
||||
self.cr_date = cr_date
|
||||
self.contacts = contacts
|
||||
self.hosts = hosts
|
||||
self.statuses = statuses
|
||||
|
||||
mockDataInfoDomain = fakedEppObject(
|
||||
"fakepw",
|
||||
cr_date=datetime.datetime(2023, 5, 25, 19, 45, 35),
|
||||
contacts=[common.DomainContact(contact="123", type="security")],
|
||||
hosts=["fake.host.com"],
|
||||
statuses=[
|
||||
common.Status(state="serverTransferProhibited", description="", lang="en"),
|
||||
common.Status(state="inactive", description="", lang="en"),
|
||||
],
|
||||
)
|
||||
infoDomainNoContact = fakedEppObject(
|
||||
"security",
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
from django.test import TestCase, RequestFactory, Client
|
||||
from django.contrib.admin.sites import AdminSite
|
||||
from contextlib import ExitStack
|
||||
from django.contrib import messages
|
||||
from django.urls import reverse
|
||||
|
||||
from registrar.admin import (
|
||||
|
@ -535,7 +537,160 @@ class TestDomainApplicationAdmin(TestCase):
|
|||
"Cannot edit an application with a restricted creator.",
|
||||
)
|
||||
|
||||
def test_error_when_saving_approved_to_rejected_and_domain_is_active(self):
|
||||
# Create an instance of the model
|
||||
application = completed_application(status=DomainApplication.APPROVED)
|
||||
domain = Domain.objects.create(name=application.requested_domain.name)
|
||||
application.approved_domain = domain
|
||||
application.save()
|
||||
|
||||
# Create a request object with a superuser
|
||||
request = self.factory.post(
|
||||
"/admin/registrar/domainapplication/{}/change/".format(application.pk)
|
||||
)
|
||||
request.user = self.superuser
|
||||
|
||||
# Define a custom implementation for is_active
|
||||
def custom_is_active(self):
|
||||
return True # Override to return True
|
||||
|
||||
# Use ExitStack to combine patch contexts
|
||||
with ExitStack() as stack:
|
||||
# Patch Domain.is_active and django.contrib.messages.error simultaneously
|
||||
stack.enter_context(patch.object(Domain, "is_active", custom_is_active))
|
||||
stack.enter_context(patch.object(messages, "error"))
|
||||
|
||||
# Simulate saving the model
|
||||
application.status = DomainApplication.REJECTED
|
||||
self.admin.save_model(request, application, None, True)
|
||||
|
||||
# Assert that the error message was called with the correct argument
|
||||
messages.error.assert_called_once_with(
|
||||
request,
|
||||
"This action is not permitted. The domain " + "is already active.",
|
||||
)
|
||||
|
||||
def test_side_effects_when_saving_approved_to_rejected(self):
|
||||
# Create an instance of the model
|
||||
application = completed_application(status=DomainApplication.APPROVED)
|
||||
domain = Domain.objects.create(name=application.requested_domain.name)
|
||||
domain_information = DomainInformation.objects.create(
|
||||
creator=self.superuser, domain=domain
|
||||
)
|
||||
application.approved_domain = domain
|
||||
application.save()
|
||||
|
||||
# Create a request object with a superuser
|
||||
request = self.factory.post(
|
||||
"/admin/registrar/domainapplication/{}/change/".format(application.pk)
|
||||
)
|
||||
request.user = self.superuser
|
||||
|
||||
# Define a custom implementation for is_active
|
||||
def custom_is_active(self):
|
||||
return False # Override to return False
|
||||
|
||||
# Use ExitStack to combine patch contexts
|
||||
with ExitStack() as stack:
|
||||
# Patch Domain.is_active and django.contrib.messages.error simultaneously
|
||||
stack.enter_context(patch.object(Domain, "is_active", custom_is_active))
|
||||
stack.enter_context(patch.object(messages, "error"))
|
||||
|
||||
# Simulate saving the model
|
||||
application.status = DomainApplication.REJECTED
|
||||
self.admin.save_model(request, application, None, True)
|
||||
|
||||
# Assert that the error message was never called
|
||||
messages.error.assert_not_called()
|
||||
|
||||
self.assertEqual(application.approved_domain, None)
|
||||
|
||||
# Assert that Domain got Deleted
|
||||
with self.assertRaises(Domain.DoesNotExist):
|
||||
domain.refresh_from_db()
|
||||
|
||||
# Assert that DomainInformation got Deleted
|
||||
with self.assertRaises(DomainInformation.DoesNotExist):
|
||||
domain_information.refresh_from_db()
|
||||
|
||||
def test_error_when_saving_approved_to_ineligible_and_domain_is_active(self):
|
||||
# Create an instance of the model
|
||||
application = completed_application(status=DomainApplication.APPROVED)
|
||||
domain = Domain.objects.create(name=application.requested_domain.name)
|
||||
application.approved_domain = domain
|
||||
application.save()
|
||||
|
||||
# Create a request object with a superuser
|
||||
request = self.factory.post(
|
||||
"/admin/registrar/domainapplication/{}/change/".format(application.pk)
|
||||
)
|
||||
request.user = self.superuser
|
||||
|
||||
# Define a custom implementation for is_active
|
||||
def custom_is_active(self):
|
||||
return True # Override to return True
|
||||
|
||||
# Use ExitStack to combine patch contexts
|
||||
with ExitStack() as stack:
|
||||
# Patch Domain.is_active and django.contrib.messages.error simultaneously
|
||||
stack.enter_context(patch.object(Domain, "is_active", custom_is_active))
|
||||
stack.enter_context(patch.object(messages, "error"))
|
||||
|
||||
# Simulate saving the model
|
||||
application.status = DomainApplication.INELIGIBLE
|
||||
self.admin.save_model(request, application, None, True)
|
||||
|
||||
# Assert that the error message was called with the correct argument
|
||||
messages.error.assert_called_once_with(
|
||||
request,
|
||||
"This action is not permitted. The domain " + "is already active.",
|
||||
)
|
||||
|
||||
def test_side_effects_when_saving_approved_to_ineligible(self):
|
||||
# Create an instance of the model
|
||||
application = completed_application(status=DomainApplication.APPROVED)
|
||||
domain = Domain.objects.create(name=application.requested_domain.name)
|
||||
domain_information = DomainInformation.objects.create(
|
||||
creator=self.superuser, domain=domain
|
||||
)
|
||||
application.approved_domain = domain
|
||||
application.save()
|
||||
|
||||
# Create a request object with a superuser
|
||||
request = self.factory.post(
|
||||
"/admin/registrar/domainapplication/{}/change/".format(application.pk)
|
||||
)
|
||||
request.user = self.superuser
|
||||
|
||||
# Define a custom implementation for is_active
|
||||
def custom_is_active(self):
|
||||
return False # Override to return False
|
||||
|
||||
# Use ExitStack to combine patch contexts
|
||||
with ExitStack() as stack:
|
||||
# Patch Domain.is_active and django.contrib.messages.error simultaneously
|
||||
stack.enter_context(patch.object(Domain, "is_active", custom_is_active))
|
||||
stack.enter_context(patch.object(messages, "error"))
|
||||
|
||||
# Simulate saving the model
|
||||
application.status = DomainApplication.INELIGIBLE
|
||||
self.admin.save_model(request, application, None, True)
|
||||
|
||||
# Assert that the error message was never called
|
||||
messages.error.assert_not_called()
|
||||
|
||||
self.assertEqual(application.approved_domain, None)
|
||||
|
||||
# Assert that Domain got Deleted
|
||||
with self.assertRaises(Domain.DoesNotExist):
|
||||
domain.refresh_from_db()
|
||||
|
||||
# Assert that DomainInformation got Deleted
|
||||
with self.assertRaises(DomainInformation.DoesNotExist):
|
||||
domain_information.refresh_from_db()
|
||||
|
||||
def tearDown(self):
|
||||
Domain.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
DomainApplication.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
|
@ -632,6 +787,7 @@ class MyUserAdminTest(TestCase):
|
|||
"last_name",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"status",
|
||||
)
|
||||
|
||||
self.assertEqual(list_display, expected_list_display)
|
||||
|
@ -648,7 +804,12 @@ class MyUserAdminTest(TestCase):
|
|||
request = self.client.request().wsgi_request
|
||||
request.user = create_user()
|
||||
fieldsets = self.admin.get_fieldsets(request)
|
||||
expected_fieldsets = ((None, {"fields": []}),)
|
||||
expected_fieldsets = (
|
||||
(None, {"fields": ("password", "status")}),
|
||||
("Personal Info", {"fields": ("first_name", "last_name", "email")}),
|
||||
("Permissions", {"fields": ("is_active", "is_staff", "is_superuser")}),
|
||||
("Important dates", {"fields": ("last_login", "date_joined")}),
|
||||
)
|
||||
self.assertEqual(fieldsets, expected_fieldsets)
|
||||
|
||||
def tearDown(self):
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from django.test import TestCase
|
||||
from django.db.utils import IntegrityError
|
||||
from unittest.mock import patch
|
||||
|
||||
from registrar.models import (
|
||||
Contact,
|
||||
|
@ -439,7 +440,26 @@ class TestDomainApplication(TestCase):
|
|||
application = completed_application(status=DomainApplication.INELIGIBLE)
|
||||
|
||||
with self.assertRaises(TransitionNotAllowed):
|
||||
application.reject_with_prejudice()
|
||||
application.reject()
|
||||
|
||||
def test_transition_not_allowed_approved_rejected_when_domain_is_active(self):
|
||||
"""Create an application with status approved, create a matching domain that
|
||||
is active, and call reject against transition rules"""
|
||||
|
||||
application = completed_application(status=DomainApplication.APPROVED)
|
||||
domain = Domain.objects.create(name=application.requested_domain.name)
|
||||
application.approved_domain = domain
|
||||
application.save()
|
||||
|
||||
# Define a custom implementation for is_active
|
||||
def custom_is_active(self):
|
||||
return True # Override to return True
|
||||
|
||||
# Use patch to temporarily replace is_active with the custom implementation
|
||||
with patch.object(Domain, "is_active", custom_is_active):
|
||||
# Now, when you call is_active on Domain, it will return True
|
||||
with self.assertRaises(TransitionNotAllowed):
|
||||
application.reject()
|
||||
|
||||
def test_transition_not_allowed_started_ineligible(self):
|
||||
"""Create an application with status started and call reject
|
||||
|
@ -495,6 +515,25 @@ class TestDomainApplication(TestCase):
|
|||
with self.assertRaises(TransitionNotAllowed):
|
||||
application.reject_with_prejudice()
|
||||
|
||||
def test_transition_not_allowed_approved_ineligible_when_domain_is_active(self):
|
||||
"""Create an application with status approved, create a matching domain that
|
||||
is active, and call reject_with_prejudice against transition rules"""
|
||||
|
||||
application = completed_application(status=DomainApplication.APPROVED)
|
||||
domain = Domain.objects.create(name=application.requested_domain.name)
|
||||
application.approved_domain = domain
|
||||
application.save()
|
||||
|
||||
# Define a custom implementation for is_active
|
||||
def custom_is_active(self):
|
||||
return True # Override to return True
|
||||
|
||||
# Use patch to temporarily replace is_active with the custom implementation
|
||||
with patch.object(Domain, "is_active", custom_is_active):
|
||||
# Now, when you call is_active on Domain, it will return True
|
||||
with self.assertRaises(TransitionNotAllowed):
|
||||
application.reject_with_prejudice()
|
||||
|
||||
|
||||
class TestPermissions(TestCase):
|
||||
|
||||
|
|
|
@ -34,6 +34,8 @@ class TestDomainCache(MockEppLib):
|
|||
# (see InfoDomainResult)
|
||||
self.assertEquals(domain._cache["auth_info"], self.mockDataInfoDomain.auth_info)
|
||||
self.assertEquals(domain._cache["cr_date"], self.mockDataInfoDomain.cr_date)
|
||||
status_list = [status.state for status in self.mockDataInfoDomain.statuses]
|
||||
self.assertEquals(domain._cache["statuses"], status_list)
|
||||
self.assertFalse("avail" in domain._cache.keys())
|
||||
|
||||
# using a setter should clear the cache
|
||||
|
@ -49,7 +51,8 @@ class TestDomainCache(MockEppLib):
|
|||
),
|
||||
call(commands.InfoContact(id="123", auth_info=None), cleaned=True),
|
||||
call(commands.InfoHost(name="fake.host.com"), cleaned=True),
|
||||
]
|
||||
],
|
||||
any_order=False, # Ensure calls are in the specified order
|
||||
)
|
||||
|
||||
def test_cache_used_when_avail(self):
|
||||
|
@ -106,16 +109,14 @@ class TestDomainCache(MockEppLib):
|
|||
domain._get_property("hosts")
|
||||
self.assertEqual(domain._cache["hosts"], [expectedHostsDict])
|
||||
|
||||
def tearDown(self) -> None:
|
||||
Domain.objects.all().delete()
|
||||
super().tearDown()
|
||||
|
||||
class TestDomainCreation(TestCase):
|
||||
|
||||
class TestDomainCreation(MockEppLib):
|
||||
"""Rule: An approved domain application must result in a domain"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Background:
|
||||
Given that a valid domain application exists
|
||||
"""
|
||||
|
||||
def test_approved_application_creates_domain_locally(self):
|
||||
"""
|
||||
Scenario: Analyst approves a domain application
|
||||
|
@ -123,8 +124,6 @@ class TestDomainCreation(TestCase):
|
|||
Then a Domain exists in the database with the same `name`
|
||||
But a domain object does not exist in the registry
|
||||
"""
|
||||
patcher = patch("registrar.models.domain.Domain._get_or_create_domain")
|
||||
mocked_domain_creation = patcher.start()
|
||||
draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
|
||||
user, _ = User.objects.get_or_create()
|
||||
application = DomainApplication.objects.create(
|
||||
|
@ -137,19 +136,46 @@ class TestDomainCreation(TestCase):
|
|||
# should hav information present for this domain
|
||||
domain = Domain.objects.get(name="igorville.gov")
|
||||
self.assertTrue(domain)
|
||||
mocked_domain_creation.assert_not_called()
|
||||
self.mockedSendFunction.assert_not_called()
|
||||
|
||||
@skip("not implemented yet")
|
||||
def test_accessing_domain_properties_creates_domain_in_registry(self):
|
||||
"""
|
||||
Scenario: A registrant checks the status of a newly approved domain
|
||||
Given that no domain object exists in the registry
|
||||
When a property is accessed
|
||||
Then Domain sends `commands.CreateDomain` to the registry
|
||||
And `domain.state` is set to `CREATED`
|
||||
And `domain.state` is set to `UNKNOWN`
|
||||
And `domain.is_active()` returns False
|
||||
"""
|
||||
raise
|
||||
domain = Domain.objects.create(name="beef-tongue.gov")
|
||||
# trigger getter
|
||||
_ = domain.statuses
|
||||
|
||||
# contacts = PublicContact.objects.filter(domain=domain,
|
||||
# type=PublicContact.ContactTypeChoices.REGISTRANT).get()
|
||||
|
||||
# Called in _fetch_cache
|
||||
self.mockedSendFunction.assert_has_calls(
|
||||
[
|
||||
# TODO: due to complexity of the test, will return to it in
|
||||
# a future ticket
|
||||
# call(
|
||||
# commands.CreateDomain(name="beef-tongue.gov",
|
||||
# id=contact.registry_id, auth_info=None),
|
||||
# cleaned=True,
|
||||
# ),
|
||||
call(
|
||||
commands.InfoDomain(name="beef-tongue.gov", auth_info=None),
|
||||
cleaned=True,
|
||||
),
|
||||
call(commands.InfoContact(id="123", auth_info=None), cleaned=True),
|
||||
call(commands.InfoHost(name="fake.host.com"), cleaned=True),
|
||||
],
|
||||
any_order=False, # Ensure calls are in the specified order
|
||||
)
|
||||
|
||||
self.assertEqual(domain.state, Domain.State.UNKNOWN)
|
||||
self.assertEqual(domain.is_active(), False)
|
||||
|
||||
@skip("assertion broken with mock addition")
|
||||
def test_empty_domain_creation(self):
|
||||
|
@ -168,20 +194,71 @@ class TestDomainCreation(TestCase):
|
|||
with self.assertRaisesRegex(IntegrityError, "name"):
|
||||
Domain.objects.create(name="igorville.gov")
|
||||
|
||||
@skip("cannot activate a domain without mock registry")
|
||||
def test_get_status(self):
|
||||
"""Returns proper status based on `state`."""
|
||||
domain = Domain.objects.create(name="igorville.gov")
|
||||
domain.save()
|
||||
self.assertEqual(None, domain.status)
|
||||
domain.activate()
|
||||
domain.save()
|
||||
self.assertIn("ok", domain.status)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
DomainInformation.objects.all().delete()
|
||||
DomainApplication.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
super().tearDown()
|
||||
|
||||
|
||||
class TestDomainStatuses(MockEppLib):
|
||||
"""Domain statuses are set by the registry"""
|
||||
|
||||
def test_get_status(self):
|
||||
"""Domain 'statuses' getter returns statuses by calling epp"""
|
||||
domain, _ = Domain.objects.get_or_create(name="chicken-liver.gov")
|
||||
# trigger getter
|
||||
_ = domain.statuses
|
||||
status_list = [status.state for status in self.mockDataInfoDomain.statuses]
|
||||
self.assertEquals(domain._cache["statuses"], status_list)
|
||||
|
||||
# Called in _fetch_cache
|
||||
self.mockedSendFunction.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
commands.InfoDomain(name="chicken-liver.gov", auth_info=None),
|
||||
cleaned=True,
|
||||
),
|
||||
call(commands.InfoContact(id="123", auth_info=None), cleaned=True),
|
||||
call(commands.InfoHost(name="fake.host.com"), cleaned=True),
|
||||
],
|
||||
any_order=False, # Ensure calls are in the specified order
|
||||
)
|
||||
|
||||
def test_get_status_returns_empty_list_when_value_error(self):
|
||||
"""Domain 'statuses' getter returns an empty list
|
||||
when value error"""
|
||||
domain, _ = Domain.objects.get_or_create(name="pig-knuckles.gov")
|
||||
|
||||
def side_effect(self):
|
||||
raise KeyError
|
||||
|
||||
patcher = patch("registrar.models.domain.Domain._get_property")
|
||||
mocked_get = patcher.start()
|
||||
mocked_get.side_effect = side_effect
|
||||
|
||||
# trigger getter
|
||||
_ = domain.statuses
|
||||
|
||||
with self.assertRaises(KeyError):
|
||||
_ = domain._cache["statuses"]
|
||||
self.assertEquals(_, [])
|
||||
|
||||
patcher.stop()
|
||||
|
||||
@skip("not implemented yet")
|
||||
def test_place_client_hold_sets_status(self):
|
||||
"""Domain 'place_client_hold' method causes the registry to change statuses"""
|
||||
raise
|
||||
|
||||
@skip("not implemented yet")
|
||||
def test_revert_client_hold_sets_status(self):
|
||||
"""Domain 'revert_client_hold' method causes the registry to change statuses"""
|
||||
raise
|
||||
|
||||
def tearDown(self) -> None:
|
||||
Domain.objects.all().delete()
|
||||
super().tearDown()
|
||||
|
||||
|
||||
class TestRegistrantContacts(MockEppLib):
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue