Merge branch 'main' into za/850-epp-contact-get

This commit is contained in:
zandercymatics 2023-09-18 09:57:49 -06:00
commit 7d91764048
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
8 changed files with 491 additions and 35 deletions

View file

@ -130,6 +130,7 @@ class MyUserAdmin(BaseUserAdmin):
inlines = [UserContactInline] inlines = [UserContactInline]
list_display = ( list_display = (
"username",
"email", "email",
"first_name", "first_name",
"last_name", "last_name",
@ -159,10 +160,51 @@ class MyUserAdmin(BaseUserAdmin):
("Important dates", {"fields": ("last_login", "date_joined")}), ("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): def get_list_display(self, request):
if not request.user.is_superuser: if not request.user.is_superuser:
# Customize the list display for staff users # 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 # Use the default list display for non-staff users
return super().get_list_display(request) return super().get_list_display(request)
@ -171,11 +213,18 @@ class MyUserAdmin(BaseUserAdmin):
if not request.user.is_superuser: if not request.user.is_superuser:
# If the user doesn't have permission to change the model, # If the user doesn't have permission to change the model,
# show a read-only fieldset # show a read-only fieldset
return ((None, {"fields": []}),) return self.analyst_fieldsets
# If the user has permission to change the model, show all fields # If the user has permission to change the model, show all fields
return super().get_fieldsets(request, obj) 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): class HostIPInline(admin.StackedInline):
"""Edit an ip address on the host page.""" """Edit an ip address on the host page."""
@ -472,7 +521,7 @@ class DomainApplicationAdmin(ListHeaderAdmin):
# Detail view # Detail view
form = DomainApplicationAdminForm form = DomainApplicationAdminForm
fieldsets = [ fieldsets = [
(None, {"fields": ["status", "investigator", "creator"]}), (None, {"fields": ["status", "investigator", "creator", "approved_domain"]}),
( (
"Type of organization", "Type of organization",
{ {
@ -542,29 +591,57 @@ class DomainApplicationAdmin(ListHeaderAdmin):
# Get the original application from the database # Get the original application from the database
original_obj = models.DomainApplication.objects.get(pk=obj.pk) original_obj = models.DomainApplication.objects.get(pk=obj.pk)
if obj.status != original_obj.status: if (
status_method_mapping = { obj
models.DomainApplication.STARTED: None, and original_obj.status == models.DomainApplication.APPROVED
models.DomainApplication.SUBMITTED: obj.submit, and (
models.DomainApplication.IN_REVIEW: obj.in_review, obj.status == models.DomainApplication.REJECTED
models.DomainApplication.ACTION_NEEDED: obj.action_needed, or obj.status == models.DomainApplication.INELIGIBLE
models.DomainApplication.APPROVED: obj.approve, )
models.DomainApplication.WITHDRAWN: obj.withdraw, and not obj.domain_is_not_active()
models.DomainApplication.REJECTED: obj.reject, ):
models.DomainApplication.INELIGIBLE: obj.reject_with_prejudice, # If an admin tried to set an approved application to
} # rejected or ineligible and the related domain is already
selected_method = status_method_mapping.get(obj.status) # active, shortcut the action and throw a friendly
if selected_method is None: # error message. This action would still not go through
logger.warning("Unknown status selected in django admin") # shortcut or not as the rules are duplicated on the model,
else: # but the error would be an ugly Django error screen.
# 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) # 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: else:
# Clear the success message # Clear the success message
messages.set_level(request, messages.ERROR) messages.set_level(request, messages.ERROR)

View file

@ -149,7 +149,7 @@ class UserFixture:
"permissions": ["change_domainapplication"], "permissions": ["change_domainapplication"],
}, },
{"app_label": "registrar", "model": "domain", "permissions": ["view_domain"]}, {"app_label": "registrar", "model": "domain", "permissions": ["view_domain"]},
{"app_label": "registrar", "model": "user", "permissions": ["view_user"]}, {"app_label": "registrar", "model": "user", "permissions": ["change_user"]},
] ]
@classmethod @classmethod

View file

@ -0,0 +1,147 @@
# 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
class Migration(migrations.Migration):
dependencies = [
("registrar", "0030_alter_user_status"),
]
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=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"username",
models.TextField(
help_text="Username - this will be an email address",
verbose_name="Username",
),
),
(
"domain_name",
models.TextField(blank=True, null=True, verbose_name="Domain name"),
),
(
"status",
models.CharField(
blank=True,
choices=[("created", "Created"), ("hold", "Hold")],
help_text="domain status during the transfer",
max_length=255,
verbose_name="Status",
),
),
(
"email_sent",
models.BooleanField(
default=False,
help_text="indicates whether email was sent",
verbose_name="email sent",
),
),
],
options={
"abstract": False,
},
),
migrations.RemoveField(
model_name="domainapplication",
name="more_organization_information",
),
migrations.RemoveField(
model_name="domainapplication",
name="type_of_work",
),
migrations.RemoveField(
model_name="domaininformation",
name="more_organization_information",
),
migrations.RemoveField(
model_name="domaininformation",
name="type_of_work",
),
migrations.AddField(
model_name="domainapplication",
name="about_your_organization",
field=models.TextField(
blank=True, help_text="Information about your organization", null=True
),
),
migrations.AddField(
model_name="domaininformation",
name="about_your_organization",
field=models.TextField(
blank=True, help_text="Information about your organization", null=True
),
),
migrations.AlterField(
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(
model_name="publiccontact",
name="contact_type",
field=models.CharField(
choices=[
("registrant", "Registrant"),
("admin", "Administrative"),
("tech", "Technical"),
("security", "Security"),
],
help_text="For which type of WHOIS contact",
max_length=14,
),
),
]

View file

@ -593,6 +593,11 @@ class Domain(TimeStampedModel, DomainHelper):
""" """
return self.state == self.State.READY 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): def transfer(self):
"""Going somewhere. Not implemented.""" """Going somewhere. Not implemented."""
raise NotImplementedError() raise NotImplementedError()

