diff --git a/src/registrar/admin.py b/src/registrar/admin.py index d6af9497d..884ef9b46 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1,18 +1,20 @@ +from datetime import date import logging + from django import forms -from django.db.models.functions import Concat -from django.http import HttpResponse +from django.db.models.functions import Concat, Coalesce +from django.db.models import Value, CharField +from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import redirect from django_fsm import get_available_FIELD_transitions from django.contrib import admin, messages from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType -from django.http.response import HttpResponseRedirect from django.urls import reverse +from dateutil.relativedelta import relativedelta # type: ignore from epplibwrapper.errors import ErrorCode, RegistryError -from registrar.models.domain import Domain -from registrar.models.user import User +from registrar.models import Contact, Domain, DomainApplication, DraftDomain, User, Website from registrar.utility import csv_export from registrar.views.utility.mixins import OrderableFieldsMixin from django.contrib.admin.views.main import ORDER_VAR @@ -25,6 +27,7 @@ from django.utils.safestring import mark_safe from django.utils.html import escape from django.contrib.auth.forms import UserChangeForm, UsernameField + logger = logging.getLogger(__name__) @@ -194,41 +197,52 @@ class CustomLogEntryAdmin(LogEntryAdmin): class AdminSortFields: - def get_queryset(db_field): + _name_sort = ["first_name", "last_name", "email"] + + # Define a mapping of field names to model querysets and sort expressions. + # A dictionary is used for specificity, but the downside is some degree of repetition. + # To eliminate this, this list can be generated dynamically but the readability of that + # is impacted. + sort_mapping = { + # == Contact == # + "other_contacts": (Contact, _name_sort), + "authorizing_official": (Contact, _name_sort), + "submitter": (Contact, _name_sort), + # == User == # + "creator": (User, _name_sort), + "user": (User, _name_sort), + "investigator": (User, _name_sort), + # == Website == # + "current_websites": (Website, "website"), + "alternative_domains": (Website, "website"), + # == DraftDomain == # + "requested_domain": (DraftDomain, "name"), + # == DomainApplication == # + "domain_application": (DomainApplication, "requested_domain__name"), + # == Domain == # + "domain": (Domain, "name"), + "approved_domain": (Domain, "name"), + } + + @classmethod + def get_queryset(cls, db_field): """This is a helper function for formfield_for_manytomany and formfield_for_foreignkey""" - # customize sorting - if db_field.name in ( - "other_contacts", - "authorizing_official", - "submitter", - ): - # Sort contacts by first_name, then last_name, then email - return models.Contact.objects.all().order_by(Concat("first_name", "last_name", "email")) - elif db_field.name in ("current_websites", "alternative_domains"): - # sort web sites - return models.Website.objects.all().order_by("website") - elif db_field.name in ( - "creator", - "user", - "investigator", - ): - # Sort users by first_name, then last_name, then email - return models.User.objects.all().order_by(Concat("first_name", "last_name", "email")) - elif db_field.name in ( - "domain", - "approved_domain", - ): - # Sort domains by name - return models.Domain.objects.all().order_by("name") - elif db_field.name in ("requested_domain",): - # Sort draft domains by name - return models.DraftDomain.objects.all().order_by("name") - elif db_field.name in ("domain_application",): - # Sort domain applications by name - return models.DomainApplication.objects.all().order_by("requested_domain__name") - else: + queryset_info = cls.sort_mapping.get(db_field.name, None) + if queryset_info is None: return None + # Grab the model we want to order, and grab how we want to order it + model, order_by = queryset_info + match db_field.name: + case "investigator": + # We should only return users who are staff. + return model.objects.filter(is_staff=True).order_by(*order_by) + case _: + if isinstance(order_by, list) or isinstance(order_by, tuple): + return model.objects.order_by(*order_by) + else: + return model.objects.order_by(order_by) + class AuditedAdmin(admin.ModelAdmin): """Custom admin to make auditing easier.""" @@ -246,9 +260,14 @@ class AuditedAdmin(admin.ModelAdmin): def formfield_for_manytomany(self, db_field, request, **kwargs): """customize the behavior of formfields with manytomany relationships. the customized behavior includes sorting of objects in lists as well as customizing helper text""" + + # Define a queryset. Note that in the super of this, + # a new queryset will only be generated if one does not exist. + # Thus, the order in which we define queryset matters. queryset = AdminSortFields.get_queryset(db_field) if queryset: kwargs["queryset"] = queryset + formfield = super().formfield_for_manytomany(db_field, request, **kwargs) # customize the help text for all formfields for manytomany formfield.help_text = ( @@ -258,11 +277,16 @@ class AuditedAdmin(admin.ModelAdmin): return formfield def formfield_for_foreignkey(self, db_field, request, **kwargs): - """customize the behavior of formfields with foreign key relationships. this will customize - the behavior of selects. customized behavior includes sorting of objects in list""" + """Customize the behavior of formfields with foreign key relationships. This will customize + the behavior of selects. Customized behavior includes sorting of objects in list.""" + + # Define a queryset. Note that in the super of this, + # a new queryset will only be generated if one does not exist. + # Thus, the order in which we define queryset matters. queryset = AdminSortFields.get_queryset(db_field) if queryset: kwargs["queryset"] = queryset + return super().formfield_for_foreignkey(db_field, request, **kwargs) @@ -297,7 +321,6 @@ class ListHeaderAdmin(AuditedAdmin, OrderableFieldsMixin): parameter_value: string} TODO: convert investigator id to investigator username """ - filters = [] # Retrieve the filter parameters for param in request.GET.keys(): @@ -343,6 +366,14 @@ class MyUserAdmin(BaseUserAdmin): form = MyUserAdminForm + class Meta: + """Contains meta information about this class""" + + model = models.User + fields = "__all__" + + _meta = Meta() + inlines = [UserContactInline] list_display = ( @@ -431,6 +462,42 @@ class MyUserAdmin(BaseUserAdmin): # in autocomplete_fields for user ordering = ["first_name", "last_name", "email"] + def get_search_results(self, request, queryset, search_term): + """ + Override for get_search_results. This affects any upstream model using autocomplete_fields, + such as DomainApplication. This is because autocomplete_fields uses an API call to fetch data, + and this fetch comes from this method. + """ + # Custom filtering logic + queryset, use_distinct = super().get_search_results(request, queryset, search_term) + + # If we aren't given a request to modify, we shouldn't try to + if request is None or not hasattr(request, "GET"): + return queryset, use_distinct + + # Otherwise, lets modify it! + request_get = request.GET + + # The request defines model name and field name. + # For instance, model_name could be "DomainApplication" + # and field_name could be "investigator". + model_name = request_get.get("model_name", None) + field_name = request_get.get("field_name", None) + + # Make sure we're only modifying requests from these models. + models_to_target = {"domainapplication"} + if model_name in models_to_target: + # Define rules per field + match field_name: + case "investigator": + # We should not display investigators who don't have a staff role + queryset = queryset.filter(is_staff=True) + case _: + # In the default case, do nothing + pass + + return queryset, use_distinct + # Let's define First group # (which should in theory be the ONLY group) def group(self, obj): @@ -495,6 +562,9 @@ class ContactAdmin(ListHeaderAdmin): "contact", "email", ] + # this ordering effects the ordering of results + # in autocomplete_fields for user + ordering = ["first_name", "last_name", "email"] # We name the custom prop 'contact' because linter # is not allowing a short_description attr on it @@ -803,8 +873,23 @@ class DomainApplicationAdmin(ListHeaderAdmin): """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] + # Select all investigators that are staff, then order by name and email + privileged_users = ( + DomainApplication.objects.select_related("investigator") + .filter(investigator__is_staff=True) + .order_by("investigator__first_name", "investigator__last_name", "investigator__email") + ) + + # Annotate the full name and return a values list that lookups can use + privileged_users_annotated = privileged_users.annotate( + full_name=Coalesce( + Concat("investigator__first_name", Value(" "), "investigator__last_name", output_field=CharField()), + "investigator__email", + output_field=CharField(), + ) + ).values_list("investigator__id", "full_name") + + return privileged_users_annotated def queryset(self, request, queryset): """Custom queryset implementation, filters by investigator""" @@ -902,26 +987,19 @@ class DomainApplicationAdmin(ListHeaderAdmin): "anything_else", "is_policy_acknowledged", ] - + autocomplete_fields = [ + "approved_domain", + "requested_domain", + "submitter", + "creator", + "authorizing_official", + "investigator", + ] 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: @@ -989,7 +1067,6 @@ class DomainApplicationAdmin(ListHeaderAdmin): admin user permissions and the application creator's status, so we'll use the baseline readonly_fields and extend it as needed. """ - readonly_fields = list(self.readonly_fields) # Check if the creator is restricted @@ -1069,8 +1146,8 @@ class DomainInformationInline(admin.StackedInline): return formfield def formfield_for_foreignkey(self, db_field, request, **kwargs): - """customize the behavior of formfields with foreign key relationships. this will customize - the behavior of selects. customized behavior includes sorting of objects in list""" + """Customize the behavior of formfields with foreign key relationships. This will customize + the behavior of selects. Customized behavior includes sorting of objects in list.""" queryset = AdminSortFields.get_queryset(db_field) if queryset: kwargs["queryset"] = queryset @@ -1124,6 +1201,26 @@ class DomainAdmin(ListHeaderAdmin): # Table ordering ordering = ["name"] + def changeform_view(self, request, object_id=None, form_url="", extra_context=None): + """Custom changeform implementation to pass in context information""" + if extra_context is None: + extra_context = {} + + # Pass in what the an extended expiration date would be for the expiration date modal + if object_id is not None: + domain = Domain.objects.get(pk=object_id) + years_to_extend_by = self._get_calculated_years_for_exp_date(domain) + curr_exp_date = domain.registry_expiration_date + if curr_exp_date < date.today(): + extra_context["extended_expiration_date"] = date.today() + relativedelta(years=years_to_extend_by) + else: + new_date = domain.registry_expiration_date + relativedelta(years=years_to_extend_by) + extra_context["extended_expiration_date"] = new_date + else: + extra_context["extended_expiration_date"] = None + + return super().changeform_view(request, object_id, form_url, extra_context) + def export_data_type(self, request): # match the CSV example with all the fields response = HttpResponse(content_type="text/csv") @@ -1182,6 +1279,7 @@ class DomainAdmin(ListHeaderAdmin): "_edit_domain": self.do_edit_domain, "_delete_domain": self.do_delete_domain, "_get_status": self.do_get_status, + "_extend_expiration_date": self.do_extend_expiration_date, } # Check which action button was pressed and call the corresponding function @@ -1192,6 +1290,81 @@ class DomainAdmin(ListHeaderAdmin): # If no matching action button is found, return the super method return super().response_change(request, obj) + def do_extend_expiration_date(self, request, obj): + """Extends a domains expiration date by one year from the current date""" + + # Make sure we're dealing with a Domain + if not isinstance(obj, Domain): + self.message_user(request, "Object is not of type Domain.", messages.ERROR) + return None + + years = self._get_calculated_years_for_exp_date(obj) + + # Renew the domain. + try: + obj.renew_domain(length=years) + self.message_user( + request, + "Successfully extended the expiration date.", + ) + except RegistryError as err: + if err.is_connection_error(): + error_message = "Error connecting to the registry." + else: + error_message = f"Error extending this domain: {err}." + self.message_user(request, error_message, messages.ERROR) + except KeyError: + # In normal code flow, a keyerror can only occur when + # fresh data can't be pulled from the registry, and thus there is no cache. + self.message_user( + request, + "Error connecting to the registry. No expiration date was found.", + messages.ERROR, + ) + except Exception as err: + logger.error(err, stack_info=True) + self.message_user(request, "Could not delete: An unspecified error occured", messages.ERROR) + + return HttpResponseRedirect(".") + + def _get_calculated_years_for_exp_date(self, obj, extension_period: int = 1): + """Given the current date, an extension period, and a registry_expiration_date + on the domain object, calculate the number of years needed to extend the + current expiration date by the extension period. + """ + # Get the date we want to update to + desired_date = self._get_current_date() + relativedelta(years=extension_period) + + # Grab the current expiration date + try: + exp_date = obj.registry_expiration_date + except KeyError: + # if no expiration date from registry, set it to today + logger.warning("current expiration date not set; setting to today") + exp_date = self._get_current_date() + + # If the expiration date is super old (2020, for example), we need to + # "catch up" to the current year, so we add the difference. + # If both years match, then lets just proceed as normal. + calculated_exp_date = exp_date + relativedelta(years=extension_period) + + year_difference = desired_date.year - exp_date.year + + years = extension_period + if desired_date > calculated_exp_date: + # Max probably isn't needed here (no code flow), but it guards against negative and 0. + # In both of those cases, we just want to extend by the extension_period. + years = max(extension_period, year_difference) + + return years + + # Workaround for unit tests, as we cannot mock date directly. + # it is immutable. Rather than dealing with a convoluted workaround, + # lets wrap this in a function. + def _get_current_date(self): + """Gets the current date""" + return date.today() + def do_delete_domain(self, request, obj): if not isinstance(obj, Domain): # Could be problematic if the type is similar, @@ -1350,6 +1523,10 @@ class DraftDomainAdmin(ListHeaderAdmin): search_fields = ["name"] search_help_text = "Search by draft domain name." + # this ordering effects the ordering of results + # in autocomplete_fields for user + ordering = ["name"] + class VerifiedByStaffAdmin(ListHeaderAdmin): list_display = ("email", "requestor", "truncated_notes", "created_at") diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 866c7bd7d..29aa9ce03 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -23,6 +23,33 @@ function openInNewTab(el, removeAttribute = false){ // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> // Initialization code. +/** An IIFE for pages in DjangoAdmin that use modals. + * Dja strips out form elements, and modals generate their content outside + * of the current form scope, so we need to "inject" these inputs. +*/ +(function (){ + function createPhantomModalFormButtons(){ + let submitButtons = document.querySelectorAll('.usa-modal button[type="submit"]'); + form = document.querySelector("form") + submitButtons.forEach((button) => { + + let input = document.createElement("input"); + input.type = "submit"; + input.name = button.name; + input.value = button.value; + input.style.display = "none" + + // Add the hidden input to the form + form.appendChild(input); + button.addEventListener("click", () => { + console.log("clicking") + input.click(); + }) + }) + } + + createPhantomModalFormButtons(); +})(); /** An IIFE for pages in DjangoAdmin which may need custom JS implementation. * Currently only appends target="_blank" to the domain_form object, * but this can be expanded. @@ -41,8 +68,8 @@ function openInNewTab(el, removeAttribute = false){ let domainFormElement = document.getElementById("domain_form"); let domainSubmitButton = document.getElementById("manageDomainSubmitButton"); if(domainSubmitButton && domainFormElement){ - domainSubmitButton.addEventListener("mouseover", () => openInNewTab(domainFormElement, true)); - domainSubmitButton.addEventListener("mouseout", () => openInNewTab(domainFormElement, false)); + domainSubmitButton.addEventListener("mouseover", () => openInNewTab(domainFormElement, true)); + domainSubmitButton.addEventListener("mouseout", () => openInNewTab(domainFormElement, false)); } } diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index 760c4f13a..b57c6a015 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -183,6 +183,10 @@ h1, h2, h3, .submit-row div.spacer { flex-grow: 1; } +.submit-row .mini-spacer{ + margin-left: 2px; + margin-right: 2px; +} .submit-row span { margin-top: units(1); } @@ -270,3 +274,31 @@ h1, h2, h3, margin: 0!important; } } + +// Hides the "clear" button on autocomplete, as we already have one to use +.select2-selection__clear { + display: none; +} + +// Fixes a display issue where the list was entirely white, or had too much whitespace +.select2-dropdown { + display: inline-grid !important; +} + +input.admin-confirm-button { + text-transform: none; +} + +// Button groups in /admin incorrectly have bullets. +// Remove that! +.usa-modal__footer .usa-button-group__item { + list-style-type: none; +} + +// USWDS media checks are overzealous in this situation, +// we should manually define this behaviour. +@media (max-width: 768px) { + .button-list-mobile { + display: contents !important; + } +} diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 7ebe3dc34..020ba43c0 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -265,15 +265,17 @@ class Domain(TimeStampedModel, DomainHelper): Default length and unit of time are 1 year. """ - # if no expiration date from registry, set to today + + # If no date is specified, grab the registry_expiration_date try: - cur_exp_date = self.registry_expiration_date + exp_date = self.registry_expiration_date except KeyError: + # if no expiration date from registry, set it to today logger.warning("current expiration date not set; setting to today") - cur_exp_date = date.today() + exp_date = date.today() # create RenewDomain request - request = commands.RenewDomain(name=self.name, cur_exp_date=cur_exp_date, period=epp.Period(length, unit)) + request = commands.RenewDomain(name=self.name, cur_exp_date=exp_date, period=epp.Period(length, unit)) try: # update expiration date in registry, and set the updated diff --git a/src/registrar/models/utility/generic_helper.py b/src/registrar/models/utility/generic_helper.py new file mode 100644 index 000000000..01d4e6b33 --- /dev/null +++ b/src/registrar/models/utility/generic_helper.py @@ -0,0 +1,37 @@ +"""This file contains general purpose helpers that don't belong in any specific location""" + +import time +import logging + + +logger = logging.getLogger(__name__) + + +class Timer: + """ + This class is used to measure execution time for performance profiling. + __enter__ and __exit__ is used such that you can wrap any code you want + around a with statement. After this exits, logger.info will print + the execution time in seconds. + + Note that this class does not account for general randomness as more + robust libraries do, so there is some tiny amount of latency involved + in using this, but it is minimal enough that for most applications it is not + noticable. + + Usage: + with Timer(): + ...some code + """ + + def __enter__(self): + """Starts the timer""" + self.start = time.time() + # This allows usage of the instance within the with block + return self + + def __exit__(self, *args): + """Ends the timer and logs what happened""" + self.end = time.time() + self.duration = self.end - self.start + logger.info(f"Execution time: {self.duration} seconds") diff --git a/src/registrar/templates/admin/base_site.html b/src/registrar/templates/admin/base_site.html index f9ff23455..73e9ba1f0 100644 --- a/src/registrar/templates/admin/base_site.html +++ b/src/registrar/templates/admin/base_site.html @@ -18,11 +18,12 @@ + + {% endblock %} {% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} - {% block extrastyle %}{{ block.super }} {% endblock %} diff --git a/src/registrar/templates/django/admin/domain_change_form.html b/src/registrar/templates/django/admin/domain_change_form.html index c4461d07f..67c5ac291 100644 --- a/src/registrar/templates/django/admin/domain_change_form.html +++ b/src/registrar/templates/django/admin/domain_change_form.html @@ -2,21 +2,117 @@ {% load i18n static %} {% block field_sets %} -
- - -
- {% if original.state == original.State.READY %} - - {% elif original.state == original.State.ON_HOLD %} - - {% endif %} - {% if original.state == original.State.READY or original.state == original.State.ON_HOLD %} - | - {% endif %} - {% if original.state != original.State.DELETED %} - +
+
+ + {# Dja has margin styles defined on inputs as is. Lets work with it, rather than fight it. #} + + +
+
+ {% if original.state != original.State.DELETED %} + + Extend expiration date + + | + {% endif %} + {% if original.state == original.State.READY %} + + {% elif original.state == original.State.ON_HOLD %} + + {% endif %} + {% if original.state == original.State.READY or original.state == original.State.ON_HOLD %} + | + {% endif %} + {% if original.state != original.State.DELETED %} + {% endif %} +
{{ block.super }} {% endblock %} + +{% block submit_buttons_bottom %} + {% comment %} + Modals behave very weirdly in django admin. + They tend to "strip out" any injected form elements, leaving only the main form. + In addition, USWDS handles modals by first destroying the element, then repopulating it toward the end of the page. + In effect, this means that the modal is not, and cannot, be surrounded by any form element at compile time. + + The current workaround for this is to use javascript to inject a hidden input, and bind submit of that + element to the click of the confirmation button within this modal. + + This is controlled by the class `dja-form-placeholder` on the button. + + In addition, the modal element MUST be placed low in the DOM. The script loads slower on DJA than on other portions + of the application, so this means that it will briefly "populate", causing unintended visual effects. + {% endcomment %} +
+
+
+ +
+

+ This will extend the expiration date by one year. + {# Acts as a
#} +

+ This action cannot be undone. +

+

+ Domain: {{ original.name }} + {# Acts as a
#} +

+ New expiration date: {{ extended_expiration_date }} + {{test}} +

+
+ + +
+ +
+
+{{ block.super }} +{% endblock %} \ No newline at end of file diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 2865bf5c5..17833d689 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -231,6 +231,7 @@ class AuditedAdminMockData: first_name="{} first_name:{}".format(item_name, short_hand), last_name="{} last_name:{}".format(item_name, short_hand), username="{} username:{}".format(item_name + str(uuid.uuid4())[:8], short_hand), + is_staff=True, )[0] return user @@ -921,6 +922,11 @@ class MockEppLib(TestCase): ex_date=datetime.date(2023, 5, 25), ) + mockButtonRenewedDomainExpDate = fakedEppObject( + "fake.gov", + ex_date=datetime.date(2025, 5, 25), + ) + mockDnsNeededRenewedDomainExpDate = fakedEppObject( "fakeneeded.gov", ex_date=datetime.date(2023, 2, 15), @@ -1049,11 +1055,19 @@ class MockEppLib(TestCase): res_data=[self.mockMaximumRenewedDomainExpDate], code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY, ) - else: - return MagicMock( - res_data=[self.mockRenewedDomainExpDate], - code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY, - ) + elif getattr(_request, "name", None) == "fake.gov": + period = getattr(_request, "period", None) + extension_period = getattr(period, "length", None) + if extension_period == 2: + return MagicMock( + res_data=[self.mockButtonRenewedDomainExpDate], + code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY, + ) + else: + return MagicMock( + res_data=[self.mockRenewedDomainExpDate], + code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY, + ) def mockInfoDomainCommands(self, _request, cleaned): request_name = getattr(_request, "name", None) diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index f90b18584..949636f3e 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1,6 +1,8 @@ +from datetime import date from django.test import TestCase, RequestFactory, Client from django.contrib.admin.sites import AdminSite from contextlib import ExitStack +from django_webtest import WebTest # type: ignore from django.contrib import messages from django.urls import reverse from registrar.admin import ( @@ -35,7 +37,7 @@ from .common import ( ) from django.contrib.sessions.backends.db import SessionStore from django.contrib.auth import get_user_model -from unittest.mock import patch +from unittest.mock import ANY, call, patch from unittest import skip from django.conf import settings @@ -45,7 +47,11 @@ import logging logger = logging.getLogger(__name__) -class TestDomainAdmin(MockEppLib): +class TestDomainAdmin(MockEppLib, WebTest): + # csrf checks do not work with WebTest. + # We disable them here. TODO for another ticket. + csrf_checks = False + def setUp(self): self.site = AdminSite() self.admin = DomainAdmin(model=Domain, admin_site=self.site) @@ -53,8 +59,177 @@ class TestDomainAdmin(MockEppLib): self.superuser = create_superuser() self.staffuser = create_user() self.factory = RequestFactory() + self.app.set_user(self.superuser.username) + self.client.force_login(self.superuser) super().setUp() + @skip("TODO for another ticket. This test case is grabbing old db data.") + @patch("registrar.admin.DomainAdmin._get_current_date", return_value=date(2024, 1, 1)) + def test_extend_expiration_date_button(self, mock_date_today): + """ + Tests if extend_expiration_date button extends correctly + """ + + # Create a ready domain with a preset expiration date + domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) + + response = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk])) + + # Make sure the ex date is what we expect it to be + domain_ex_date = Domain.objects.get(id=domain.id).expiration_date + self.assertEqual(domain_ex_date, date(2023, 5, 25)) + + # Make sure that the page is loading as expected + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + self.assertContains(response, "Extend expiration date") + + # Grab the form to submit + form = response.forms["domain_form"] + + with patch("django.contrib.messages.add_message") as mock_add_message: + # Submit the form + response = form.submit("_extend_expiration_date") + + # Follow the response + response = response.follow() + + # refresh_from_db() does not work for objects with protected=True. + # https://github.com/viewflow/django-fsm/issues/89 + new_domain = Domain.objects.get(id=domain.id) + + # Check that the current expiration date is what we expect + self.assertEqual(new_domain.expiration_date, date(2025, 5, 25)) + + # Assert that everything on the page looks correct + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + self.assertContains(response, "Extend expiration date") + + # Ensure the message we recieve is in line with what we expect + expected_message = "Successfully extended the expiration date." + expected_call = call( + # The WGSI request doesn't need to be tested + ANY, + messages.INFO, + expected_message, + extra_tags="", + fail_silently=False, + ) + mock_add_message.assert_has_calls([expected_call], 1) + + @patch("registrar.admin.DomainAdmin._get_current_date", return_value=date(2024, 1, 1)) + def test_extend_expiration_date_button_epp(self, mock_date_today): + """ + Tests if extend_expiration_date button sends the right epp command + """ + + # Create a ready domain with a preset expiration date + domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) + + response = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk])) + + # Make sure that the page is loading as expected + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + self.assertContains(response, "Extend expiration date") + + # Grab the form to submit + form = response.forms["domain_form"] + + with patch("django.contrib.messages.add_message") as mock_add_message: + with patch("registrar.models.Domain.renew_domain") as renew_mock: + # Submit the form + response = form.submit("_extend_expiration_date") + + # Follow the response + response = response.follow() + + # This value is based off of the current year - the expiration date. + # We "freeze" time to 2024, so 2024 - 2023 will always result in an + # "extension" of 2, as that will be one year of extension from that date. + extension_length = 2 + + # Assert that it is calling the function with the right extension length. + # We only need to test the value that EPP sends, as we can assume the other + # test cases cover the "renew" function. + renew_mock.assert_has_calls([call(length=extension_length)], any_order=False) + + # We should not make duplicate calls + self.assertEqual(renew_mock.call_count, 1) + + # Assert that everything on the page looks correct + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + self.assertContains(response, "Extend expiration date") + + # Ensure the message we recieve is in line with what we expect + expected_message = "Successfully extended the expiration date." + expected_call = call( + # The WGSI request doesn't need to be tested + ANY, + messages.INFO, + expected_message, + extra_tags="", + fail_silently=False, + ) + mock_add_message.assert_has_calls([expected_call], 1) + + @patch("registrar.admin.DomainAdmin._get_current_date", return_value=date(2023, 1, 1)) + def test_extend_expiration_date_button_date_matches_epp(self, mock_date_today): + """ + Tests if extend_expiration_date button sends the right epp command + when the current year matches the expiration date + """ + + # Create a ready domain with a preset expiration date + domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) + + response = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk])) + + # Make sure that the page is loading as expected + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + self.assertContains(response, "Extend expiration date") + + # Grab the form to submit + form = response.forms["domain_form"] + + with patch("django.contrib.messages.add_message") as mock_add_message: + with patch("registrar.models.Domain.renew_domain") as renew_mock: + # Submit the form + response = form.submit("_extend_expiration_date") + + # Follow the response + response = response.follow() + + extension_length = 1 + + # Assert that it is calling the function with the right extension length. + # We only need to test the value that EPP sends, as we can assume the other + # test cases cover the "renew" function. + renew_mock.assert_has_calls([call(length=extension_length)], any_order=False) + + # We should not make duplicate calls + self.assertEqual(renew_mock.call_count, 1) + + # Assert that everything on the page looks correct + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + self.assertContains(response, "Extend expiration date") + + # Ensure the message we recieve is in line with what we expect + expected_message = "Successfully extended the expiration date." + expected_call = call( + # The WGSI request doesn't need to be tested + ANY, + messages.INFO, + expected_message, + extra_tags="", + fail_silently=False, + ) + mock_add_message.assert_has_calls([expected_call], 1) + def test_short_org_name_in_domains_list(self): """ Make sure the short name is displaying in admin on the list page @@ -941,8 +1116,17 @@ class TestDomainApplicationAdmin(MockEppLib): 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) + sorted_fields = ["first_name", "last_name", "email"] + expected_dropdown = list(User.objects.filter(is_staff=True).order_by(*sorted_fields)) + + # Grab the current dropdown. We do an API call to autocomplete to get this info. + application_queryset = self.admin.formfield_for_foreignkey(investigator_field, request).queryset + user_request = self.factory.post( + "/admin/autocomplete/?app_label=registrar&model_name=domainapplication&field_name=investigator" + ) + user_admin = MyUserAdmin(User, self.site) + user_queryset = user_admin.get_search_results(user_request, application_queryset, None)[0] + current_dropdown = list(user_queryset) self.assertEqual(expected_dropdown, current_dropdown) @@ -976,7 +1160,13 @@ class TestDomainApplicationAdmin(MockEppLib): 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")) + # These names have metadata embedded in them. :investigator implicitly tests if + # these are actually from the attribute "investigator". + expected_list = [ + "AGuy AGuy last_name:investigator", + "FinalGuy FinalGuy last_name:investigator", + "SomeGuy first_name:investigator SomeGuy last_name:investigator", + ] # 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)] @@ -1396,11 +1586,46 @@ class AuditedAdminTest(TestCase): return ordered_list + def test_alphabetically_sorted_domain_application_investigator(self): + """Tests if the investigator field is alphabetically sorted by mimicking + the call event flow""" + # Creates multiple domain applications - review status does not matter + applications = multiple_unalphabetical_domain_objects("application") + + # Create a mock request + application_request = self.factory.post( + "/admin/registrar/domainapplication/{}/change/".format(applications[0].pk) + ) + + # Get the formfield data from the application page + application_admin = AuditedAdmin(DomainApplication, self.site) + field = DomainApplication.investigator.field + application_queryset = application_admin.formfield_for_foreignkey(field, application_request).queryset + + request = self.factory.post( + "/admin/autocomplete/?app_label=registrar&model_name=domainapplication&field_name=investigator" + ) + + sorted_fields = ["first_name", "last_name", "email"] + desired_sort_order = list(User.objects.filter(is_staff=True).order_by(*sorted_fields)) + + # Grab the data returned from get search results + admin = MyUserAdmin(User, self.site) + search_queryset = admin.get_search_results(request, application_queryset, None)[0] + current_sort_order = list(search_queryset) + + self.assertEqual( + desired_sort_order, + current_sort_order, + "Investigator is not ordered alphabetically", + ) + + # This test case should be refactored in general, as it is too overly specific and engineered def test_alphabetically_sorted_fk_fields_domain_application(self): tested_fields = [ DomainApplication.authorizing_official.field, DomainApplication.submitter.field, - DomainApplication.investigator.field, + # DomainApplication.investigator.field, DomainApplication.creator.field, DomainApplication.requested_domain.field, ] @@ -1418,39 +1643,40 @@ class AuditedAdminTest(TestCase): # but both fields are of a fixed length. # For test case purposes, this should be performant. for field in tested_fields: - isNamefield: bool = field == DomainApplication.requested_domain.field - if isNamefield: - sorted_fields = ["name"] - else: - sorted_fields = ["first_name", "last_name"] - # We want both of these to be lists, as it is richer test wise. - - desired_order = self.order_by_desired_field_helper(model_admin, request, field.name, *sorted_fields) - current_sort_order = list(model_admin.formfield_for_foreignkey(field, request).queryset) - - # Conforms to the same object structure as desired_order - current_sort_order_coerced_type = [] - - # This is necessary as .queryset and get_queryset - # return lists of different types/structures. - # We need to parse this data and coerce them into the same type. - for contact in current_sort_order: - if not isNamefield: - first = contact.first_name - last = contact.last_name + with self.subTest(field=field): + isNamefield: bool = field == DomainApplication.requested_domain.field + if isNamefield: + sorted_fields = ["name"] else: - first = contact.name - last = None + sorted_fields = ["first_name", "last_name"] + # We want both of these to be lists, as it is richer test wise. - name_tuple = self.coerced_fk_field_helper(first, last, field.name, ":") - if name_tuple is not None: - current_sort_order_coerced_type.append(name_tuple) + desired_order = self.order_by_desired_field_helper(model_admin, request, field.name, *sorted_fields) + current_sort_order = list(model_admin.formfield_for_foreignkey(field, request).queryset) - self.assertEqual( - desired_order, - current_sort_order_coerced_type, - "{} is not ordered alphabetically".format(field.name), - ) + # Conforms to the same object structure as desired_order + current_sort_order_coerced_type = [] + + # This is necessary as .queryset and get_queryset + # return lists of different types/structures. + # We need to parse this data and coerce them into the same type. + for contact in current_sort_order: + if not isNamefield: + first = contact.first_name + last = contact.last_name + else: + first = contact.name + last = None + + name_tuple = self.coerced_fk_field_helper(first, last, field.name, ":") + if name_tuple is not None: + current_sort_order_coerced_type.append(name_tuple) + + self.assertEqual( + desired_order, + current_sort_order_coerced_type, + "{} is not ordered alphabetically".format(field.name), + ) def test_alphabetically_sorted_fk_fields_domain_information(self): tested_fields = [