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)