diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index c3bcac535..d91021225 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -12,6 +12,7 @@ from django.http.response import HttpResponseRedirect
from django.urls import reverse
from epplibwrapper.errors import ErrorCode, RegistryError
from registrar.models.domain import Domain
+from registrar.models.user import User
from registrar.utility import csv_export
from . import models
from auditlog.models import LogEntry # type: ignore
@@ -538,6 +539,9 @@ class DomainInformationAdmin(ListHeaderAdmin):
# to activate the edit/delete/view buttons
filter_horizontal = ("other_contacts",)
+ # Table ordering
+ ordering = ["domain__name"]
+
def get_readonly_fields(self, request, obj=None):
"""Set the read-only state on form elements.
We have 1 conditions that determine which fields are read-only:
@@ -589,6 +593,27 @@ class DomainApplicationAdmin(ListHeaderAdmin):
"""Custom domain applications admin class."""
+ class InvestigatorFilter(admin.SimpleListFilter):
+ """Custom investigator filter that only displays users with the manager role"""
+
+ title = "investigator"
+ # Match the old param name to avoid unnecessary refactoring
+ parameter_name = "investigator__id__exact"
+
+ def lookups(self, request, model_admin):
+ """Lookup reimplementation, gets users of is_staff.
+ Returns a list of tuples consisting of (user.id, user)
+ """
+ privileged_users = User.objects.filter(is_staff=True).order_by("first_name", "last_name", "email")
+ return [(user.id, user) for user in privileged_users]
+
+ def queryset(self, request, queryset):
+ """Custom queryset implementation, filters by investigator"""
+ if self.value() is None:
+ return queryset
+ else:
+ return queryset.filter(investigator__id__exact=self.value())
+
# Columns
list_display = [
"requested_domain",
@@ -600,7 +625,7 @@ class DomainApplicationAdmin(ListHeaderAdmin):
]
# Filters
- list_filter = ("status", "organization_type", "investigator")
+ list_filter = ("status", "organization_type", InvestigatorFilter)
# Search
search_fields = [
@@ -676,6 +701,23 @@ class DomainApplicationAdmin(ListHeaderAdmin):
filter_horizontal = ("current_websites", "alternative_domains", "other_contacts")
+ # Table ordering
+ ordering = ["requested_domain__name"]
+
+ # lists in filter_horizontal are not sorted properly, sort them
+ # by website
+ def formfield_for_manytomany(self, db_field, request, **kwargs):
+ if db_field.name in ("current_websites", "alternative_domains"):
+ kwargs["queryset"] = models.Website.objects.all().order_by("website") # Sort websites
+ return super().formfield_for_manytomany(db_field, request, **kwargs)
+
+ def formfield_for_foreignkey(self, db_field, request, **kwargs):
+ # Removes invalid investigator options from the investigator dropdown
+ if db_field.name == "investigator":
+ kwargs["queryset"] = User.objects.filter(is_staff=True)
+ return db_field.formfield(**kwargs)
+ return super().formfield_for_foreignkey(db_field, request, **kwargs)
+
# Trigger action when a fieldset is changed
def save_model(self, request, obj, form, change):
if obj and obj.creator.status != models.User.RESTRICTED:
@@ -865,6 +907,9 @@ class DomainAdmin(ListHeaderAdmin):
change_list_template = "django/admin/domain_change_list.html"
readonly_fields = ["state", "expiration_date"]
+ # Table ordering
+ ordering = ["name"]
+
def export_data_type(self, request):
# match the CSV example with all the fields
response = HttpResponse(content_type="text/csv")
diff --git a/src/registrar/migrations/0057_domainapplication_submission_date.py b/src/registrar/migrations/0057_domainapplication_submission_date.py
new file mode 100644
index 000000000..a2a170888
--- /dev/null
+++ b/src/registrar/migrations/0057_domainapplication_submission_date.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.2.7 on 2023-12-13 15:04
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("registrar", "0056_alter_domain_state_alter_domainapplication_status_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="domainapplication",
+ name="submission_date",
+ field=models.DateField(blank=True, default=None, help_text="Date submitted", null=True),
+ ),
+ ]
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
index 44cb45433..7d2086499 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -8,6 +8,7 @@ from typing import Optional
from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore
from django.db import models
+from django.utils import timezone
from typing import Any
@@ -200,6 +201,14 @@ class Domain(TimeStampedModel, DomainHelper):
"""Get the `cr_date` element from the registry."""
return self._get_property("cr_date")
+ @creation_date.setter # type: ignore
+ def creation_date(self, cr_date: date):
+ """
+ Direct setting of the creation date in the registry is not implemented.
+
+ Creation date can only be set by registry."""
+ raise NotImplementedError()
+
@Cache
def last_transferred_date(self) -> date:
"""Get the `tr_date` element from the registry."""
@@ -963,6 +972,16 @@ class Domain(TimeStampedModel, DomainHelper):
def isActive(self):
return self.state == Domain.State.CREATED
+ def is_expired(self):
+ """
+ Check if the domain's expiration date is in the past.
+ Returns True if expired, False otherwise.
+ """
+ if self.expiration_date is None:
+ return True
+ now = timezone.now().date()
+ return self.expiration_date < now
+
def map_epp_contact_to_public_contact(self, contact: eppInfo.InfoContactResultData, contact_id, contact_type):
"""Maps the Epp contact representation to a PublicContact object.
@@ -1582,38 +1601,11 @@ class Domain(TimeStampedModel, DomainHelper):
def _fetch_cache(self, fetch_hosts=False, fetch_contacts=False):
"""Contact registry for info about a domain."""
try:
- # get info from registry
data_response = self._get_or_create_domain()
cache = self._extract_data_from_response(data_response)
-
- # remove null properties (to distinguish between "a value of None" and null)
- cleaned = self._remove_null_properties(cache)
-
- if "statuses" in cleaned:
- cleaned["statuses"] = [status.state for status in cleaned["statuses"]]
-
- cleaned["dnssecdata"] = self._get_dnssec_data(data_response.extensions)
-
- # Capture and store old hosts and contacts from cache if they exist
- old_cache_hosts = self._cache.get("hosts")
- old_cache_contacts = self._cache.get("contacts")
-
- if fetch_contacts:
- cleaned["contacts"] = self._get_contacts(cleaned.get("_contacts", []))
- if old_cache_hosts is not None:
- logger.debug("resetting cleaned['hosts'] to old_cache_hosts")
- cleaned["hosts"] = old_cache_hosts
-
- if fetch_hosts:
- cleaned["hosts"] = self._get_hosts(cleaned.get("_hosts", []))
- if old_cache_contacts is not None:
- cleaned["contacts"] = old_cache_contacts
-
- # if expiration date from registry does not match what is in db,
- # update the db
- if "ex_date" in cleaned and cleaned["ex_date"] != self.expiration_date:
- self.expiration_date = cleaned["ex_date"]
- self.save()
+ cleaned = self._clean_cache(cache, data_response)
+ self._update_hosts_and_contacts(cleaned, fetch_hosts, fetch_contacts)
+ self._update_dates(cleaned)
self._cache = cleaned
@@ -1621,6 +1613,7 @@ class Domain(TimeStampedModel, DomainHelper):
logger.error(e)
def _extract_data_from_response(self, data_response):
+ """extract data from response from registry"""
data = data_response.res_data[0]
return {
"auth_info": getattr(data, "auth_info", ...),
@@ -1635,6 +1628,15 @@ class Domain(TimeStampedModel, DomainHelper):
"up_date": getattr(data, "up_date", ...),
}
+ def _clean_cache(self, cache, data_response):
+ """clean up the cache"""
+ # remove null properties (to distinguish between "a value of None" and null)
+ cleaned = self._remove_null_properties(cache)
+ if "statuses" in cleaned:
+ cleaned["statuses"] = [status.state for status in cleaned["statuses"]]
+ cleaned["dnssecdata"] = self._get_dnssec_data(data_response.extensions)
+ return cleaned
+
def _remove_null_properties(self, cache):
return {k: v for k, v in cache.items() if v is not ...}
@@ -1648,6 +1650,42 @@ class Domain(TimeStampedModel, DomainHelper):
dnssec_data = extension
return dnssec_data
+ def _update_hosts_and_contacts(self, cleaned, fetch_hosts, fetch_contacts):
+ """Capture and store old hosts and contacts from cache if they don't exist"""
+ old_cache_hosts = self._cache.get("hosts")
+ old_cache_contacts = self._cache.get("contacts")
+
+ if fetch_contacts:
+ cleaned["contacts"] = self._get_contacts(cleaned.get("_contacts", []))
+ if old_cache_hosts is not None:
+ logger.debug("resetting cleaned['hosts'] to old_cache_hosts")
+ cleaned["hosts"] = old_cache_hosts
+
+ if fetch_hosts:
+ cleaned["hosts"] = self._get_hosts(cleaned.get("_hosts", []))
+ if old_cache_contacts is not None:
+ cleaned["contacts"] = old_cache_contacts
+
+ def _update_dates(self, cleaned):
+ """Update dates (expiration and creation) from cleaned"""
+ requires_save = False
+
+ # if expiration date from registry does not match what is in db,
+ # update the db
+ if "ex_date" in cleaned and cleaned["ex_date"] != self.expiration_date:
+ self.expiration_date = cleaned["ex_date"]
+ requires_save = True
+
+ # if creation_date from registry does not match what is in db,
+ # update the db
+ if "cr_date" in cleaned and cleaned["cr_date"] != self.created_at:
+ self.created_at = cleaned["cr_date"]
+ requires_save = True
+
+ # if either registration date or creation date need updating
+ if requires_save:
+ self.save()
+
def _get_contacts(self, contacts):
choices = PublicContact.ContactTypeChoices
# We expect that all these fields get populated,
diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py
index 8fca5918b..6f88c4ea0 100644
--- a/src/registrar/models/domain_application.py
+++ b/src/registrar/models/domain_application.py
@@ -6,6 +6,7 @@ import logging
from django.apps import apps
from django.db import models
from django_fsm import FSMField, transition # type: ignore
+from django.utils import timezone
from registrar.models.domain import Domain
from .utility.time_stamped_model import TimeStampedModel
@@ -547,6 +548,14 @@ class DomainApplication(TimeStampedModel):
help_text="Acknowledged .gov acceptable use policy",
)
+ # submission date records when application is submitted
+ submission_date = models.DateField(
+ null=True,
+ blank=True,
+ default=None,
+ help_text="Date submitted",
+ )
+
def __str__(self):
try:
if self.requested_domain and self.requested_domain.name:
@@ -617,6 +626,10 @@ class DomainApplication(TimeStampedModel):
if not DraftDomain.string_could_be_domain(self.requested_domain.name):
raise ValueError("Requested domain is not a valid domain name.")
+ # Update submission_date to today
+ self.submission_date = timezone.now().date()
+ self.save()
+
self._send_status_update_email(
"submission confirmation",
"emails/submission_confirmation.txt",
diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py
index 1d934f659..e6c323128 100644
--- a/src/registrar/models/domain_information.py
+++ b/src/registrar/models/domain_information.py
@@ -229,6 +229,7 @@ class DomainInformation(TimeStampedModel):
da_dict.pop("alternative_domains", None)
da_dict.pop("requested_domain", None)
da_dict.pop("approved_domain", None)
+ da_dict.pop("submission_date", None)
other_contacts = da_dict.pop("other_contacts", [])
domain_info = cls(**da_dict)
domain_info.domain_application = domain_application
diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html
index 470df7537..6d67925bc 100644
--- a/src/registrar/templates/domain_detail.html
+++ b/src/registrar/templates/domain_detail.html
@@ -6,7 +6,7 @@
@@ -17,7 +17,9 @@
Status:
- {% if domain.state == domain.State.UNKNOWN or domain.state == domain.State.DNS_NEEDED%}
+ {% if domain.is_expired %}
+ Expired
+ {% elif domain.state == domain.State.UNKNOWN or domain.state == domain.State.DNS_NEEDED%}
DNS needed
{% else %}
{{ domain.state|title }}
@@ -26,13 +28,16 @@
+
+
+ {% include "includes/domain_dates.html" %}
{% url 'domain-dns-nameservers' pk=domain.id as url %}
{% if domain.nameservers|length > 0 %}
{% include "includes/summary_item.html" with title='DNS name servers' domains='true' value=domain.nameservers list='true' edit_link=url editable=domain.is_editable %}
{% else %}
{% if domain.is_editable %}
- DNS name servers
+ DNS name servers
No DNS name servers have been added yet. Before your domain can be used we’ll need information about your domain name servers.
Add DNS name servers
{% else %}
diff --git a/src/registrar/templates/emails/domain_invitation.txt b/src/registrar/templates/emails/domain_invitation.txt
index ed9c297f4..f0612a67f 100644
--- a/src/registrar/templates/emails/domain_invitation.txt
+++ b/src/registrar/templates/emails/domain_invitation.txt
@@ -1,7 +1,7 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi.
-{{ full_name }} has added you as a manager on {{ domain.name }}.
+{{ requester_email }} has added you as a manager on {{ domain.name }}.
YOU NEED A LOGIN.GOV ACCOUNT
You’ll need a Login.gov account to manage your .gov domain. Login.gov provides
diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html
index 38c3e35b4..15835920b 100644
--- a/src/registrar/templates/home.html
+++ b/src/registrar/templates/home.html
@@ -39,7 +39,7 @@
Domain name |
- Date created |
+ Expires |
Status |
Action |
@@ -50,9 +50,11 @@
{{ domain.name }}
|
- {{ domain.created_time|date }} |
+ {{ domain.expiration_date|date }} |
- {% if domain.state == "unknown" or domain.state == "dns needed"%}
+ {% if domain.is_expired %}
+ Expired
+ {% elif domain.state == "unknown" or domain.state == "dns needed"%}
DNS needed
{% else %}
{{ domain.state|title }}
@@ -99,7 +101,7 @@
Domain name |
- Date created |
+ Date submitted |
Status |
Action |
@@ -110,7 +112,13 @@
{{ application.requested_domain.name|default:"New domain request" }}
|
- {{ application.created_at|date }} |
+
+ {% if application.submission_date %}
+ {{ application.submission_date|date }}
+ {% else %}
+ Not submitted
+ {% endif %}
+ |
{{ application.get_status_display }} |
{% if application.status == "started" or application.status == "action needed" or application.status == "withdrawn" %}
diff --git a/src/registrar/templates/includes/domain_dates.html b/src/registrar/templates/includes/domain_dates.html
new file mode 100644
index 000000000..c05e202e1
--- /dev/null
+++ b/src/registrar/templates/includes/domain_dates.html
@@ -0,0 +1,12 @@
+{% if domain.expiration_date or domain.created_at %}
+
+ {% if domain.expiration_date %}
+ Expires:
+ {{ domain.expiration_date|date }}
+ {% if domain.is_expired %} (expired){% endif %}
+
+ {% endif %}
+ {% if domain.created_at %}
+ Date created: {{ domain.created_at|date }}{% endif %}
+
+{% endif %}
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
index 29944396e..9c9da29cc 100644
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -15,13 +15,7 @@ from registrar.admin import (
ContactAdmin,
UserDomainRoleAdmin,
)
-from registrar.models import (
- Domain,
- DomainApplication,
- DomainInformation,
- User,
- DomainInvitation,
-)
+from registrar.models import Domain, DomainApplication, DomainInformation, User, DomainInvitation, Contact, Website
from registrar.models.user_domain_role import UserDomainRole
from .common import (
completed_application,
@@ -323,6 +317,7 @@ class TestDomainApplicationAdmin(MockEppLib):
self.admin = DomainApplicationAdmin(model=DomainApplication, admin_site=self.site)
self.superuser = create_superuser()
self.staffuser = create_user()
+ self.client = Client(HTTP_HOST="localhost:8080")
def test_short_org_name_in_applications_list(self):
"""
@@ -637,6 +632,7 @@ class TestDomainApplicationAdmin(MockEppLib):
"no_other_contacts_rationale",
"anything_else",
"is_policy_acknowledged",
+ "submission_date",
"current_websites",
"other_contacts",
"alternative_domains",
@@ -860,12 +856,224 @@ class TestDomainApplicationAdmin(MockEppLib):
with self.assertRaises(DomainInformation.DoesNotExist):
domain_information.refresh_from_db()
+ def test_has_correct_filters(self):
+ """
+ This test verifies that DomainApplicationAdmin has the correct filters set up.
+
+ It retrieves the current list of filters from DomainApplicationAdmin
+ and checks that it matches the expected list of filters.
+ """
+ request = self.factory.get("/")
+ request.user = self.superuser
+
+ # Grab the current list of table filters
+ readonly_fields = self.admin.get_list_filter(request)
+ expected_fields = ("status", "organization_type", DomainApplicationAdmin.InvestigatorFilter)
+
+ self.assertEqual(readonly_fields, expected_fields)
+
+ def test_table_sorted_alphabetically(self):
+ """
+ This test verifies that the DomainApplicationAdmin table is sorted alphabetically
+ by the 'requested_domain__name' field.
+
+ It creates a list of DomainApplication instances in a non-alphabetical order,
+ then retrieves the queryset from the DomainApplicationAdmin and checks
+ that it matches the expected queryset,
+ which is sorted alphabetically by the 'requested_domain__name' field.
+ """
+ # Creates a list of DomainApplications in scrambled order
+ multiple_unalphabetical_domain_objects("application")
+
+ request = self.factory.get("/")
+ request.user = self.superuser
+
+ # Get the expected list of alphabetically sorted DomainApplications
+ expected_order = DomainApplication.objects.order_by("requested_domain__name")
+
+ # Get the returned queryset
+ queryset = self.admin.get_queryset(request)
+
+ # Check the order
+ self.assertEqual(
+ list(queryset),
+ list(expected_order),
+ )
+
+ def test_displays_investigator_filter(self):
+ """
+ This test verifies that the investigator filter in the admin interface for
+ the DomainApplication model displays correctly.
+
+ It creates two DomainApplication instances, each with a different investigator.
+ It then simulates a staff user logging in and applying the investigator filter
+ on the DomainApplication admin page.
+
+ We then test if the page displays the filter we expect, but we do not test
+ if we get back the correct response in the table. This is to isolate if
+ the filter displays correctly, when the filter isn't filtering correctly.
+ """
+
+ # Create a mock DomainApplication object, with a fake investigator
+ application: DomainApplication = generic_domain_object("application", "SomeGuy")
+ investigator_user = User.objects.filter(username=application.investigator.username).get()
+ investigator_user.is_staff = True
+ investigator_user.save()
+
+ p = "userpass"
+ self.client.login(username="staffuser", password=p)
+ response = self.client.get(
+ "/admin/registrar/domainapplication/",
+ {
+ "investigator__id__exact": investigator_user.id,
+ },
+ follow=True,
+ )
+
+ # Then, test if the filter actually exists
+ self.assertIn("filters", response.context)
+
+ # Assert the content of filters and search_query
+ filters = response.context["filters"]
+
+ self.assertEqual(
+ filters,
+ [
+ {
+ "parameter_name": "investigator",
+ "parameter_value": "SomeGuy first_name:investigator SomeGuy last_name:investigator",
+ },
+ ],
+ )
+
+ def test_investigator_filter_filters_correctly(self):
+ """
+ This test verifies that the investigator filter in the admin interface for
+ the DomainApplication model works correctly.
+
+ It creates two DomainApplication instances, each with a different investigator.
+ It then simulates a staff user logging in and applying the investigator filter
+ on the DomainApplication admin page.
+
+ It then verifies that it was applied correctly.
+ The test checks that the response contains the expected DomainApplication pbjects
+ in the table.
+ """
+
+ # Create a mock DomainApplication object, with a fake investigator
+ application: DomainApplication = generic_domain_object("application", "SomeGuy")
+ investigator_user = User.objects.filter(username=application.investigator.username).get()
+ investigator_user.is_staff = True
+ investigator_user.save()
+
+ # Create a second mock DomainApplication object, to test filtering
+ application: DomainApplication = generic_domain_object("application", "BadGuy")
+ another_user = User.objects.filter(username=application.investigator.username).get()
+ another_user.is_staff = True
+ another_user.save()
+
+ p = "userpass"
+ self.client.login(username="staffuser", password=p)
+ response = self.client.get(
+ "/admin/registrar/domainapplication/",
+ {
+ "investigator__id__exact": investigator_user.id,
+ },
+ follow=True,
+ )
+
+ expected_name = "SomeGuy first_name:investigator SomeGuy last_name:investigator"
+ # We expect to see this four times, two of them are from the html for the filter,
+ # and the other two are the html from the list entry in the table.
+ self.assertContains(response, expected_name, count=4)
+
+ # Check that we don't also get the thing we aren't filtering for.
+ # We expect to see this two times in the filter
+ unexpected_name = "BadGuy first_name:investigator BadGuy last_name:investigator"
+ self.assertContains(response, unexpected_name, count=2)
+
+ def test_investigator_dropdown_displays_only_staff(self):
+ """
+ This test verifies that the dropdown for the 'investigator' field in the DomainApplicationAdmin
+ interface only displays users who are marked as staff.
+
+ It creates two DomainApplication instances, one with an investigator
+ who is a staff user and another with an investigator who is not a staff user.
+
+ It then retrieves the queryset for the 'investigator' dropdown from DomainApplicationAdmin
+ and checks that it matches the expected queryset, which only includes staff users.
+ """
+ # Create a mock DomainApplication object, with a fake investigator
+ application: DomainApplication = generic_domain_object("application", "SomeGuy")
+ investigator_user = User.objects.filter(username=application.investigator.username).get()
+ investigator_user.is_staff = True
+ investigator_user.save()
+
+ # Create a mock DomainApplication object, with a user that is not staff
+ application_2: DomainApplication = generic_domain_object("application", "SomeOtherGuy")
+ investigator_user_2 = User.objects.filter(username=application_2.investigator.username).get()
+ investigator_user_2.is_staff = False
+ investigator_user_2.save()
+
+ p = "userpass"
+ self.client.login(username="staffuser", password=p)
+
+ request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
+
+ # Get the actual field from the model's meta information
+ investigator_field = DomainApplication._meta.get_field("investigator")
+
+ # We should only be displaying staff users, in alphabetical order
+ expected_dropdown = list(User.objects.filter(is_staff=True))
+ current_dropdown = list(self.admin.formfield_for_foreignkey(investigator_field, request).queryset)
+
+ self.assertEqual(expected_dropdown, current_dropdown)
+
+ # Non staff users should not be in the list
+ self.assertNotIn(application_2, current_dropdown)
+
+ def test_investigator_list_is_alphabetically_sorted(self):
+ """
+ This test verifies that filter list for the 'investigator'
+ is displayed alphabetically
+ """
+ # Create a mock DomainApplication object, with a fake investigator
+ application: DomainApplication = generic_domain_object("application", "SomeGuy")
+ investigator_user = User.objects.filter(username=application.investigator.username).get()
+ investigator_user.is_staff = True
+ investigator_user.save()
+
+ application_2: DomainApplication = generic_domain_object("application", "AGuy")
+ investigator_user_2 = User.objects.filter(username=application_2.investigator.username).get()
+ investigator_user_2.first_name = "AGuy"
+ investigator_user_2.is_staff = True
+ investigator_user_2.save()
+
+ application_3: DomainApplication = generic_domain_object("application", "FinalGuy")
+ investigator_user_3 = User.objects.filter(username=application_3.investigator.username).get()
+ investigator_user_3.first_name = "FinalGuy"
+ investigator_user_3.is_staff = True
+ investigator_user_3.save()
+
+ p = "userpass"
+ self.client.login(username="staffuser", password=p)
+ request = RequestFactory().get("/")
+
+ expected_list = list(User.objects.filter(is_staff=True).order_by("first_name", "last_name", "email"))
+
+ # Get the actual sorted list of investigators from the lookups method
+ actual_list = [item for _, item in self.admin.InvestigatorFilter.lookups(self, request, self.admin)]
+
+ self.assertEqual(expected_list, actual_list)
+
def tearDown(self):
super().tearDown()
Domain.objects.all().delete()
DomainInformation.objects.all().delete()
DomainApplication.objects.all().delete()
User.objects.all().delete()
+ Contact.objects.all().delete()
+ Website.objects.all().delete()
class DomainInvitationAdminTest(TestCase):
diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py
index b76e2000c..461d87661 100644
--- a/src/registrar/tests/test_models_domain.py
+++ b/src/registrar/tests/test_models_domain.py
@@ -1966,6 +1966,9 @@ class TestExpirationDate(MockEppLib):
"""
super().setUp()
# for the tests, need a domain in the ready state
+ # mock data for self.domain includes the following dates:
+ # cr_date=datetime.datetime(2023, 5, 25, 19, 45, 35)
+ # ex_date=datetime.date(2023, 5, 25)
self.domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
# for the test, need a domain that will raise an exception
self.domain_w_error, _ = Domain.objects.get_or_create(name="fake-error.gov", state=Domain.State.READY)
@@ -1991,6 +1994,23 @@ class TestExpirationDate(MockEppLib):
with self.assertRaises(RegistryError):
self.domain_w_error.renew_domain()
+ def test_is_expired(self):
+ """assert that is_expired returns true for expiration_date in past"""
+ # force fetch_cache to be called
+ self.domain.statuses
+ self.assertTrue(self.domain.is_expired)
+
+ def test_is_not_expired(self):
+ """assert that is_expired returns false for expiration in future"""
+ # to do this, need to mock value returned from timezone.now
+ # set now to 2023-01-01
+ mocked_datetime = datetime.datetime(2023, 1, 1, 12, 0, 0)
+ # force fetch_cache which sets the expiration date to 2023-05-25
+ self.domain.statuses
+
+ with patch("registrar.models.domain.timezone.now", return_value=mocked_datetime):
+ self.assertFalse(self.domain.is_expired())
+
def test_expiration_date_updated_on_info_domain_call(self):
"""assert that expiration date in db is updated on info domain call"""
# force fetch_cache to be called
@@ -1999,6 +2019,36 @@ class TestExpirationDate(MockEppLib):
self.assertEquals(self.domain.expiration_date, test_date)
+class TestCreationDate(MockEppLib):
+ """Created_at in domain model is updated from EPP"""
+
+ def setUp(self):
+ """
+ Domain exists in registry
+ """
+ super().setUp()
+ # for the tests, need a domain with a creation date
+ self.domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
+ # creation_date returned from mockDataInfoDomain with creation date:
+ # cr_date=datetime.datetime(2023, 5, 25, 19, 45, 35)
+ self.creation_date = datetime.datetime(2023, 5, 25, 19, 45, 35)
+
+ def tearDown(self):
+ Domain.objects.all().delete()
+ super().tearDown()
+
+ def test_creation_date_setter_not_implemented(self):
+ """assert that the setter for creation date is not implemented and will raise error"""
+ with self.assertRaises(NotImplementedError):
+ self.domain.creation_date = datetime.date.today()
+
+ def test_creation_date_updated_on_info_domain_call(self):
+ """assert that creation date in db is updated on info domain call"""
+ # force fetch_cache to be called
+ self.domain.statuses
+ self.assertEquals(self.domain.created_at, self.creation_date)
+
+
class TestAnalystClientHold(MockEppLib):
"""Rule: Analysts may suspend or restore a domain by using client hold"""
diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py
index e69ab3dfd..bc661d119 100644
--- a/src/registrar/tests/test_views.py
+++ b/src/registrar/tests/test_views.py
@@ -6,7 +6,6 @@ from django.test import Client, TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
from .common import MockEppLib, completed_application, create_user # type: ignore
-
from django_webtest import WebTest # type: ignore
import boto3_mocking # type: ignore
@@ -100,7 +99,7 @@ class LoggedInTests(TestWithUser):
response = self.client.get("/")
# count = 2 because it is also in screenreader content
self.assertContains(response, "igorville.gov", count=2)
- self.assertContains(response, "DNS needed")
+ self.assertContains(response, "Expired")
# clean up
role.delete()
@@ -1331,6 +1330,12 @@ class TestDomainDetail(TestDomainOverview):
class TestDomainManagers(TestDomainOverview):
+ def tearDown(self):
+ """Ensure that the user has its original permissions"""
+ super().tearDown()
+ self.user.is_staff = False
+ self.user.save()
+
def test_domain_managers(self):
response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id}))
self.assertContains(response, "Domain managers")
@@ -1457,6 +1462,189 @@ class TestDomainManagers(TestDomainOverview):
Content=ANY,
)
+ @boto3_mocking.patching
+ def test_domain_invitation_email_has_email_as_requester_non_existent(self):
+ """Inviting a non existent user sends them an email, with email as the name."""
+ # make sure there is no user with this email
+ email_address = "mayor@igorville.gov"
+ User.objects.filter(email=email_address).delete()
+
+ self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
+
+ mock_client = MagicMock()
+ mock_client_instance = mock_client.return_value
+
+ with boto3_mocking.clients.handler_for("sesv2", mock_client):
+ add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
+ session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
+ add_page.form["email"] = email_address
+ self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+ add_page.form.submit()
+
+ # check the mock instance to see if `send_email` was called right
+ mock_client_instance.send_email.assert_called_once_with(
+ FromEmailAddress=settings.DEFAULT_FROM_EMAIL,
+ Destination={"ToAddresses": [email_address]},
+ Content=ANY,
+ )
+
+ # Check the arguments passed to send_email method
+ _, kwargs = mock_client_instance.send_email.call_args
+
+ # Extract the email content, and check that the message is as we expect
+ email_content = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
+ self.assertIn("info@example.com", email_content)
+
+ # Check that the requesters first/last name do not exist
+ self.assertNotIn("First", email_content)
+ self.assertNotIn("Last", email_content)
+ self.assertNotIn("First Last", email_content)
+
+ @boto3_mocking.patching
+ def test_domain_invitation_email_has_email_as_requester(self):
+ """Inviting a user sends them an email, with email as the name."""
+ # Create a fake user object
+ email_address = "mayor@igorville.gov"
+ User.objects.get_or_create(email=email_address, username="fakeuser@fakeymail.com")
+
+ self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
+
+ mock_client = MagicMock()
+ mock_client_instance = mock_client.return_value
+
+ with boto3_mocking.clients.handler_for("sesv2", mock_client):
+ add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
+ session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
+ add_page.form["email"] = email_address
+ self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+ add_page.form.submit()
+
+ # check the mock instance to see if `send_email` was called right
+ mock_client_instance.send_email.assert_called_once_with(
+ FromEmailAddress=settings.DEFAULT_FROM_EMAIL,
+ Destination={"ToAddresses": [email_address]},
+ Content=ANY,
+ )
+
+ # Check the arguments passed to send_email method
+ _, kwargs = mock_client_instance.send_email.call_args
+
+ # Extract the email content, and check that the message is as we expect
+ email_content = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
+ self.assertIn("info@example.com", email_content)
+
+ # Check that the requesters first/last name do not exist
+ self.assertNotIn("First", email_content)
+ self.assertNotIn("Last", email_content)
+ self.assertNotIn("First Last", email_content)
+
+ @boto3_mocking.patching
+ def test_domain_invitation_email_has_email_as_requester_staff(self):
+ """Inviting a user sends them an email, with email as the name."""
+ # Create a fake user object
+ email_address = "mayor@igorville.gov"
+ User.objects.get_or_create(email=email_address, username="fakeuser@fakeymail.com")
+
+ # Make sure the user is staff
+ self.user.is_staff = True
+ self.user.save()
+
+ self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
+
+ mock_client = MagicMock()
+ mock_client_instance = mock_client.return_value
+
+ with boto3_mocking.clients.handler_for("sesv2", mock_client):
+ add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
+ session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
+ add_page.form["email"] = email_address
+ self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+ add_page.form.submit()
+
+ # check the mock instance to see if `send_email` was called right
+ mock_client_instance.send_email.assert_called_once_with(
+ FromEmailAddress=settings.DEFAULT_FROM_EMAIL,
+ Destination={"ToAddresses": [email_address]},
+ Content=ANY,
+ )
+
+ # Check the arguments passed to send_email method
+ _, kwargs = mock_client_instance.send_email.call_args
+
+ # Extract the email content, and check that the message is as we expect
+ email_content = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
+ self.assertIn("help@get.gov", email_content)
+
+ # Check that the requesters first/last name do not exist
+ self.assertNotIn("First", email_content)
+ self.assertNotIn("Last", email_content)
+ self.assertNotIn("First Last", email_content)
+
+ @boto3_mocking.patching
+ def test_domain_invitation_email_displays_error_non_existent(self):
+ """Inviting a non existent user sends them an email, with email as the name."""
+ # make sure there is no user with this email
+ email_address = "mayor@igorville.gov"
+ User.objects.filter(email=email_address).delete()
+
+ # Give the user who is sending the email an invalid email address
+ self.user.email = ""
+ self.user.save()
+
+ self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
+
+ mock_client = MagicMock()
+
+ mock_error_message = MagicMock()
+ with boto3_mocking.clients.handler_for("sesv2", mock_client):
+ with patch("django.contrib.messages.error") as mock_error_message:
+ add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
+ session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
+ add_page.form["email"] = email_address
+ self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+ add_page.form.submit().follow()
+
+ expected_message_content = "Can't send invitation email. No email is associated with your account."
+
+ # Grab the message content
+ returned_error_message = mock_error_message.call_args[0][1]
+
+ # Check that the message content is what we expect
+ self.assertEqual(expected_message_content, returned_error_message)
+
+ @boto3_mocking.patching
+ def test_domain_invitation_email_displays_error(self):
+ """When the requesting user has no email, an error is displayed"""
+ # make sure there is no user with this email
+ # Create a fake user object
+ email_address = "mayor@igorville.gov"
+ User.objects.get_or_create(email=email_address, username="fakeuser@fakeymail.com")
+
+ # Give the user who is sending the email an invalid email address
+ self.user.email = ""
+ self.user.save()
+
+ self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
+
+ mock_client = MagicMock()
+
+ mock_error_message = MagicMock()
+ with boto3_mocking.clients.handler_for("sesv2", mock_client):
+ with patch("django.contrib.messages.error") as mock_error_message:
+ add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
+ session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
+ add_page.form["email"] = email_address
+ self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+ add_page.form.submit().follow()
+
+ expected_message_content = "Can't send invitation email. No email is associated with your account."
+
+ # Grab the message content
+ returned_error_message = mock_error_message.call_args[0][1]
+
+ # Check that the message content is what we expect
+ self.assertEqual(expected_message_content, returned_error_message)
+
def test_domain_invitation_cancel(self):
"""Posting to the delete view deletes an invitation."""
email_address = "mayor@igorville.gov"
diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py
index 5ec4433f7..59b206993 100644
--- a/src/registrar/views/domain.py
+++ b/src/registrar/views/domain.py
@@ -17,7 +17,6 @@ from django.views.generic.edit import FormMixin
from registrar.models import (
Domain,
- DomainInformation,
DomainInvitation,
User,
UserDomainRole,
@@ -644,21 +643,27 @@ class DomainAddUserView(DomainFormBaseView):
"""Get an absolute URL for this domain."""
return self.request.build_absolute_uri(reverse("domain", kwargs={"pk": self.object.id}))
- def _send_domain_invitation_email(self, email: str, add_success=True):
+ def _send_domain_invitation_email(self, email: str, requester: User, add_success=True):
"""Performs the sending of the domain invitation email,
does not make a domain information object
email: string- email to send to
add_success: bool- default True indicates:
adding a success message to the view if the email sending succeeds"""
- # created a new invitation in the database, so send an email
- domainInfoResults = DomainInformation.objects.filter(domain=self.object)
- domainInfo = domainInfoResults.first()
- first = ""
- last = ""
- if domainInfo is not None:
- first = domainInfo.creator.first_name
- last = domainInfo.creator.last_name
- full_name = f"{first} {last}"
+
+ # Set a default email address to send to for staff
+ requester_email = "help@get.gov"
+
+ # Check if the email requester has a valid email address
+ if not requester.is_staff and requester.email is not None and requester.email.strip() != "":
+ requester_email = requester.email
+ elif not requester.is_staff:
+ messages.error(self.request, "Can't send invitation email. No email is associated with your account.")
+ logger.error(
+ f"Can't send email to '{email}' on domain '{self.object}'."
+ f"No email exists for the requester '{requester.username}'.",
+ exc_info=True,
+ )
+ return None
try:
send_templated_email(
@@ -668,7 +673,7 @@ class DomainAddUserView(DomainFormBaseView):
context={
"domain_url": self._domain_abs_url(),
"domain": self.object,
- "full_name": full_name,
+ "requester_email": requester_email,
},
)
except EmailSendingError:
@@ -683,7 +688,7 @@ class DomainAddUserView(DomainFormBaseView):
if add_success:
messages.success(self.request, f"Invited {email} to this domain.")
- def _make_invitation(self, email_address: str):
+ def _make_invitation(self, email_address: str, requester: User):
"""Make a Domain invitation for this email and redirect with a message."""
invitation, created = DomainInvitation.objects.get_or_create(email=email_address, domain=self.object)
if not created:
@@ -693,21 +698,22 @@ class DomainAddUserView(DomainFormBaseView):
f"{email_address} has already been invited to this domain.",
)
else:
- self._send_domain_invitation_email(email=email_address)
+ self._send_domain_invitation_email(email=email_address, requester=requester)
return redirect(self.get_success_url())
def form_valid(self, form):
"""Add the specified user on this domain."""
requested_email = form.cleaned_data["email"]
+ requester = self.request.user
# look up a user with that email
try:
requested_user = User.objects.get(email=requested_email)
except User.DoesNotExist:
# no matching user, go make an invitation
- return self._make_invitation(requested_email)
+ return self._make_invitation(requested_email, requester)
else:
# if user already exists then just send an email
- self._send_domain_invitation_email(requested_email, add_success=False)
+ self._send_domain_invitation_email(requested_email, requester, add_success=False)
try:
UserDomainRole.objects.create(
diff --git a/src/registrar/views/index.py b/src/registrar/views/index.py
index b203694ff..9605c723d 100644
--- a/src/registrar/views/index.py
+++ b/src/registrar/views/index.py
@@ -1,7 +1,6 @@
-from django.db.models import F
from django.shortcuts import render
-from registrar.models import DomainApplication
+from registrar.models import DomainApplication, Domain, UserDomainRole
def index(request):
@@ -14,12 +13,9 @@ def index(request):
# the active applications table
context["domain_applications"] = applications.exclude(status="approved")
- domains = request.user.permissions.values(
- "role",
- pk=F("domain__id"),
- name=F("domain__name"),
- created_time=F("domain__created_at"),
- state=F("domain__state"),
- )
+ user_domain_roles = UserDomainRole.objects.filter(user=request.user)
+ domain_ids = user_domain_roles.values_list("domain_id", flat=True)
+ domains = Domain.objects.filter(id__in=domain_ids)
+
context["domains"] = domains
return render(request, "home.html", context)
| |