Merge branch 'main' into dk/927-domain-invitation-email

This commit is contained in:
David Kennedy 2023-09-22 14:45:54 -04:00
commit f5aacb8fb8
No known key found for this signature in database
GPG key ID: 6528A5386E66B96B
8 changed files with 361 additions and 168 deletions

View file

@ -238,129 +238,6 @@ class MyHostAdmin(AuditedAdmin):
inlines = [HostIPInline] inlines = [HostIPInline]
class DomainAdmin(ListHeaderAdmin):
"""Custom domain admin class to add extra buttons."""
# Columns
list_display = [
"name",
"organization_type",
"state",
]
def organization_type(self, obj):
return obj.domain_info.organization_type
organization_type.admin_order_field = ( # type: ignore
"domain_info__organization_type"
)
# Filters
list_filter = ["domain_info__organization_type"]
search_fields = ["name"]
search_help_text = "Search by domain name."
change_form_template = "django/admin/domain_change_form.html"
readonly_fields = ["state"]
def response_change(self, request, obj):
# Create dictionary of action functions
ACTION_FUNCTIONS = {
"_place_client_hold": self.do_place_client_hold,
"_remove_client_hold": self.do_remove_client_hold,
"_edit_domain": self.do_edit_domain,
"_delete_domain": self.do_delete_domain,
"_get_status": self.do_get_status,
}
# Check which action button was pressed and call the corresponding function
for action, function in ACTION_FUNCTIONS.items():
if action in request.POST:
return function(request, obj)
# If no matching action button is found, return the super method
return super().response_change(request, obj)
def do_delete_domain(self, request, obj):
try:
obj.deleted()
obj.save()
except Exception as err:
self.message_user(request, err, messages.ERROR)
else:
self.message_user(
request,
("Domain %s Should now be deleted " ". Thanks!") % obj.name,
)
return HttpResponseRedirect(".")
def do_get_status(self, request, obj):
try:
statuses = obj.statuses
except Exception as err:
self.message_user(request, err, messages.ERROR)
else:
self.message_user(
request,
("Domain statuses are %s" ". Thanks!") % statuses,
)
return HttpResponseRedirect(".")
def do_place_client_hold(self, request, obj):
try:
obj.place_client_hold()
obj.save()
except Exception as err:
self.message_user(request, err, messages.ERROR)
else:
self.message_user(
request,
(
"%s is in client hold. This domain is no longer accessible on"
" the public internet."
)
% obj.name,
)
return HttpResponseRedirect(".")
def do_remove_client_hold(self, request, obj):
try:
obj.revert_client_hold()
obj.save()
except Exception as err:
self.message_user(request, err, messages.ERROR)
else:
self.message_user(
request,
("%s is ready. This domain is accessible on the public internet.")
% obj.name,
)
return HttpResponseRedirect(".")
def do_edit_domain(self, request, obj):
# We want to know, globally, when an edit action occurs
request.session["analyst_action"] = "edit"
# Restricts this action to this domain (pk) only
request.session["analyst_action_location"] = obj.id
return HttpResponseRedirect(reverse("domain", args=(obj.id,)))
def change_view(self, request, object_id):
# If the analyst was recently editing a domain page,
# delete any associated session values
if "analyst_action" in request.session:
del request.session["analyst_action"]
del request.session["analyst_action_location"]
return super().change_view(request, object_id)
def has_change_permission(self, request, obj=None):
# Fixes a bug wherein users which are only is_staff
# can access 'change' when GET,
# but cannot access this page when it is a request of type POST.
if request.user.is_staff:
return True
return super().has_change_permission(request, obj)
class ContactAdmin(ListHeaderAdmin): class ContactAdmin(ListHeaderAdmin):
"""Custom contact admin class to add search.""" """Custom contact admin class to add search."""
@ -456,6 +333,81 @@ class DomainInformationAdmin(ListHeaderAdmin):
] ]
search_help_text = "Search by domain." 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): class DomainApplicationAdminForm(forms.ModelForm):
"""Custom form to limit transitions to available transitions""" """Custom form to limit transitions to available transitions"""
@ -492,10 +444,6 @@ class DomainApplicationAdmin(ListHeaderAdmin):
"""Custom domain applications admin class.""" """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 # Columns
list_display = [ list_display = [
"requested_domain", "requested_domain",
@ -705,6 +653,154 @@ class TransitionDomainAdmin(ListHeaderAdmin):
search_fields = ["username", "domain_name"] search_fields = ["username", "domain_name"]
search_help_text = "Search by user or domain name." search_help_text = "Search by user or domain name."
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",
"organization_type",
"state",
]
def organization_type(self, obj):
return obj.domain_info.organization_type
organization_type.admin_order_field = ( # type: ignore
"domain_info__organization_type"
)
# Filters
list_filter = ["domain_info__organization_type", "state"]
search_fields = ["name"]
search_help_text = "Search by domain name."
change_form_template = "django/admin/domain_change_form.html"
readonly_fields = ["state"]
def response_change(self, request, obj):
# Create dictionary of action functions
ACTION_FUNCTIONS = {
"_place_client_hold": self.do_place_client_hold,
"_remove_client_hold": self.do_remove_client_hold,
"_edit_domain": self.do_edit_domain,
"_delete_domain": self.do_delete_domain,
"_get_status": self.do_get_status,
}
# Check which action button was pressed and call the corresponding function
for action, function in ACTION_FUNCTIONS.items():
if action in request.POST:
return function(request, obj)
# If no matching action button is found, return the super method
return super().response_change(request, obj)
def do_delete_domain(self, request, obj):
try:
obj.deleted()
obj.save()
except Exception as err:
self.message_user(request, err, messages.ERROR)
else:
self.message_user(
request,
("Domain %s Should now be deleted " ". Thanks!") % obj.name,
)
return HttpResponseRedirect(".")
def do_get_status(self, request, obj):
try:
statuses = obj.statuses
except Exception as err:
self.message_user(request, err, messages.ERROR)
else:
self.message_user(
request,
("Domain statuses are %s" ". Thanks!") % statuses,
)
return HttpResponseRedirect(".")
def do_place_client_hold(self, request, obj):
try:
obj.place_client_hold()
obj.save()
except Exception as err:
self.message_user(request, err, messages.ERROR)
else:
self.message_user(
request,
(
"%s is in client hold. This domain is no longer accessible on"
" the public internet."
)
% obj.name,
)
return HttpResponseRedirect(".")
def do_remove_client_hold(self, request, obj):
try:
obj.revert_client_hold()
obj.save()
except Exception as err:
self.message_user(request, err, messages.ERROR)
else:
self.message_user(
request,
("%s is ready. This domain is accessible on the public internet.")
% obj.name,
)
return HttpResponseRedirect(".")
def do_edit_domain(self, request, obj):
# We want to know, globally, when an edit action occurs
request.session["analyst_action"] = "edit"
# Restricts this action to this domain (pk) only
request.session["analyst_action_location"] = obj.id
return HttpResponseRedirect(reverse("domain", args=(obj.id,)))
def change_view(self, request, object_id):
# If the analyst was recently editing a domain page,
# delete any associated session values
if "analyst_action" in request.session:
del request.session["analyst_action"]
del request.session["analyst_action_location"]
return super().change_view(request, object_id)
def has_change_permission(self, request, obj=None):
# Fixes a bug wherein users which are only is_staff
# can access 'change' when GET,
# but cannot access this page when it is a request of type POST.
if request.user.is_staff:
return True
return super().has_change_permission(request, obj)
class DraftDomainAdmin(ListHeaderAdmin):
"""Custom draft domain admin class."""
search_fields = ["name"]
search_help_text = "Search by draft domain name."
admin.site.unregister(LogEntry) # Unregister the default registration admin.site.unregister(LogEntry) # Unregister the default registration
admin.site.register(LogEntry, CustomLogEntryAdmin) admin.site.register(LogEntry, CustomLogEntryAdmin)
@ -714,6 +810,7 @@ admin.site.register(models.Contact, ContactAdmin)
admin.site.register(models.DomainInvitation, DomainInvitationAdmin) admin.site.register(models.DomainInvitation, DomainInvitationAdmin)
admin.site.register(models.DomainInformation, DomainInformationAdmin) admin.site.register(models.DomainInformation, DomainInformationAdmin)
admin.site.register(models.Domain, DomainAdmin) admin.site.register(models.Domain, DomainAdmin)
admin.site.register(models.DraftDomain, DraftDomainAdmin)
admin.site.register(models.Host, MyHostAdmin) admin.site.register(models.Host, MyHostAdmin)
admin.site.register(models.Nameserver, MyHostAdmin) admin.site.register(models.Nameserver, MyHostAdmin)
admin.site.register(models.Website, WebsiteAdmin) admin.site.register(models.Website, WebsiteAdmin)

View file

@ -143,12 +143,22 @@ class UserFixture:
"permissions": ["view_logentry"], "permissions": ["view_logentry"],
}, },
{"app_label": "registrar", "model": "contact", "permissions": ["view_contact"]}, {"app_label": "registrar", "model": "contact", "permissions": ["view_contact"]},
{
"app_label": "registrar",
"model": "domaininformation",
"permissions": ["change_domaininformation"],
},
{ {
"app_label": "registrar", "app_label": "registrar",
"model": "domainapplication", "model": "domainapplication",
"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": "draftdomain",
"permissions": ["change_draftdomain"],
},
{"app_label": "registrar", "model": "user", "permissions": ["change_user"]}, {"app_label": "registrar", "model": "user", "permissions": ["change_user"]},
] ]

View file

@ -332,24 +332,23 @@ class Domain(TimeStampedModel, DomainHelper):
@Cache @Cache
def statuses(self) -> list[str]: 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. A domain's status indicates various properties. See Domain.Status.
""" """
# implementation note: the Status object from EPP stores the string in try:
# a dataclass property `state`, not to be confused with the `state` field here return self._get_property("statuses")
if "statuses" not in self._cache: except KeyError:
self._fetch_cache() logger.error("Can't retrieve status from domain info")
if "statuses" not in self._cache: return []
raise Exception("Can't retreive status from domain info")
else:
return self._cache["statuses"]
@statuses.setter # type: ignore @statuses.setter # type: ignore
def statuses(self, statuses: list[str]): 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 - We will not implement this. Statuses are set by the registry
# some statuses cannot be set by the client at all when we run delete and client hold, and these are the only statuses
we will be triggering.
"""
raise NotImplementedError() raise NotImplementedError()
@Cache @Cache