View file

@ -405,7 +405,7 @@ class DomainApplication(TimeStampedModel):
blank=True, blank=True,
help_text="The approved domain", help_text="The approved domain",
related_name="domain_application", related_name="domain_application",
on_delete=models.PROTECT, on_delete=models.SET_NULL,
) )
requested_domain = models.OneToOneField( requested_domain = models.OneToOneField(
@ -471,6 +471,11 @@ class DomainApplication(TimeStampedModel):
except Exception: except Exception:
return "" 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( def _send_status_update_email(
self, new_status, email_template, email_template_subject self, new_status, email_template, email_template_subject
): ):
@ -594,11 +599,22 @@ class DomainApplication(TimeStampedModel):
"emails/domain_request_withdrawn_subject.txt", "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): def reject(self):
"""Reject an application that has been submitted. """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( self._send_status_update_email(
"action needed", "action needed",
@ -606,14 +622,25 @@ class DomainApplication(TimeStampedModel):
"emails/status_change_rejected_subject.txt", "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): def reject_with_prejudice(self):
"""The applicant is a bad actor, reject with prejudice. """The applicant is a bad actor, reject with prejudice.
No email As a side effect, but we block the applicant from editing No email As a side effect, but we block the applicant from editing
any existing domains/applications and from submitting new aplications. any existing domains/applications and from submitting new aplications.
We do this by setting an ineligible status on the user, which the 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() self.creator.restrict_user()

View file

@ -13,7 +13,7 @@ logger = logging.getLogger(__name__)
class DomainInformation(TimeStampedModel): class DomainInformation(TimeStampedModel):
"""A registrant's domain information for that domain, exported from """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 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 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 the application once approved, so copying them that way we can make changes
@ -150,7 +150,7 @@ class DomainInformation(TimeStampedModel):
domain = models.OneToOneField( domain = models.OneToOneField(
"registrar.Domain", "registrar.Domain",
on_delete=models.PROTECT, on_delete=models.CASCADE,
blank=True, blank=True,
null=True, null=True,
# Access this information via Domain as "domain.domain_info" # Access this information via Domain as "domain.domain_info"

View file

@ -1,5 +1,7 @@
from django.test import TestCase, RequestFactory, Client from django.test import TestCase, RequestFactory, Client
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
from contextlib import ExitStack
from django.contrib import messages
from django.urls import reverse from django.urls import reverse
from registrar.admin import ( from registrar.admin import (
@ -535,7 +537,160 @@ class TestDomainApplicationAdmin(TestCase):
"Cannot edit an application with a restricted creator.", "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): def tearDown(self):
Domain.objects.all().delete()
DomainInformation.objects.all().delete() DomainInformation.objects.all().delete()
DomainApplication.objects.all().delete() DomainApplication.objects.all().delete()
User.objects.all().delete() User.objects.all().delete()
@ -632,6 +787,7 @@ class MyUserAdminTest(TestCase):
"last_name", "last_name",
"is_staff", "is_staff",
"is_superuser", "is_superuser",
"status",
) )
self.assertEqual(list_display, expected_list_display) self.assertEqual(list_display, expected_list_display)
@ -648,7 +804,12 @@ class MyUserAdminTest(TestCase):
request = self.client.request().wsgi_request request = self.client.request().wsgi_request
request.user = create_user() request.user = create_user()
fieldsets = self.admin.get_fieldsets(request) 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) self.assertEqual(fieldsets, expected_fieldsets)
def tearDown(self): def tearDown(self):

View file

@ -1,5 +1,6 @@
from django.test import TestCase from django.test import TestCase
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from unittest.mock import patch
from registrar.models import ( from registrar.models import (
Contact, Contact,
@ -439,7 +440,26 @@ class TestDomainApplication(TestCase):
application = completed_application(status=DomainApplication.INELIGIBLE) application = completed_application(status=DomainApplication.INELIGIBLE)
with self.assertRaises(TransitionNotAllowed): 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): def test_transition_not_allowed_started_ineligible(self):
"""Create an application with status started and call reject """Create an application with status started and call reject
@ -495,6 +515,25 @@ class TestDomainApplication(TestCase):
with self.assertRaises(TransitionNotAllowed): with self.assertRaises(TransitionNotAllowed):
application.reject_with_prejudice() 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): class TestPermissions(TestCase):