diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index fba675bf7..849cb6100 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -1434,6 +1434,20 @@ class DomainInvitationAdmin(ListHeaderAdmin):
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
+ def save_model(self, request, obj, form, change):
+ """
+ Override the save_model method.
+
+ On creation of a new domain invitation, attempt to retrieve the invitation,
+ which will be successful if a single User exists for that email; otherwise, will
+ just continue to create the invitation.
+ """
+ if not change and User.objects.filter(email=obj.email).count() == 1:
+ # Domain Invitation creation for an existing User
+ obj.retrieve()
+ # Call the parent save method to save the object
+ super().save_model(request, obj, form, change)
+
class PortfolioInvitationAdmin(ListHeaderAdmin):
"""Custom portfolio invitation admin class."""
diff --git a/src/registrar/assets/src/sass/_theme/_admin.scss b/src/registrar/assets/src/sass/_theme/_admin.scss
index 5bb523cac..a71804d77 100644
--- a/src/registrar/assets/src/sass/_theme/_admin.scss
+++ b/src/registrar/assets/src/sass/_theme/_admin.scss
@@ -351,6 +351,40 @@ div#content > h2 {
}
}
+.module {
+ .margin-left-0 {
+ margin-left: 0;
+ }
+ .margin-top-0 {
+ margin-top: 0;
+ }
+ .padding-left-0 {
+ padding-left: 0;
+ }
+}
+
+.admin-list-inline {
+ li {
+ float: left;
+ padding-top: 0;
+ margin-right: 4px;
+ }
+ li:not(:last-child)::after {
+ content: ",";
+ }
+}
+
+.form-row {
+ .margin-y-0 {
+ margin-top: 0;
+ margin-bottom: 0;
+ }
+ .padding-y-0 {
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+}
+
// Fixes a display issue where the list was entirely white, or had too much whitespace
.select2-dropdown {
display: inline-grid !important;
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
index 217b88202..6a135036d 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -1575,7 +1575,7 @@ class Domain(TimeStampedModel, DomainHelper):
logger.info("Changing to DNS_NEEDED state")
logger.info("able to transition to DNS_NEEDED state")
- def get_state_help_text(self) -> str:
+ def get_state_help_text(self, request=None) -> str:
"""Returns a str containing additional information about a given state.
Returns custom content for when the domain itself is expired."""
@@ -1585,6 +1585,8 @@ class Domain(TimeStampedModel, DomainHelper):
help_text = (
"This domain has expired, but it is still online. " "To renew this domain, contact help@get.gov."
)
+ elif flag_is_active(request, "domain_renewal") and self.is_expiring():
+ help_text = "This domain will expire soon. Contact one of the listed domain managers to renew the domain."
else:
help_text = Domain.State.get_help_text(self.state)
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 bebdd6ea2..a074e8a7c 100644
--- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
+++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
@@ -109,22 +109,38 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
This ONLY applies to analysts. For superusers, its business as usual.
{% endcomment %}
- {% with total_websites=field.contents|split:", " %}
- {% for website in total_websites %}
- {{ website }}{% if not forloop.last %}, {% endif %}
- {# Acts as a #}
- {% if total_websites|length < 5 %}
-
+ {% with total_websites=field.contents|split:", " %}
+ {% if total_websites|length == 1 %}
+
+ {% for alt_domain in original_object.alternative_domains.all %}
+ {% comment %}White space matters: do NOT reformat the following line{% endcomment %}
+
{% elif field.field.name == "domain_managers" or field.field.name == "invited_domain_managers" %}
diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html
index becf46d5b..2cd3e5a5c 100644
--- a/src/registrar/templates/domain_detail.html
+++ b/src/registrar/templates/domain_detail.html
@@ -49,11 +49,11 @@
{% if domain.get_state_help_text %}
- {% if has_domain_renewal_flag and domain.is_expiring and is_domain_manager %}
+ {% if has_domain_renewal_flag and domain.is_expired and is_domain_manager %}
This domain has expired, but it is still online.
{% url 'domain-renewal' pk=domain.id as url %}
Renew to maintain access.
- {% elif has_domain_renewal_flag and domain.is_expired and is_domain_manager %}
+ {% elif has_domain_renewal_flag and domain.is_expiring and is_domain_manager %}
This domain will expire soon.
{% url 'domain-renewal' pk=domain.id as url %}
Renew to maintain access.
diff --git a/src/registrar/templates/domain_renewal.html b/src/registrar/templates/domain_renewal.html
index 6bc2c1bf1..0e72568cc 100644
--- a/src/registrar/templates/domain_renewal.html
+++ b/src/registrar/templates/domain_renewal.html
@@ -51,8 +51,6 @@
{% url 'user-profile' as url %}
{% include "includes/summary_item.html" with title='Your Contact Information' value=request.user edit_link=url editable=is_editable contact='true' %}
-
-
{% if analyst_action != 'edit' or analyst_action_location != domain.pk %}
{% if is_portfolio_user and not is_domain_manager %}
@@ -67,10 +65,11 @@
{% url 'domain-security-email' pk=domain.id as url %}
{% if security_email is not None and security_email not in hidden_security_emails%}
- {% include "includes/summary_item.html" with title='Security email' value=security_email custom_text_for_value_none='We strongly recommend that you provide a security email. This email will allow the public to report observed or suspected security issues on your domain.' edit_link=url editable=is_editable %}
+ {% include "includes/summary_item.html" with title='Security email' value=security_email custom_text_for_value_none='We strongly recommend that you provide a security email. This email will allow the public to report observed or suspected security issues on your domain.' edit_link=url editable=is_editable %}
{% else %}
- {% include "includes/summary_item.html" with title='Security email' value='None provided' custom_text_for_value_none='We strongly recommend that you provide a security email. This email will allow the public to report observed or suspected security issues on your domain.' edit_link=url editable=is_editable %}
+ {% include "includes/summary_item.html" with title='Security email' value='None provided' custom_text_for_value_none='We strongly recommend that you provide a security email. This email will allow the public to report observed or suspected security issues on your domain.' edit_link=url editable=is_editable %}
{% endif %}
+
{% url 'domain-users' pk=domain.id as url %}
{% if portfolio %}
{% include "includes/summary_item.html" with title='Domain managers' domain_permissions=True value=domain edit_link=url editable=is_editable %}
@@ -78,7 +77,6 @@
{% include "includes/summary_item.html" with title='Domain managers' list=True users=True value=domain.permissions.all edit_link=url editable=is_editable %}
{% endif %}
-
- {% if has_domain_renewal_flag and num_expiring_domains > 0 %}
+ {% if has_domain_renewal_flag %}
{% endif %}
{% else %}
-
{% if custom_text_for_value_none %}
-
{{ custom_text_for_value_none }}
+
{{ custom_text_for_value_none }}
{% endif %}
{% if value %}
{{ value }}
{% endif %}
- {% if not value and not custom_text_for_value_none %}
+ {% if not value %}
None
{% endif %}
-
{% endif %}
diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py
index 8eca0108e..05b39cf55 100644
--- a/src/registrar/tests/common.py
+++ b/src/registrar/tests/common.py
@@ -13,6 +13,7 @@ from django.contrib.auth import get_user_model, login
from django.utils.timezone import make_aware
from datetime import date, datetime, timedelta
from django.utils import timezone
+from django.utils.html import strip_spaces_between_tags
from registrar.models import (
Contact,
@@ -107,6 +108,11 @@ def get_time_aware_date(date=datetime(2023, 11, 1)):
return timezone.make_aware(date)
+def normalize_html(html):
+ """Normalize HTML by removing newlines and extra spaces."""
+ return strip_spaces_between_tags(" ".join(html.split()))
+
+
class GenericTestHelper(TestCase):
"""A helper class that contains various helper functions for TestCases"""
@@ -1017,8 +1023,9 @@ def create_ready_domain():
# TODO in 1793: Remove the federal agency/updated federal agency fields
def completed_domain_request( # noqa
has_other_contacts=True,
- has_current_website=True,
- has_alternative_gov_domain=True,
+ # pass empty [] if you want current_websites or alternative_domains set to None
+ current_websites=["city.com"],
+ alternative_domains=["city1.gov"],
has_about_your_organization=True,
has_anything_else=True,
has_cisa_representative=True,
@@ -1050,8 +1057,6 @@ def completed_domain_request( # noqa
phone="(555) 555 5555",
)
domain, _ = DraftDomain.objects.get_or_create(name=name)
- alt, _ = Website.objects.get_or_create(website="city1.gov")
- current, _ = Website.objects.get_or_create(website="city.com")
other, _ = Contact.objects.get_or_create(
first_name="Testy",
last_name="Tester",
@@ -1118,10 +1123,14 @@ def completed_domain_request( # noqa
if has_other_contacts:
domain_request.other_contacts.add(other)
- if has_current_website:
- domain_request.current_websites.add(current)
- if has_alternative_gov_domain:
- domain_request.alternative_domains.add(alt)
+ if len(current_websites) > 0:
+ for website in current_websites:
+ current, _ = Website.objects.get_or_create(website=website)
+ domain_request.current_websites.add(current)
+ if len(alternative_domains) > 0:
+ for alternative_domain in alternative_domains:
+ alt, _ = Website.objects.get_or_create(website=alternative_domain)
+ domain_request.alternative_domains.add(alt)
if has_cisa_representative:
domain_request.cisa_representative_first_name = "CISA-first-name"
domain_request.cisa_representative_last_name = "CISA-last-name"
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
index dceb3a79e..3195f8237 100644
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -131,13 +131,11 @@ class TestDomainInvitationAdmin(TestCase):
tests have available superuser, client, and admin
"""
- @classmethod
- def setUpClass(cls):
- cls.factory = RequestFactory()
- cls.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite())
- cls.superuser = create_superuser()
-
def setUp(self):
+ self.factory = RequestFactory()
+ self.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite())
+ self.superuser = create_superuser()
+ self.domain = Domain.objects.create(name="example.com")
"""Create a client object"""
self.client = Client(HTTP_HOST="localhost:8080")
@@ -145,9 +143,6 @@ class TestDomainInvitationAdmin(TestCase):
"""Delete all DomainInvitation objects"""
DomainInvitation.objects.all().delete()
Contact.objects.all().delete()
-
- @classmethod
- def tearDownClass(self):
User.objects.all().delete()
@less_console_noise_decorator
@@ -168,6 +163,7 @@ class TestDomainInvitationAdmin(TestCase):
)
self.assertContains(response, "Show more")
+ @less_console_noise_decorator
def test_get_filters(self):
"""Ensures that our filters are displaying correctly"""
with less_console_noise():
@@ -192,6 +188,59 @@ class TestDomainInvitationAdmin(TestCase):
self.assertContains(response, invited_html, count=1)
self.assertContains(response, retrieved_html, count=1)
+ @less_console_noise_decorator
+ def test_save_model_user_exists(self):
+ """Test saving a domain invitation when the user exists.
+
+ Should attempt to retrieve the domain invitation."""
+ # Create a user with the same email
+ User.objects.create_user(email="test@example.com", username="username")
+
+ # Create a domain invitation instance
+ invitation = DomainInvitation(email="test@example.com", domain=self.domain)
+
+ admin_instance = DomainInvitationAdmin(DomainInvitation, admin_site=None)
+
+ # Create a request object
+ request = self.factory.post("/admin/registrar/DomainInvitation/add/")
+ request.user = self.superuser
+
+ # Patch the retrieve method
+ with patch.object(DomainInvitation, "retrieve") as mock_retrieve:
+ admin_instance.save_model(request, invitation, form=None, change=False)
+
+ # Assert retrieve was called
+ mock_retrieve.assert_called_once()
+
+ # Assert the invitation was saved
+ self.assertEqual(DomainInvitation.objects.count(), 1)
+ self.assertEqual(DomainInvitation.objects.first().email, "test@example.com")
+
+ @less_console_noise_decorator
+ def test_save_model_user_does_not_exist(self):
+ """Test saving a domain invitation when the user does not exist.
+
+ Should not attempt to retrieve the domain invitation."""
+ # Create a domain invitation instance
+ invitation = DomainInvitation(email="nonexistent@example.com", domain=self.domain)
+
+ admin_instance = DomainInvitationAdmin(DomainInvitation, admin_site=None)
+
+ # Create a request object
+ request = self.factory.post("/admin/registrar/DomainInvitation/add/")
+ request.user = self.superuser
+
+ # Patch the retrieve method to ensure it is not called
+ with patch.object(DomainInvitation, "retrieve") as mock_retrieve:
+ admin_instance.save_model(request, invitation, form=None, change=False)
+
+ # Assert retrieve was not called
+ mock_retrieve.assert_not_called()
+
+ # Assert the invitation was saved
+ self.assertEqual(DomainInvitation.objects.count(), 1)
+ self.assertEqual(DomainInvitation.objects.first().email, "nonexistent@example.com")
+
class TestUserPortfolioPermissionAdmin(TestCase):
"""Tests for the PortfolioInivtationAdmin class"""
diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py
index da789a1b5..968de0d65 100644
--- a/src/registrar/tests/test_admin_request.py
+++ b/src/registrar/tests/test_admin_request.py
@@ -40,6 +40,7 @@ from .common import (
multiple_unalphabetical_domain_objects,
MockEppLib,
GenericTestHelper,
+ normalize_html,
)
from unittest.mock import ANY, patch
@@ -1530,8 +1531,9 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertContains(response, expected_url)
@less_console_noise_decorator
- def test_other_websites_has_readonly_link(self):
- """Tests if the readonly other_websites field has links"""
+ def test_other_websites_has_one_readonly_link(self):
+ """Tests if the readonly other_websites field has links.
+ Test markup for one website."""
# Create a fake domain request
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
@@ -1547,8 +1549,224 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertContains(response, domain_request.requested_domain.name)
# Check that the page contains the link we expect.
- expected_url = 'city.com'
- self.assertContains(response, expected_url)
+ expected_markup = """
+
+ """
+
+ normalized_expected = normalize_html(expected_markup)
+ normalized_response = normalize_html(response.content.decode("utf-8"))
+
+ index = normalized_response.find(normalized_expected)
+
+ # Assert that the expected markup is found in the response
+ if index == -1:
+ self.fail(
+ f"Expected markup not found in the response.\n\n"
+ f"Expected:\n{normalized_expected}\n\n"
+ f"Start index of mismatch: {index}\n\n"
+ f"Consider checking the surrounding response for context."
+ )
+
+ @less_console_noise_decorator
+ def test_other_websites_has_few_readonly_links(self):
+ """Tests if the readonly other_websites field has links.
+ Test markup for 5 or less websites."""
+
+ # Create a domain request with 4 current websites
+ domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.IN_REVIEW,
+ current_websites=["city.gov", "city2.gov", "city3.gov", "city4.gov"],
+ )
+
+ self.client.force_login(self.staffuser)
+ response = self.client.get(
+ "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
+ follow=True,
+ )
+
+ # Make sure the page loaded, and that we're on the right page
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, domain_request.requested_domain.name)
+
+ # Check that the page contains the link we expect.
+ expected_markup = """
+
+ """
+
+ normalized_expected = normalize_html(expected_markup)
+ normalized_response = normalize_html(response.content.decode("utf-8"))
+
+ index = normalized_response.find(normalized_expected)
+
+ # Assert that the expected markup is found in the response
+ if index == -1:
+ self.fail(
+ f"Expected markup not found in the response.\n\n"
+ f"Expected:\n{normalized_expected}\n\n"
+ f"Start index of mismatch: {index}\n\n"
+ f"Consider checking the surrounding response for context."
+ )
+
+ @less_console_noise_decorator
+ def test_other_websites_has_lots_readonly_links(self):
+ """Tests if the readonly other_websites field has links.
+ Test markup for 6 or more websites."""
+
+ # Create a domain requests with 6 current websites
+ domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.IN_REVIEW,
+ current_websites=["city.gov", "city2.gov", "city3.gov", "city4.gov", "city5.gov", "city6.gov"],
+ )
+
+ self.client.force_login(self.staffuser)
+ response = self.client.get(
+ "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
+ follow=True,
+ )
+
+ # Make sure the page loaded, and that we're on the right page
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, domain_request.requested_domain.name)
+
+ # Check that the page contains the link we expect.
+ expected_markup = """
+
+ """
+
+ normalized_expected = normalize_html(expected_markup)
+ normalized_response = normalize_html(response.content.decode("utf-8"))
+
+ index = normalized_response.find(normalized_expected)
+
+ # Assert that the expected markup is found in the response
+ if index == -1:
+ self.fail(
+ f"Expected markup not found in the response.\n\n"
+ f"Expected:\n{normalized_expected}\n\n"
+ f"Start index of mismatch: {index}\n\n"
+ f"Consider checking the surrounding response for context."
+ )
+
+ @less_console_noise_decorator
+ def test_alternative_domains_has_one_readonly_link(self):
+ """Tests if the readonly alternative_domains field has links.
+ Test markup for one website."""
+
+ # Create a fake domain request
+ domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
+
+ self.client.force_login(self.staffuser)
+ response = self.client.get(
+ "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
+ follow=True,
+ )
+
+ # Make sure the page loaded, and that we're on the right page
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, domain_request.requested_domain.name)
+
+ # Check that the page contains the link we expect.
+ website = Website.objects.filter(website="city1.gov").first()
+ base_url = "/admin/registrar/website"
+ return_path = f"/admin/registrar/domainrequest/{domain_request.pk}/change/"
+ expected_markup = f"""
+