View file

@ -45,7 +45,7 @@ class User(AbstractUser):
def __str__(self): def __str__(self):
# this info is pulled from Login.gov # this info is pulled from Login.gov
if self.first_name or self.last_name: 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: elif self.email:
return self.email return self.email
else: else:

View file

@ -26,7 +26,7 @@
<li>Domain meets our naming requirements</li> <li>Domain meets our naming requirements</li>
</ul> </ul>
<p> You can <a href="{% url 'todo' %}"><del>check the status</del></a> <p> You can <a href="{% url 'home' %}">check the status</a>
of your request at any time. We'll email you with any questions or when we of your request at any time. We'll email you with any questions or when we
complete our review.</p> complete our review.</p>

View file

@ -14,8 +14,6 @@
<p>Before your domain can be used we'll need information about your domain <p>Before your domain can be used we'll need information about your domain
name servers.</p> name servers.</p>
<p><a class="usa-link" href="{% url "todo" %}">Get help with domain servers.</a></p>
{% include "includes/required_fields.html" %} {% include "includes/required_fields.html" %}
<form class="usa-form usa-form--large" method="post" novalidate id="form-container"> <form class="usa-form usa-form--large" method="post" novalidate id="form-container">

View file

@ -547,17 +547,29 @@ class MockEppLib(TestCase):
class fakedEppObject(object): 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.auth_info = auth_info
self.cr_date = cr_date self.cr_date = cr_date
self.contacts = contacts self.contacts = contacts
self.hosts = hosts self.hosts = hosts
self.statuses = statuses
mockDataInfoDomain = fakedEppObject( mockDataInfoDomain = fakedEppObject(
"fakepw", "fakepw",
cr_date=datetime.datetime(2023, 5, 25, 19, 45, 35), cr_date=datetime.datetime(2023, 5, 25, 19, 45, 35),
contacts=[common.DomainContact(contact="123", type="security")], contacts=[common.DomainContact(contact="123", type="security")],
hosts=["fake.host.com"], hosts=["fake.host.com"],
statuses=[
common.Status(state="serverTransferProhibited", description="", lang="en"),
common.Status(state="inactive", description="", lang="en"),
],
) )
infoDomainNoContact = fakedEppObject( infoDomainNoContact = fakedEppObject(
"security", "security",

View file

@ -34,6 +34,8 @@ class TestDomainCache(MockEppLib):
# (see InfoDomainResult) # (see InfoDomainResult)
self.assertEquals(domain._cache["auth_info"], self.mockDataInfoDomain.auth_info) self.assertEquals(domain._cache["auth_info"], self.mockDataInfoDomain.auth_info)
self.assertEquals(domain._cache["cr_date"], self.mockDataInfoDomain.cr_date) 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()) self.assertFalse("avail" in domain._cache.keys())
# using a setter should clear the cache # 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.InfoContact(id="123", auth_info=None), cleaned=True),
call(commands.InfoHost(name="fake.host.com"), 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): def test_cache_used_when_avail(self):
@ -106,16 +109,14 @@ class TestDomainCache(MockEppLib):
domain._get_property("hosts") domain._get_property("hosts")
self.assertEqual(domain._cache["hosts"], [expectedHostsDict]) 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""" """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): def test_approved_application_creates_domain_locally(self):
""" """
Scenario: Analyst approves a domain application Scenario: Analyst approves a domain application
@ -123,8 +124,6 @@ class TestDomainCreation(TestCase):
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
""" """
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") draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
user, _ = User.objects.get_or_create() user, _ = User.objects.get_or_create()
application = DomainApplication.objects.create( application = DomainApplication.objects.create(
@ -137,19 +136,46 @@ class TestDomainCreation(TestCase):
# should hav information present for this domain # should hav 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)
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): def test_accessing_domain_properties_creates_domain_in_registry(self):
""" """
Scenario: A registrant checks the status of a newly approved domain Scenario: A registrant checks the status of a newly approved domain
Given that no domain object exists in the registry Given that no domain object exists in the registry
When a property is accessed When a property is accessed
Then Domain sends `commands.CreateDomain` to the registry 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 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") @skip("assertion broken with mock addition")
def test_empty_domain_creation(self): def test_empty_domain_creation(self):
@ -168,20 +194,71 @@ class TestDomainCreation(TestCase):
with self.assertRaisesRegex(IntegrityError, "name"): with self.assertRaisesRegex(IntegrityError, "name"):
Domain.objects.create(name="igorville.gov") 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: def tearDown(self) -> None:
DomainInformation.objects.all().delete() DomainInformation.objects.all().delete()
DomainApplication.objects.all().delete() DomainApplication.objects.all().delete()
Domain.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): class TestRegistrantContacts(MockEppLib):