From 1ebff9fc45560c27a98051a2e644b5b5d7f98f52 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 23 Aug 2024 10:45:32 -0400 Subject: [PATCH 01/20] some incremental changes --- src/registrar/admin.py | 47 ++++++++++++++++--- .../templates/admin/stacked_no_heading.html | 42 +++++++++++++++++ .../includes/domain_info_inline_stacked.html | 2 +- 3 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 src/registrar/templates/admin/stacked_no_heading.html diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 3ad5e3ea0..5b55eee12 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2272,6 +2272,17 @@ class DomainInformationInline(admin.StackedInline): analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields autocomplete_fields = DomainInformationAdmin.autocomplete_fields + # Removing specific fields from the first fieldset dynamically + fieldsets[0][1]["fields"] = [ + field for field in fieldsets[0][1]["fields"] if field not in ["creator", "submitter", "domain_request", "notes"] + ] + fieldsets[2][1]["fields"] = [ + field for field in fieldsets[2][1]["fields"] if field not in ["other_contacts", "no_other_contacts_rationale"] + ] + fieldsets[3][1]["fields"].extend(["other_contacts", "no_other_contacts_rationale"]) # type: ignore + fieldsets_to_move = fieldsets.pop(3) + fieldsets.append(fieldsets_to_move) + def has_change_permission(self, request, obj=None): """Custom has_change_permission override so that we can specify that analysts can edit this through this inline, but not through the model normally""" @@ -2382,14 +2393,11 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): fieldsets = ( ( - None, - {"fields": ["name", "state", "expiration_date", "first_ready", "deleted"]}, + "Domain Information", + {"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() @@ -2410,6 +2418,25 @@ 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 = "DNS Sec 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: + formatted_nameservers.append(f"{server} [{', '.join(ip_list)}]") + + # Join the formatted strings with line breaks + return "\n".join(formatted_nameservers) + + nameservers.short_description = "Nameservers" # type: ignore + def custom_election_board(self, obj): domain_info = getattr(obj, "domain_info", None) if domain_info: @@ -2436,7 +2463,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/templates/admin/stacked_no_heading.html b/src/registrar/templates/admin/stacked_no_heading.html new file mode 100644 index 000000000..60e37d757 --- /dev/null +++ b/src/registrar/templates/admin/stacked_no_heading.html @@ -0,0 +1,42 @@ +{% load i18n admin_urls %} +{% load i18n static %} + +{% comment %} +This is copied from Djangos implementation of this template, with added "blocks" +It is not inherently customizable on its own, so we can modify this instead. +https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/edit_inline/stacked.html +{% endcomment %} + +
+ +
+ {{ inline_admin_formset.formset.management_form }} + {{ inline_admin_formset.formset.non_form_errors }} + + {% for inline_admin_form in inline_admin_formset %}
+ {% if inline_admin_form.form.non_field_errors %} + {{ inline_admin_form.form.non_field_errors }} + {% endif %} + + {% for fieldset in inline_admin_form %} + {# .gov override #} + {% block fieldset %} + {% include "admin/includes/fieldset.html" %} + {% endblock fieldset%} + {# End of .gov override #} + {% endfor %} + + {% if inline_admin_form.needs_explicit_pk_field %} + {{ inline_admin_form.pk_field.field }} + {% endif %} + {% if inline_admin_form.fk_field %} + {{ inline_admin_form.fk_field.field }} + {% endif %} +
+ {% endfor %} +
+ +
diff --git a/src/registrar/templates/django/admin/includes/domain_info_inline_stacked.html b/src/registrar/templates/django/admin/includes/domain_info_inline_stacked.html index 414c485e5..9638bb7cb 100644 --- a/src/registrar/templates/django/admin/includes/domain_info_inline_stacked.html +++ b/src/registrar/templates/django/admin/includes/domain_info_inline_stacked.html @@ -1,4 +1,4 @@ -{% extends 'admin/stacked.html' %} +{% extends 'admin/stacked_no_heading.html' %} {% load i18n static %} {% block fieldset %} From bec50b4e08c8629eac47d83485483818f66c20b5 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 17 Sep 2024 10:54:29 -0400 Subject: [PATCH 02/20] checking in updates --- src/registrar/admin.py | 72 +++++++++- .../admin/includes/detail_table_fieldset.html | 2 +- .../includes/domain_info_inline_stacked.html | 2 +- src/registrar/tests/test_admin_domain.py | 125 ++++++++++++++++++ 4 files changed, 195 insertions(+), 6 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index c1b90083e..84649913d 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -11,6 +11,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 @@ -2340,11 +2341,12 @@ class DomainInformationInline(admin.StackedInline): template = "django/admin/includes/domain_info_inline_stacked.html" model = models.DomainInformation - fieldsets = DomainInformationAdmin.fieldsets - readonly_fields = DomainInformationAdmin.readonly_fields + fieldsets = list(DomainInformationAdmin.fieldsets) + readonly_fields = list(DomainInformationAdmin.readonly_fields) analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields autocomplete_fields = DomainInformationAdmin.autocomplete_fields + readonly_fields.extend(["domain_managers", "invited_domain_managers"]) # type: ignore # Removing specific fields from the first fieldset dynamically fieldsets[0][1]["fields"] = [ field for field in fieldsets[0][1]["fields"] if field not in ["creator", "submitter", "domain_request", "notes"] @@ -2352,9 +2354,71 @@ class DomainInformationInline(admin.StackedInline): fieldsets[2][1]["fields"] = [ field for field in fieldsets[2][1]["fields"] if field not in ["other_contacts", "no_other_contacts_rationale"] ] - fieldsets[3][1]["fields"].extend(["other_contacts", "no_other_contacts_rationale"]) # type: ignore + fieldsets[2][1]["fields"].extend(["domain_managers", "invited_domain_managers"]) # type: ignore fieldsets_to_move = fieldsets.pop(3) fieldsets.append(fieldsets_to_move) + + + 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 joined users who have roles/perms that are not Admin, unpack and return an HTML block. + + DJA readonly can't handle querysets, so we need to unpack and return html here. + Alternatively, we could return querysets in context but that would limit where this + data would display in a custom change form without extensive template customization. + + Will be used in the after_help_text block.""" + domain_managers = self.get_domain_managers(obj) + if not domain_managers: + return "No domain managers found." + + domain_manager_details = "" + "" + 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'" + domain_manager_details += f"" + domain_manager_details += "" + domain_manager_details += "
UIDNameEmail
{escape(domain_manager.username)}' + domain_manager_details += f"{escape(full_name)}{escape(domain_manager.email)}
" + 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. + + DJA readonly can't handle querysets, so we need to unpack and return html here. + Alternatively, we could return querysets in context but that would limit where this + data would display in a custom change form without extensive template customization. + + Will be used in the after_help_text block.""" + domain_invitations = self.get_domain_invitations(obj) + if not domain_invitations: + return "No invited domain managers found." + + domain_invitation_details = "" + "" + for domain_invitation in domain_invitations: + domain_invitation_details += "" + domain_invitation_details += f"" + domain_invitation_details += f"" + domain_invitation_details += "" + domain_invitation_details += "
EmailStatus
{escape(domain_invitation.email)}{escape(domain_invitation.status.capitalize())}
" + 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 @@ -2466,7 +2530,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): fieldsets = ( ( - "Domain Information", + None, {"fields": ["state", "expiration_date", "first_ready", "deleted", "dnssecdata", "nameservers"]}, ), ) 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 e22bcb571..a02162167 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -137,7 +137,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/templates/django/admin/includes/domain_info_inline_stacked.html b/src/registrar/templates/django/admin/includes/domain_info_inline_stacked.html index 9638bb7cb..414c485e5 100644 --- a/src/registrar/templates/django/admin/includes/domain_info_inline_stacked.html +++ b/src/registrar/templates/django/admin/includes/domain_info_inline_stacked.html @@ -1,4 +1,4 @@ -{% extends 'admin/stacked_no_heading.html' %} +{% extends 'admin/stacked.html' %} {% load i18n static %} {% block fieldset %} diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py index 49f095a25..bfbdad81d 100644 --- a/src/registrar/tests/test_admin_domain.py +++ b/src/registrar/tests/test_admin_domain.py @@ -334,6 +334,131 @@ class TestDomainAdminAsStaff(MockEppLib): 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): """Test DomainAdmin class as super user. From a772678d76a2e7055950f2eb0099bd8010ac7c47 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 20 Sep 2024 07:52:54 -0400 Subject: [PATCH 03/20] added values to readonly_fields in DomainInformationAdmin and redundant method definitions --- src/registrar/admin.py | 73 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 84649913d..ac9f01925 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1511,7 +1511,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): ] # Readonly fields for analysts and superusers - readonly_fields = ("other_contacts", "is_election_board") + readonly_fields = ("other_contacts", "is_election_board", "domain_managers", "invited_domain_managers") # Read only that we'll leverage for CISA Analysts analyst_readonly_fields = [ @@ -1606,6 +1606,70 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): return super().formfield_for_foreignkey(db_field, request, use_admin_sort_fields=use_sort, **kwargs) + 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 + + @admin.display(description='Domain managers') + def domain_managers(self, obj): + """Get joined users who have roles/perms that are not Admin, unpack and return an HTML block. + + DJA readonly can't handle querysets, so we need to unpack and return html here. + Alternatively, we could return querysets in context but that would limit where this + data would display in a custom change form without extensive template customization. + + Will be used in the after_help_text block.""" + domain_managers = self.get_domain_managers(obj) + if not domain_managers: + return "No domain managers found." + + domain_manager_details = "" + "" + 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'" + domain_manager_details += f"" + domain_manager_details += "" + domain_manager_details += "
UIDNameEmail
{escape(domain_manager.username)}' + domain_manager_details += f"{escape(full_name)}{escape(domain_manager.email)}
" + return format_html(domain_manager_details) + + domain_managers.short_description = "Domain managers" # type: ignore + + @admin.display(description='Invited domain managers') + def invited_domain_managers(self, obj): + """Get emails which have been invited to the domain, unpack and return an HTML block. + + DJA readonly can't handle querysets, so we need to unpack and return html here. + Alternatively, we could return querysets in context but that would limit where this + data would display in a custom change form without extensive template customization. + + Will be used in the after_help_text block.""" + domain_invitations = self.get_domain_invitations(obj) + if not domain_invitations: + return "No invited domain managers found." + + domain_invitation_details = "" + "" + for domain_invitation in domain_invitations: + domain_invitation_details += "" + domain_invitation_details += f"" + domain_invitation_details += f"" + domain_invitation_details += "" + domain_invitation_details += "
EmailStatus
{escape(domain_invitation.email)}{escape(domain_invitation.status.capitalize())}
" + return format_html(domain_invitation_details) + + invited_domain_managers.short_description = "Invited domain managers" # type: ignore + + class DomainRequestResource(FsmModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file""" @@ -2358,7 +2422,6 @@ class DomainInformationInline(admin.StackedInline): fieldsets_to_move = fieldsets.pop(3) fieldsets.append(fieldsets_to_move) - 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) @@ -2371,6 +2434,7 @@ class DomainInformationInline(admin.StackedInline): ) return domain_invitations + @admin.display(description='Domain managers') def domain_managers(self, obj): """Get joined users who have roles/perms that are not Admin, unpack and return an HTML block. @@ -2395,8 +2459,9 @@ class DomainInformationInline(admin.StackedInline): domain_manager_details += "" return format_html(domain_manager_details) - domain_managers.short_description = "Domain Managers" # type: ignore + domain_managers.short_description = "Domain managers" # type: ignore + @admin.display(description='Invited domain managers') def invited_domain_managers(self, obj): """Get emails which have been invited to the domain, unpack and return an HTML block. @@ -2418,7 +2483,7 @@ class DomainInformationInline(admin.StackedInline): domain_invitation_details += "" return format_html(domain_invitation_details) - invited_domain_managers.short_description = "Invited Domain Managers" # type: ignore + 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 From bf4df511bdeb1ccf0f7b24f8730409d4b1461f2d Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 20 Sep 2024 12:27:47 -0400 Subject: [PATCH 04/20] working properly --- src/registrar/admin.py | 117 ++++++----------------- src/registrar/tests/test_admin_domain.py | 31 ------ 2 files changed, 28 insertions(+), 120 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index ac9f01925..f3287be27 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1511,7 +1511,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): ] # Readonly fields for analysts and superusers - readonly_fields = ("other_contacts", "is_election_board", "domain_managers", "invited_domain_managers") + readonly_fields = ("other_contacts", "is_election_board") # Read only that we'll leverage for CISA Analysts analyst_readonly_fields = [ @@ -1606,70 +1606,6 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): return super().formfield_for_foreignkey(db_field, request, use_admin_sort_fields=use_sort, **kwargs) - 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 - - @admin.display(description='Domain managers') - def domain_managers(self, obj): - """Get joined users who have roles/perms that are not Admin, unpack and return an HTML block. - - DJA readonly can't handle querysets, so we need to unpack and return html here. - Alternatively, we could return querysets in context but that would limit where this - data would display in a custom change form without extensive template customization. - - Will be used in the after_help_text block.""" - domain_managers = self.get_domain_managers(obj) - if not domain_managers: - return "No domain managers found." - - domain_manager_details = "" + "" - 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'" - domain_manager_details += f"" - domain_manager_details += "" - domain_manager_details += "
UIDNameEmail
{escape(domain_manager.username)}' - domain_manager_details += f"{escape(full_name)}{escape(domain_manager.email)}
" - return format_html(domain_manager_details) - - domain_managers.short_description = "Domain managers" # type: ignore - - @admin.display(description='Invited domain managers') - def invited_domain_managers(self, obj): - """Get emails which have been invited to the domain, unpack and return an HTML block. - - DJA readonly can't handle querysets, so we need to unpack and return html here. - Alternatively, we could return querysets in context but that would limit where this - data would display in a custom change form without extensive template customization. - - Will be used in the after_help_text block.""" - domain_invitations = self.get_domain_invitations(obj) - if not domain_invitations: - return "No invited domain managers found." - - domain_invitation_details = "" + "" - for domain_invitation in domain_invitations: - domain_invitation_details += "" - domain_invitation_details += f"" - domain_invitation_details += f"" - domain_invitation_details += "" - domain_invitation_details += "
EmailStatus
{escape(domain_invitation.email)}{escape(domain_invitation.status.capitalize())}
" - return format_html(domain_invitation_details) - - invited_domain_managers.short_description = "Invited domain managers" # type: ignore - - class DomainRequestResource(FsmModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file""" @@ -2405,23 +2341,10 @@ class DomainInformationInline(admin.StackedInline): template = "django/admin/includes/domain_info_inline_stacked.html" model = models.DomainInformation - fieldsets = list(DomainInformationAdmin.fieldsets) - readonly_fields = list(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) - readonly_fields.extend(["domain_managers", "invited_domain_managers"]) # type: ignore - # Removing specific fields from the first fieldset dynamically - fieldsets[0][1]["fields"] = [ - field for field in fieldsets[0][1]["fields"] if field not in ["creator", "submitter", "domain_request", "notes"] - ] - fieldsets[2][1]["fields"] = [ - field for field in fieldsets[2][1]["fields"] if field not in ["other_contacts", "no_other_contacts_rationale"] - ] - fieldsets[2][1]["fields"].extend(["domain_managers", "invited_domain_managers"]) # type: ignore - fieldsets_to_move = fieldsets.pop(3) - fieldsets.append(fieldsets_to_move) - 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) @@ -2434,7 +2357,6 @@ class DomainInformationInline(admin.StackedInline): ) return domain_invitations - @admin.display(description='Domain managers') def domain_managers(self, obj): """Get joined users who have roles/perms that are not Admin, unpack and return an HTML block. @@ -2461,7 +2383,6 @@ class DomainInformationInline(admin.StackedInline): domain_managers.short_description = "Domain managers" # type: ignore - @admin.display(description='Invited domain managers') def invited_domain_managers(self, obj): """Get emails which have been invited to the domain, unpack and return an HTML block. @@ -2523,7 +2444,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. @@ -2532,14 +2455,30 @@ 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, f) in enumerate(modified_fieldsets): + if title is None: + modified_fieldsets[index][1]["fields"] = [ + field for field in modified_fieldsets[index][1]["fields"] if field not in ["creator", "domain_request", "notes"] + ] + elif title == "Contacts": + modified_fieldsets[index][1]["fields"] = [ + field for field in modified_fieldsets[index][1]["fields"] if field not in ["other_contacts", "no_other_contacts_rationale"] + ] + modified_fieldsets[index][1]["fields"].extend(["domain_managers", "invited_domain_managers"]) # 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 diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py index bfbdad81d..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,9 +318,6 @@ 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. @@ -540,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") @@ -560,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") From 410bdc8238adc1232471ed07031c9c1d60decb32 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 20 Sep 2024 12:35:16 -0400 Subject: [PATCH 05/20] cleaned up --- .../templates/admin/stacked_no_heading.html | 42 ------------------- 1 file changed, 42 deletions(-) delete mode 100644 src/registrar/templates/admin/stacked_no_heading.html diff --git a/src/registrar/templates/admin/stacked_no_heading.html b/src/registrar/templates/admin/stacked_no_heading.html deleted file mode 100644 index 60e37d757..000000000 --- a/src/registrar/templates/admin/stacked_no_heading.html +++ /dev/null @@ -1,42 +0,0 @@ -{% load i18n admin_urls %} -{% load i18n static %} - -{% comment %} -This is copied from Djangos implementation of this template, with added "blocks" -It is not inherently customizable on its own, so we can modify this instead. -https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/edit_inline/stacked.html -{% endcomment %} - -
- -
- {{ inline_admin_formset.formset.management_form }} - {{ inline_admin_formset.formset.non_form_errors }} - - {% for inline_admin_form in inline_admin_formset %}
- {% if inline_admin_form.form.non_field_errors %} - {{ inline_admin_form.form.non_field_errors }} - {% endif %} - - {% for fieldset in inline_admin_form %} - {# .gov override #} - {% block fieldset %} - {% include "admin/includes/fieldset.html" %} - {% endblock fieldset%} - {# End of .gov override #} - {% endfor %} - - {% if inline_admin_form.needs_explicit_pk_field %} - {{ inline_admin_form.pk_field.field }} - {% endif %} - {% if inline_admin_form.fk_field %} - {{ inline_admin_form.fk_field.field }} - {% endif %} -
- {% endfor %} -
- -
From e4df5c83a411d28255456304ee6c43c177ae721f Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 20 Sep 2024 12:38:29 -0400 Subject: [PATCH 06/20] lint --- src/registrar/admin.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index f3287be27..a4551c728 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2444,7 +2444,7 @@ class DomainInformationInline(admin.StackedInline): return super().formfield_for_foreignkey(db_field, request, **kwargs) def get_readonly_fields(self, request, obj=None): - readonly_fields = copy.deepcopy(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 @@ -2461,13 +2461,19 @@ class DomainInformationInline(admin.StackedInline): for index, (title, f) in enumerate(modified_fieldsets): if title is None: modified_fieldsets[index][1]["fields"] = [ - field for field in modified_fieldsets[index][1]["fields"] if field not in ["creator", "domain_request", "notes"] + field + for field in modified_fieldsets[index][1]["fields"] + if field not in ["creator", "domain_request", "notes"] ] elif title == "Contacts": modified_fieldsets[index][1]["fields"] = [ - field for field in modified_fieldsets[index][1]["fields"] if field not in ["other_contacts", "no_other_contacts_rationale"] + field + for field in modified_fieldsets[index][1]["fields"] + if field not in ["other_contacts", "no_other_contacts_rationale"] ] - modified_fieldsets[index][1]["fields"].extend(["domain_managers", "invited_domain_managers"]) # type: ignore + modified_fieldsets[index][1]["fields"].extend( + ["domain_managers", "invited_domain_managers"] + ) # type: ignore # Remove or remove fieldset sections for index, (title, f) in enumerate(modified_fieldsets): @@ -2478,7 +2484,7 @@ class DomainInformationInline(admin.StackedInline): # 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 From 2dca24f330102dd193dded2a224ca8e11a7daef4 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Fri, 20 Sep 2024 11:45:13 -0700 Subject: [PATCH 07/20] Add initial rdap api endpoint --- src/api/views.py | 13 +++++++++++++ src/registrar/config/urls.py | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/api/views.py b/src/api/views.py index 2199e15ac..87bb9f589 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -19,6 +19,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_API_MESSAGES = { @@ -99,6 +100,18 @@ def available(request, domain=""): return json_response +@require_http_methods(["GET"]) +@login_not_required +def rdap(request, domain=""): + """TODO: Write description + """ + Domain = apps.get_model("registrar.Domain") + domain = request.GET.get("domain", "") + + rdap_response = requests.get(DOMAIN_FILE_URL, domain) + return rdap_response + + @require_http_methods(["GET"]) @login_not_required def get_current_full(request, file_name="current-full.csv"): diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 9b9ed569e..483db9da6 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -31,7 +31,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 @@ -183,6 +183,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( From 861f5d2fa6e074357624a7887c7e387fac02b6df Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 20 Sep 2024 15:02:10 -0400 Subject: [PATCH 08/20] request and notes added to background section --- src/registrar/admin.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index a4551c728..e7e8cfb7a 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2474,6 +2474,11 @@ class DomainInformationInline(admin.StackedInline): modified_fieldsets[index][1]["fields"].extend( ["domain_managers", "invited_domain_managers"] ) # type: ignore + elif title == "Background info": + # move domain request and notes to background + modified_fieldsets[index][1]["fields"].extend( + ["domain_request", "notes"] + ) # type: ignore # Remove or remove fieldset sections for index, (title, f) in enumerate(modified_fieldsets): From 3f7dbd5f6e77591339b6b9cad96cdabafc5f8c2e Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Fri, 20 Sep 2024 14:57:12 -0700 Subject: [PATCH 09/20] Add updated RDAP API endpoint --- src/api/views.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/api/views.py b/src/api/views.py index 87bb9f589..5c3b4c525 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 @@ -19,7 +19,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/" +RDAP_URL = "https://rdap.cloudflareregistry.com/rdap/domain/{domain}" DOMAIN_API_MESSAGES = { @@ -108,8 +108,8 @@ def rdap(request, domain=""): Domain = apps.get_model("registrar.Domain") domain = request.GET.get("domain", "") - rdap_response = requests.get(DOMAIN_FILE_URL, domain) - return rdap_response + rdap_data = requests.get(RDAP_URL.format(domain=domain)).json() + return JsonResponse(rdap_data) @require_http_methods(["GET"]) From 4ab41f82787115b0f99540f51db8c7b5720b5eae Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 23 Sep 2024 12:08:21 -0400 Subject: [PATCH 10/20] code cleanup, formatting of nameservers in display --- src/registrar/admin.py | 45 ++++++++++++++---------------------------- 1 file changed, 15 insertions(+), 30 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index e7e8cfb7a..397c4a3ed 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1566,7 +1566,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 @@ -2358,18 +2358,12 @@ class DomainInformationInline(admin.StackedInline): return domain_invitations def domain_managers(self, obj): - """Get joined users who have roles/perms that are not Admin, unpack and return an HTML block. - - DJA readonly can't handle querysets, so we need to unpack and return html here. - Alternatively, we could return querysets in context but that would limit where this - data would display in a custom change form without extensive template customization. - - Will be used in the after_help_text block.""" + """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 = "" + "" + domain_manager_details = "
UIDNameEmail
" for domain_manager in domain_managers: full_name = domain_manager.get_formatted_name() change_url = reverse("admin:registrar_user_change", args=[domain_manager.pk]) @@ -2384,13 +2378,7 @@ class DomainInformationInline(admin.StackedInline): 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. - - DJA readonly can't handle querysets, so we need to unpack and return html here. - Alternatively, we could return querysets in context but that would limit where this - data would display in a custom change form without extensive template customization. - - Will be used in the after_help_text block.""" + """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." @@ -2458,27 +2446,21 @@ class DomainInformationInline(admin.StackedInline): modified_fieldsets = copy.deepcopy(DomainInformationAdmin.get_fieldsets(self, request, obj=None)) # Modify fieldset sections in place - for index, (title, f) in enumerate(modified_fieldsets): + for index, (title, options) in enumerate(modified_fieldsets): if title is None: - modified_fieldsets[index][1]["fields"] = [ - field - for field in modified_fieldsets[index][1]["fields"] - if field not in ["creator", "domain_request", "notes"] + options["fields"] = [ + field for field in options["fields"] if field not in ["creator", "domain_request", "notes"] ] elif title == "Contacts": - modified_fieldsets[index][1]["fields"] = [ + options["fields"] = [ field - for field in modified_fieldsets[index][1]["fields"] + for field in options["fields"] if field not in ["other_contacts", "no_other_contacts_rationale"] ] - modified_fieldsets[index][1]["fields"].extend( - ["domain_managers", "invited_domain_managers"] - ) # type: ignore + options["fields"].extend(["domain_managers", "invited_domain_managers"]) # type: ignore elif title == "Background info": # move domain request and notes to background - modified_fieldsets[index][1]["fields"].extend( - ["domain_request", "notes"] - ) # type: ignore + options["fields"].extend(["domain_request", "notes"]) # type: ignore # Remove or remove fieldset sections for index, (title, f) in enumerate(modified_fieldsets): @@ -2582,7 +2564,10 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): formatted_nameservers = [] for server, ip_list in obj.nameservers: - formatted_nameservers.append(f"{server} [{', '.join(ip_list)}]") + 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) From 919fbbfd2fab88b7ad68e2fb310e5e8a3011c2e2 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 24 Sep 2024 11:01:53 -0700 Subject: [PATCH 11/20] Handle domains without TLD --- src/api/tests/test_rdap.py | 40 ++++++++++++++++++++++++++++++++++++++ src/api/views.py | 4 ++++ 2 files changed, 44 insertions(+) create mode 100644 src/api/tests/test_rdap.py diff --git a/src/api/tests/test_rdap.py b/src/api/tests/test_rdap.py new file mode 100644 index 000000000..bd442be90 --- /dev/null +++ b/src/api/tests/test_rdap.py @@ -0,0 +1,40 @@ +"""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 available, check_domain_available +from .common import less_console_noise +from registrar.utility.errors import GenericError, GenericErrorCodes +from unittest.mock import call + +from epplibwrapper import ( + commands, +) + +API_BASE_PATH = "/api/v1/rdap/?domain=" + +class RdapAPITest(MockEppLib): + """Test that the RDAP 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): + 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("RDAP", response_object) \ No newline at end of file diff --git a/src/api/views.py b/src/api/views.py index 5c3b4c525..50a537517 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -108,6 +108,10 @@ def rdap(request, domain=""): Domain = apps.get_model("registrar.Domain") 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)).json() return JsonResponse(rdap_data) From 5439894fa3bf9834a420864d42889d5ddd241310 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 24 Sep 2024 16:37:36 -0700 Subject: [PATCH 12/20] Add RDAP tests --- src/api/tests/test_available.py | 2 +- src/api/tests/test_rdap.py | 47 ++++++++++++++++++++++++++++----- src/registrar/tests/common.py | 17 ++++++++++++ 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/api/tests/test_available.py b/src/api/tests/test_available.py index 35b2e3971..6c7a0a6c3 100644 --- a/src/api/tests/test_available.py +++ b/src/api/tests/test_available.py @@ -135,7 +135,7 @@ class AvailableAPITest(MockEppLib): 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_available_get(self): self.client.force_login(self.user) response = self.client.get(API_BASE_PATH + "nonsense") diff --git a/src/api/tests/test_rdap.py b/src/api/tests/test_rdap.py index bd442be90..cd7654f08 100644 --- a/src/api/tests/test_rdap.py +++ b/src/api/tests/test_rdap.py @@ -6,8 +6,9 @@ from django.contrib.auth import get_user_model from django.test import RequestFactory from django.test import TestCase -from ..views import available, check_domain_available +from ..views import available, check_domain_available, rdap from .common import less_console_noise +from registrar.tests.common import MockRdapLib, MockEppLib from registrar.utility.errors import GenericError, GenericErrorCodes from unittest.mock import call @@ -17,10 +18,42 @@ from epplibwrapper import ( API_BASE_PATH = "/api/v1/rdap/?domain=" -class RdapAPITest(MockEppLib): - """Test that the RDAP API can be called as expected.""" - def setUp(self): +class RDapViewTest(MockRdapLib): + """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(MockRdapLib): + """Test that the API can be called as expected.""" + + def setUp(self): super().setUp() username = "test_user" first_name = "First" @@ -33,8 +66,10 @@ class RdapAPITest(MockEppLib): ) 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") + self.assertContains(response, "rdap") response_object = json.loads(response.content) - self.assertIn("RDAP", response_object) \ No newline at end of file + self.assertIn("rdapConformance", response_object) + diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 4edfbe680..fd052420e 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -1029,6 +1029,23 @@ def generic_domain_object(domain_type, object_name): return domain_request +class MockRdapLib(TestCase): + class fakedRdapObject(object): + def __init__( + self, + rdapConformance=..., + description=..., + errorCode=..., + title=... + ): + self.rdapConformance = rdapConformance + self.description = description + self.errorCode = errorCode + self.title = title + + + + class MockEppLib(TestCase): class fakedEppObject(object): """""" From 23cb5681eaad213b4674d325936a4eb831391190 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 24 Sep 2024 16:47:28 -0700 Subject: [PATCH 13/20] Remove unused mock RDAP lib class since RDAP not used in registrar views --- src/api/tests/test_rdap.py | 5 ++--- src/registrar/tests/common.py | 17 ----------------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/src/api/tests/test_rdap.py b/src/api/tests/test_rdap.py index cd7654f08..b2a474514 100644 --- a/src/api/tests/test_rdap.py +++ b/src/api/tests/test_rdap.py @@ -8,7 +8,6 @@ from django.test import TestCase from ..views import available, check_domain_available, rdap from .common import less_console_noise -from registrar.tests.common import MockRdapLib, MockEppLib from registrar.utility.errors import GenericError, GenericErrorCodes from unittest.mock import call @@ -19,7 +18,7 @@ from epplibwrapper import ( API_BASE_PATH = "/api/v1/rdap/?domain=" -class RDapViewTest(MockRdapLib): +class RDapViewTest(TestCase): """Test that the RDAP view function works as expected""" def setUp(self): @@ -50,7 +49,7 @@ class RDapViewTest(MockRdapLib): self.assertIn("errorCode", response_object) -class RdapAPITest(MockRdapLib): +class RdapAPITest(TestCase): """Test that the API can be called as expected.""" def setUp(self): diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index fd052420e..4edfbe680 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -1029,23 +1029,6 @@ def generic_domain_object(domain_type, object_name): return domain_request -class MockRdapLib(TestCase): - class fakedRdapObject(object): - def __init__( - self, - rdapConformance=..., - description=..., - errorCode=..., - title=... - ): - self.rdapConformance = rdapConformance - self.description = description - self.errorCode = errorCode - self.title = title - - - - class MockEppLib(TestCase): class fakedEppObject(object): """""" From 155e7367c15d7ae4dea123e40f885dd9bf61b1d9 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 24 Sep 2024 16:47:49 -0700 Subject: [PATCH 14/20] Fix linter --- src/api/tests/test_available.py | 2 +- src/api/tests/test_rdap.py | 3 +-- src/api/views.py | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/api/tests/test_available.py b/src/api/tests/test_available.py index 6c7a0a6c3..35b2e3971 100644 --- a/src/api/tests/test_available.py +++ b/src/api/tests/test_available.py @@ -135,7 +135,7 @@ class AvailableAPITest(MockEppLib): 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_available_get(self): self.client.force_login(self.user) response = self.client.get(API_BASE_PATH + "nonsense") diff --git a/src/api/tests/test_rdap.py b/src/api/tests/test_rdap.py index b2a474514..665e61397 100644 --- a/src/api/tests/test_rdap.py +++ b/src/api/tests/test_rdap.py @@ -63,7 +63,7 @@ class RdapAPITest(TestCase): 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) @@ -71,4 +71,3 @@ class RdapAPITest(TestCase): 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 50a537517..4f0670266 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -103,8 +103,7 @@ def available(request, domain=""): @require_http_methods(["GET"]) @login_not_required def rdap(request, domain=""): - """TODO: Write description - """ + """TODO: Write description""" Domain = apps.get_model("registrar.Domain") domain = request.GET.get("domain", "") From a435eb1f687fc8f65e6400bbdd05fb187d8f5b9a Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 24 Sep 2024 16:50:57 -0700 Subject: [PATCH 15/20] Fix typo --- src/api/tests/test_rdap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/tests/test_rdap.py b/src/api/tests/test_rdap.py index 665e61397..beb40835b 100644 --- a/src/api/tests/test_rdap.py +++ b/src/api/tests/test_rdap.py @@ -18,7 +18,7 @@ from epplibwrapper import ( API_BASE_PATH = "/api/v1/rdap/?domain=" -class RDapViewTest(TestCase): +class RdapViewTest(TestCase): """Test that the RDAP view function works as expected""" def setUp(self): From d6201fc31f56c827f0e27cba6d569cd3be8cb630 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 24 Sep 2024 17:02:37 -0700 Subject: [PATCH 16/20] Add description to rdap endpoint --- src/api/tests/test_rdap.py | 9 +-------- src/api/views.py | 3 +-- src/registrar/tests/test_url_auth.py | 1 + 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/api/tests/test_rdap.py b/src/api/tests/test_rdap.py index beb40835b..789a99152 100644 --- a/src/api/tests/test_rdap.py +++ b/src/api/tests/test_rdap.py @@ -6,14 +6,7 @@ from django.contrib.auth import get_user_model from django.test import RequestFactory from django.test import TestCase -from ..views import available, check_domain_available, rdap -from .common import less_console_noise -from registrar.utility.errors import GenericError, GenericErrorCodes -from unittest.mock import call - -from epplibwrapper import ( - commands, -) +from ..views import rdap API_BASE_PATH = "/api/v1/rdap/?domain=" diff --git a/src/api/views.py b/src/api/views.py index 4f0670266..116762307 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -103,8 +103,7 @@ def available(request, domain=""): @require_http_methods(["GET"]) @login_not_required def rdap(request, domain=""): - """TODO: Write description""" - Domain = apps.get_model("registrar.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 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", ] From a4c4bba610e9d631c00ec32c2ea301cbde47e750 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Wed, 25 Sep 2024 09:10:08 -0700 Subject: [PATCH 17/20] Add cache time --- src/api/views.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/api/views.py b/src/api/views.py index 116762307..788bd79f5 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -100,8 +100,11 @@ 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", "") From ea4b4e2dae385a197d4768e13a6da0bef5e0365f Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Wed, 25 Sep 2024 09:18:47 -0700 Subject: [PATCH 18/20] Add timeout to RDAP request --- src/api/views.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/api/views.py b/src/api/views.py index 788bd79f5..3391b6e1d 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -100,7 +100,6 @@ 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 @@ -113,7 +112,7 @@ def rdap(request, domain=""): if "." not in domain: domain = f"{domain}.gov" - rdap_data = requests.get(RDAP_URL.format(domain=domain)).json() + rdap_data = requests.get(RDAP_URL.format(domain=domain), timeout=5).json() return JsonResponse(rdap_data) From 3f57a57089f254a816b5bfa41d8acd89bb028e64 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 26 Sep 2024 18:57:38 -0400 Subject: [PATCH 19/20] Fixed text labels --- src/registrar/admin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 397c4a3ed..00b037b65 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2555,7 +2555,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): def dnssecdata(self, obj): return "Yes" if obj.dnssecdata else "No" - dnssecdata.short_description = "DNS Sec Enabled" # type: ignore + dnssecdata.short_description = "DNSSEC enabled" # type: ignore # Custom method to display formatted nameservers def nameservers(self, obj): @@ -2572,7 +2572,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): # Join the formatted strings with line breaks return "\n".join(formatted_nameservers) - nameservers.short_description = "Nameservers" # type: ignore + nameservers.short_description = "Name servers" # type: ignore def custom_election_board(self, obj): domain_info = getattr(obj, "domain_info", None) From 2ab29b9f438db7fde5220390018dad532bdea266 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Mon, 30 Sep 2024 11:35:56 -0700 Subject: [PATCH 20/20] Remove old availability URL implementation --- src/api/views.py | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/src/api/views.py b/src/api/views.py index 3391b6e1d..a7b4bde75 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -18,7 +18,6 @@ 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}" @@ -42,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.
UIDNameEmail