diff --git a/src/api/tests/test_rdap.py b/src/api/tests/test_rdap.py
new file mode 100644
index 000000000..789a99152
--- /dev/null
+++ b/src/api/tests/test_rdap.py
@@ -0,0 +1,66 @@
+"""Test the domain rdap lookup API."""
+
+import json
+
+from django.contrib.auth import get_user_model
+from django.test import RequestFactory
+from django.test import TestCase
+
+from ..views import rdap
+
+API_BASE_PATH = "/api/v1/rdap/?domain="
+
+
+class RdapViewTest(TestCase):
+ """Test that the RDAP view function works as expected"""
+
+ def setUp(self):
+ super().setUp()
+ self.user = get_user_model().objects.create(username="username")
+ self.factory = RequestFactory()
+
+ def test_rdap_get_no_tld(self):
+ """RDAP API successfully fetches RDAP for domain without a TLD"""
+ request = self.factory.get(API_BASE_PATH + "whitehouse")
+ request.user = self.user
+ response = rdap(request, domain="whitehouse")
+ # contains the right text
+ self.assertContains(response, "rdap")
+ # can be parsed into JSON with appropriate keys
+ response_object = json.loads(response.content)
+ self.assertIn("rdapConformance", response_object)
+
+ def test_rdap_invalid_domain(self):
+ """RDAP API accepts invalid domain queries and returns JSON response
+ with appropriate error codes"""
+ request = self.factory.get(API_BASE_PATH + "whitehouse.com")
+ request.user = self.user
+ response = rdap(request, domain="whitehouse.com")
+
+ self.assertContains(response, "errorCode")
+ response_object = json.loads(response.content)
+ self.assertIn("errorCode", response_object)
+
+
+class RdapAPITest(TestCase):
+ """Test that the API can be called as expected."""
+
+ def setUp(self):
+ super().setUp()
+ username = "test_user"
+ first_name = "First"
+ last_name = "Last"
+ email = "info@example.com"
+ title = "title"
+ phone = "8080102431"
+ self.user = get_user_model().objects.create(
+ username=username, title=title, first_name=first_name, last_name=last_name, email=email, phone=phone
+ )
+
+ def test_rdap_get(self):
+ """Can call RDAP API"""
+ self.client.force_login(self.user)
+ response = self.client.get(API_BASE_PATH + "whitehouse.gov")
+ self.assertContains(response, "rdap")
+ response_object = json.loads(response.content)
+ self.assertIn("rdapConformance", response_object)
diff --git a/src/api/views.py b/src/api/views.py
index 2199e15ac..a7b4bde75 100644
--- a/src/api/views.py
+++ b/src/api/views.py
@@ -2,7 +2,7 @@
from django.apps import apps
from django.views.decorators.http import require_http_methods
-from django.http import HttpResponse
+from django.http import HttpResponse, JsonResponse
from django.utils.safestring import mark_safe
from registrar.templatetags.url_helpers import public_site_url
@@ -18,7 +18,7 @@ from cachetools.func import ttl_cache
from registrar.utility.s3_bucket import S3ClientError, S3ClientHelper
-DOMAIN_FILE_URL = "https://raw.githubusercontent.com/cisagov/dotgov-data/main/current-full.csv"
+RDAP_URL = "https://rdap.cloudflareregistry.com/rdap/domain/{domain}"
DOMAIN_API_MESSAGES = {
@@ -41,30 +41,6 @@ DOMAIN_API_MESSAGES = {
}
-# this file doesn't change that often, nor is it that big, so cache the result
-# in memory for ten minutes
-@ttl_cache(ttl=600)
-def _domains():
- """Return a list of the current .gov domains.
-
- Fetch a file from DOMAIN_FILE_URL, parse the CSV for the domain,
- lowercase everything and return the list.
- """
- DraftDomain = apps.get_model("registrar.DraftDomain")
- # 5 second timeout
- file_contents = requests.get(DOMAIN_FILE_URL, timeout=5).text
- domains = set()
- # skip the first line
- for line in file_contents.splitlines()[1:]:
- # get the domain before the first comma
- domain = line.split(",", 1)[0]
- # sanity-check the string we got from the file here
- if DraftDomain.string_could_be_domain(domain):
- # lowercase everything when we put it in domains
- domains.add(domain.lower())
- return domains
-
-
def check_domain_available(domain):
"""Return true if the given domain is available.
@@ -99,6 +75,22 @@ def available(request, domain=""):
return json_response
+@require_http_methods(["GET"])
+@login_not_required
+# Since we cache domain RDAP data, cache time may need to be re-evaluated this if we encounter any memory issues
+@ttl_cache(ttl=600)
+def rdap(request, domain=""):
+ """Returns JSON dictionary of a domain's RDAP data from Cloudflare API"""
+ domain = request.GET.get("domain", "")
+
+ # If inputted domain doesn't have a TLD, append .gov to it
+ if "." not in domain:
+ domain = f"{domain}.gov"
+
+ rdap_data = requests.get(RDAP_URL.format(domain=domain), timeout=5).json()
+ return JsonResponse(rdap_data)
+
+
@require_http_methods(["GET"])
@login_not_required
def get_current_full(request, file_name="current-full.csv"):
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 84e7a22ff..9584e3942 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -9,6 +9,7 @@ from django.conf import settings
from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField
from registrar.models.domain_information import DomainInformation
+from registrar.models.domain_invitation import DomainInvitation
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from waffle.decorators import flag_is_active
@@ -1564,7 +1565,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
modified_fieldsets = []
for name, data in fieldsets:
fields = data.get("fields", [])
- fields = tuple(field for field in fields if field not in DomainInformationAdmin.superuser_only_fields)
+ fields = [field for field in fields if field not in DomainInformationAdmin.superuser_only_fields]
modified_fieldsets.append((name, {**data, "fields": fields}))
return modified_fieldsets
return fieldsets
@@ -2282,10 +2283,58 @@ class DomainInformationInline(admin.StackedInline):
template = "django/admin/includes/domain_info_inline_stacked.html"
model = models.DomainInformation
- fieldsets = DomainInformationAdmin.fieldsets
- readonly_fields = DomainInformationAdmin.readonly_fields
- analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields
- autocomplete_fields = DomainInformationAdmin.autocomplete_fields
+ fieldsets = copy.deepcopy(list(DomainInformationAdmin.fieldsets))
+ analyst_readonly_fields = copy.deepcopy(DomainInformationAdmin.analyst_readonly_fields)
+ autocomplete_fields = copy.deepcopy(DomainInformationAdmin.autocomplete_fields)
+
+ def get_domain_managers(self, obj):
+ user_domain_roles = UserDomainRole.objects.filter(domain=obj.domain)
+ user_ids = user_domain_roles.values_list("user_id", flat=True)
+ domain_managers = User.objects.filter(id__in=user_ids)
+ return domain_managers
+
+ def get_domain_invitations(self, obj):
+ domain_invitations = DomainInvitation.objects.filter(
+ domain=obj.domain, status=DomainInvitation.DomainInvitationStatus.INVITED
+ )
+ return domain_invitations
+
+ def domain_managers(self, obj):
+ """Get domain managers for the domain, unpack and return an HTML block."""
+ domain_managers = self.get_domain_managers(obj)
+ if not domain_managers:
+ return "No domain managers found."
+
+ domain_manager_details = "
UID | Name | Email |
"
+ for domain_manager in domain_managers:
+ full_name = domain_manager.get_formatted_name()
+ change_url = reverse("admin:registrar_user_change", args=[domain_manager.pk])
+ domain_manager_details += ""
+ domain_manager_details += f'{escape(domain_manager.username)}'
+ domain_manager_details += f" | {escape(full_name)} | "
+ domain_manager_details += f"{escape(domain_manager.email)} | "
+ domain_manager_details += "
"
+ domain_manager_details += "
"
+ return format_html(domain_manager_details)
+
+ domain_managers.short_description = "Domain managers" # type: ignore
+
+ def invited_domain_managers(self, obj):
+ """Get emails which have been invited to the domain, unpack and return an HTML block."""
+ domain_invitations = self.get_domain_invitations(obj)
+ if not domain_invitations:
+ return "No invited domain managers found."
+
+ domain_invitation_details = "Email | Status | " + "
"
+ for domain_invitation in domain_invitations:
+ domain_invitation_details += ""
+ domain_invitation_details += f"{escape(domain_invitation.email)} | "
+ domain_invitation_details += f"{escape(domain_invitation.status.capitalize())} | "
+ domain_invitation_details += "
"
+ domain_invitation_details += "
"
+ return format_html(domain_invitation_details)
+
+ invited_domain_managers.short_description = "Invited domain managers" # type: ignore
def has_change_permission(self, request, obj=None):
"""Custom has_change_permission override so that we can specify that
@@ -2325,7 +2374,9 @@ class DomainInformationInline(admin.StackedInline):
return super().formfield_for_foreignkey(db_field, request, **kwargs)
def get_readonly_fields(self, request, obj=None):
- return DomainInformationAdmin.get_readonly_fields(self, request, obj=None)
+ readonly_fields = copy.deepcopy(DomainInformationAdmin.get_readonly_fields(self, request, obj=None))
+ readonly_fields.extend(["domain_managers", "invited_domain_managers"]) # type: ignore
+ return readonly_fields
# Re-route the get_fieldsets method to utilize DomainInformationAdmin.get_fieldsets
# since that has all the logic for excluding certain fields according to user permissions.
@@ -2334,13 +2385,34 @@ class DomainInformationInline(admin.StackedInline):
def get_fieldsets(self, request, obj=None):
# Grab fieldsets from DomainInformationAdmin so that it handles all logic
# for permission-based field visibility.
- modified_fieldsets = DomainInformationAdmin.get_fieldsets(self, request, obj=None)
+ modified_fieldsets = copy.deepcopy(DomainInformationAdmin.get_fieldsets(self, request, obj=None))
- # remove .gov domain from fieldset
+ # Modify fieldset sections in place
+ for index, (title, options) in enumerate(modified_fieldsets):
+ if title is None:
+ options["fields"] = [
+ field for field in options["fields"] if field not in ["creator", "domain_request", "notes"]
+ ]
+ elif title == "Contacts":
+ options["fields"] = [
+ field
+ for field in options["fields"]
+ if field not in ["other_contacts", "no_other_contacts_rationale"]
+ ]
+ options["fields"].extend(["domain_managers", "invited_domain_managers"]) # type: ignore
+ elif title == "Background info":
+ # move domain request and notes to background
+ options["fields"].extend(["domain_request", "notes"]) # type: ignore
+
+ # Remove or remove fieldset sections
for index, (title, f) in enumerate(modified_fieldsets):
if title == ".gov domain":
- del modified_fieldsets[index]
- break
+ # remove .gov domain from fieldset
+ modified_fieldsets.pop(index)
+ elif title == "Background info":
+ # move Background info to the bottom of the list
+ fieldsets_to_move = modified_fieldsets.pop(index)
+ modified_fieldsets.append(fieldsets_to_move)
return modified_fieldsets
@@ -2398,13 +2470,10 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
fieldsets = (
(
None,
- {"fields": ["name", "state", "expiration_date", "first_ready", "deleted"]},
+ {"fields": ["state", "expiration_date", "first_ready", "deleted", "dnssecdata", "nameservers"]},
),
)
- # this ordering effects the ordering of results in autocomplete_fields for domain
- ordering = ["name"]
-
def generic_org_type(self, obj):
return obj.domain_info.get_generic_org_type_display()
@@ -2425,6 +2494,28 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
organization_name.admin_order_field = "domain_info__organization_name" # type: ignore
+ def dnssecdata(self, obj):
+ return "Yes" if obj.dnssecdata else "No"
+
+ dnssecdata.short_description = "DNSSEC enabled" # type: ignore
+
+ # Custom method to display formatted nameservers
+ def nameservers(self, obj):
+ if not obj.nameservers:
+ return "No nameservers"
+
+ formatted_nameservers = []
+ for server, ip_list in obj.nameservers:
+ server_display = str(server)
+ if ip_list:
+ server_display += f" [{', '.join(ip_list)}]"
+ formatted_nameservers.append(server_display)
+
+ # Join the formatted strings with line breaks
+ return "\n".join(formatted_nameservers)
+
+ nameservers.short_description = "Name servers" # type: ignore
+
def custom_election_board(self, obj):
domain_info = getattr(obj, "domain_info", None)
if domain_info:
@@ -2451,7 +2542,15 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
search_fields = ["name"]
search_help_text = "Search by domain name."
change_form_template = "django/admin/domain_change_form.html"
- readonly_fields = ("state", "expiration_date", "first_ready", "deleted", "federal_agency")
+ readonly_fields = (
+ "state",
+ "expiration_date",
+ "first_ready",
+ "deleted",
+ "federal_agency",
+ "dnssecdata",
+ "nameservers",
+ )
# Table ordering
ordering = ["name"]
diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py
index eace14d4a..fa75e449f 100644
--- a/src/registrar/config/urls.py
+++ b/src/registrar/config/urls.py
@@ -33,7 +33,7 @@ from registrar.views.utility.api_views import (
)
from registrar.views.domains_json import get_domains_json
from registrar.views.utility import always_404
-from api.views import available, get_current_federal, get_current_full
+from api.views import available, rdap, get_current_federal, get_current_full
DOMAIN_REQUEST_NAMESPACE = views.DomainRequestWizard.URL_NAMESPACE
@@ -200,6 +200,7 @@ urlpatterns = [
path("openid/", include("djangooidc.urls")),
path("request/", include((domain_request_urls, DOMAIN_REQUEST_NAMESPACE))),
path("api/v1/available/", available, name="available"),
+ path("api/v1/rdap/", rdap, name="rdap"),
path("api/v1/get-report/current-federal", get_current_federal, name="get-current-federal"),
path("api/v1/get-report/current-full", get_current_full, name="get-current-full"),
path(
diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
index 38a0b97c7..6947f7f8b 100644
--- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
+++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
@@ -119,7 +119,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endfor %}
{% endwith %}
- {% elif field.field.name == "display_admins" %}
+ {% elif field.field.name == "display_admins" or field.field.name == "domain_managers" or field.field.namd == "invited_domain_managers" %}
{{ field.contents|safe }}
{% elif field.field.name == "display_members" %}
diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py
index 49f095a25..a9b94781f 100644
--- a/src/registrar/tests/test_admin_domain.py
+++ b/src/registrar/tests/test_admin_domain.py
@@ -167,12 +167,6 @@ class TestDomainAdminAsStaff(MockEppLib):
expected_organization_name = "MonkeySeeMonkeyDo"
self.assertContains(response, expected_organization_name)
- # clean up this test's data
- domain.delete()
- domain_information.delete()
- _domain_request.delete()
- _creator.delete()
-
@less_console_noise_decorator
def test_deletion_is_successful(self):
"""
@@ -227,9 +221,6 @@ class TestDomainAdminAsStaff(MockEppLib):
self.assertEqual(domain.state, Domain.State.DELETED)
- # clean up data within this test
- domain.delete()
-
@less_console_noise_decorator
def test_deletion_ready_fsm_failure(self):
"""
@@ -269,9 +260,6 @@ class TestDomainAdminAsStaff(MockEppLib):
self.assertEqual(domain.state, Domain.State.READY)
- # delete data created in this test
- domain.delete()
-
@less_console_noise_decorator
def test_analyst_deletes_domain_idempotent(self):
"""
@@ -330,8 +318,130 @@ class TestDomainAdminAsStaff(MockEppLib):
)
self.assertEqual(domain.state, Domain.State.DELETED)
- # delete data created in this test
- domain.delete()
+
+class TestDomainInformationInline(MockEppLib):
+ """Test DomainAdmin class, specifically the DomainInformationInline class, as staff user.
+
+ Notes:
+ all tests share staffuser; do not change staffuser model in tests
+ tests have available staffuser, client, and admin
+ """
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.staffuser = create_user()
+ cls.site = AdminSite()
+ cls.admin = DomainAdmin(model=Domain, admin_site=cls.site)
+ cls.factory = RequestFactory()
+
+ def setUp(self):
+ self.client = Client(HTTP_HOST="localhost:8080")
+ self.client.force_login(self.staffuser)
+ super().setUp()
+
+ def tearDown(self):
+ super().tearDown()
+ Host.objects.all().delete()
+ UserDomainRole.objects.all().delete()
+ Domain.objects.all().delete()
+ DomainInformation.objects.all().delete()
+ DomainRequest.objects.all().delete()
+
+ @classmethod
+ def tearDownClass(cls):
+ User.objects.all().delete()
+ super().tearDownClass()
+
+ @less_console_noise_decorator
+ def test_domain_managers_display(self):
+ """Tests the custom domain managers field"""
+ admin_user_1 = User.objects.create(
+ username="testuser1",
+ first_name="Gerald",
+ last_name="Meoward",
+ email="meoward@gov.gov",
+ )
+
+ domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=self.staffuser, name="fake.gov"
+ )
+ domain_request.approve()
+ _domain_info = DomainInformation.objects.filter(domain=domain_request.approved_domain).get()
+ domain = Domain.objects.filter(domain_info=_domain_info).get()
+
+ UserDomainRole.objects.get_or_create(user=admin_user_1, domain=domain, role=UserDomainRole.Roles.MANAGER)
+
+ admin_user_2 = User.objects.create(
+ username="testuser2",
+ first_name="Arnold",
+ last_name="Poopy",
+ email="poopy@gov.gov",
+ )
+
+ UserDomainRole.objects.get_or_create(user=admin_user_2, domain=domain, role=UserDomainRole.Roles.MANAGER)
+
+ # Get the first inline (DomainInformationInline)
+ inline_instance = self.admin.inlines[0](self.admin.model, self.admin.admin_site)
+
+ # Call the domain_managers method
+ domain_managers = inline_instance.domain_managers(domain.domain_info)
+
+ self.assertIn(
+ f'
testuser1',
+ domain_managers,
+ )
+ self.assertIn("Gerald Meoward", domain_managers)
+ self.assertIn("meoward@gov.gov", domain_managers)
+ self.assertIn(f'
testuser2', domain_managers)
+ self.assertIn("Arnold Poopy", domain_managers)
+ self.assertIn("poopy@gov.gov", domain_managers)
+
+ @less_console_noise_decorator
+ def test_invited_domain_managers_display(self):
+ """Tests the custom invited domain managers field"""
+ admin_user_1 = User.objects.create(
+ username="testuser1",
+ first_name="Gerald",
+ last_name="Meoward",
+ email="meoward@gov.gov",
+ )
+
+ domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=self.staffuser, name="fake.gov"
+ )
+ domain_request.approve()
+ _domain_info = DomainInformation.objects.filter(domain=domain_request.approved_domain).get()
+ domain = Domain.objects.filter(domain_info=_domain_info).get()
+
+ # domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
+ UserDomainRole.objects.get_or_create(user=admin_user_1, domain=domain, role=UserDomainRole.Roles.MANAGER)
+
+ admin_user_2 = User.objects.create(
+ username="testuser2",
+ first_name="Arnold",
+ last_name="Poopy",
+ email="poopy@gov.gov",
+ )
+
+ UserDomainRole.objects.get_or_create(user=admin_user_2, domain=domain, role=UserDomainRole.Roles.MANAGER)
+
+ # Get the first inline (DomainInformationInline)
+ inline_instance = self.admin.inlines[0](self.admin.model, self.admin.admin_site)
+
+ # Call the domain_managers method
+ domain_managers = inline_instance.domain_managers(domain.domain_info)
+ # domain_managers = self.admin.get_inlinesdomain_managers(self.domain)
+
+ self.assertIn(
+ f'
testuser1',
+ domain_managers,
+ )
+ self.assertIn("Gerald Meoward", domain_managers)
+ self.assertIn("meoward@gov.gov", domain_managers)
+ self.assertIn(f'
testuser2', domain_managers)
+ self.assertIn("Arnold Poopy", domain_managers)
+ self.assertIn("poopy@gov.gov", domain_managers)
class TestDomainAdminWithClient(TestCase):
@@ -415,17 +525,6 @@ class TestDomainAdminWithClient(TestCase):
self.assertContains(response, domain.name)
# Check that the fields have the right values.
- # == Check for the creator == #
-
- # Check for the right title, email, and phone number in the response.
- # We only need to check for the end tag
- # (Otherwise this test will fail if we change classes, etc)
- self.assertContains(response, "Treat inspector")
- self.assertContains(response, "meoward.jones@igorville.gov")
- self.assertContains(response, "(555) 123 12345")
-
- # Check for the field itself
- self.assertContains(response, "Meoward Jones")
# == Check for the senior_official == #
self.assertContains(response, "testy@town.com")
@@ -435,11 +534,6 @@ class TestDomainAdminWithClient(TestCase):
# Includes things like readonly fields
self.assertContains(response, "Testy Tester")
- # == Test the other_employees field == #
- self.assertContains(response, "testy2@town.com")
- self.assertContains(response, "Another Tester")
- self.assertContains(response, "(555) 555 5557")
-
# Test for the copy link
self.assertContains(response, "button--clipboard")
diff --git a/src/registrar/tests/test_url_auth.py b/src/registrar/tests/test_url_auth.py
index 284ec7638..1cd2d1384 100644
--- a/src/registrar/tests/test_url_auth.py
+++ b/src/registrar/tests/test_url_auth.py
@@ -116,6 +116,7 @@ class TestURLAuth(TestCase):
"/api/v1/available/",
"/api/v1/get-report/current-federal",
"/api/v1/get-report/current-full",
+ "/api/v1/rdap/",
"/health",
]