Skip to content

Commit

Permalink
Merge branch 'main' into hotgov/2355-rejection-reason-emails
Browse files Browse the repository at this point in the history
  • Loading branch information
zandercymatics committed Oct 1, 2024
2 parents 47d226f + ab5cd2c commit 62156ac
Show file tree
Hide file tree
Showing 7 changed files with 326 additions and 73 deletions.
66 changes: 66 additions & 0 deletions src/api/tests/test_rdap.py
Original file line number Diff line number Diff line change
@@ -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 = "[email protected]"
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)
44 changes: 18 additions & 26 deletions src/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,7 +18,7 @@
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 = {
Expand All @@ -41,30 +41,6 @@
}


# 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.
Expand Down Expand Up @@ -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"):
Expand Down
129 changes: 114 additions & 15 deletions src/registrar/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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
Expand Down Expand Up @@ -1564,7 +1565,7 @@ def get_fieldsets(self, request, obj=None):
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
Expand Down Expand Up @@ -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 = "<table><thead><tr><th>UID</th><th>Name</th><th>Email</th></tr></thead><tbody>"
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 += "<tr>"
domain_manager_details += f'<td><a href="{change_url}">{escape(domain_manager.username)}</a>'
domain_manager_details += f"<td>{escape(full_name)}</td>"
domain_manager_details += f"<td>{escape(domain_manager.email)}</td>"
domain_manager_details += "</tr>"
domain_manager_details += "</tbody></table>"
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 = "<table><thead><tr><th>Email</th><th>Status</th>" + "</tr></thead><tbody>"
for domain_invitation in domain_invitations:
domain_invitation_details += "<tr>"
domain_invitation_details += f"<td>{escape(domain_invitation.email)}</td>"
domain_invitation_details += f"<td>{escape(domain_invitation.status.capitalize())}</td>"
domain_invitation_details += "</tr>"
domain_invitation_details += "</tbody></table>"
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
Expand Down Expand Up @@ -2325,7 +2374,9 @@ def formfield_for_foreignkey(self, db_field, request, **kwargs):
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.
Expand All @@ -2334,13 +2385,34 @@ def get_readonly_fields(self, request, obj=None):
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

Expand Down Expand Up @@ -2398,13 +2470,10 @@ def queryset(self, request, queryset):
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()

Expand All @@ -2425,6 +2494,28 @@ def organization_name(self, obj):

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:
Expand All @@ -2451,7 +2542,15 @@ def state_territory(self, obj):
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"]
Expand Down
3 changes: 2 additions & 1 deletion src/registrar/config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
)
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
Expand Down Expand Up @@ -200,6 +200,7 @@
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@
{% endfor %}
{% endwith %}
</div>
{% 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" %}
<div class="readonly">{{ field.contents|safe }}</div>
{% elif field.field.name == "display_members" %}
<div class="readonly">
Expand Down
Loading

0 comments on commit 62156ac

Please sign in to comment.