mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-05-15 09:07:02 +02:00
Merge pull request #1520 from cisagov/za/1456-sorting-not-working-correctly
(On getgov-za) Ticket #1456: Table sorting not working correctly
This commit is contained in:
commit
2d89bec20c
5 changed files with 508 additions and 52 deletions
|
@ -14,6 +14,8 @@ from epplibwrapper.errors import ErrorCode, RegistryError
|
||||||
from registrar.models.domain import Domain
|
from registrar.models.domain import Domain
|
||||||
from registrar.models.user import User
|
from registrar.models.user import User
|
||||||
from registrar.utility import csv_export
|
from registrar.utility import csv_export
|
||||||
|
from registrar.views.utility.mixins import OrderableFieldsMixin
|
||||||
|
from django.contrib.admin.views.main import ORDER_VAR
|
||||||
from . import models
|
from . import models
|
||||||
from auditlog.models import LogEntry # type: ignore
|
from auditlog.models import LogEntry # type: ignore
|
||||||
from auditlog.admin import LogEntryAdmin # type: ignore
|
from auditlog.admin import LogEntryAdmin # type: ignore
|
||||||
|
@ -22,6 +24,73 @@ from django_fsm import TransitionNotAllowed # type: ignore
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Based off of this excellent example: https://djangosnippets.org/snippets/10471/
|
||||||
|
class MultiFieldSortableChangeList(admin.views.main.ChangeList):
|
||||||
|
"""
|
||||||
|
This class overrides the behavior of column sorting in django admin tables in order
|
||||||
|
to allow for multi field sorting on admin_order_field
|
||||||
|
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
class MyCustomAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_changelist(self, request, **kwargs):
|
||||||
|
return MultiFieldSortableChangeList
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_ordering(self, request, queryset):
|
||||||
|
"""
|
||||||
|
Returns the list of ordering fields for the change list.
|
||||||
|
|
||||||
|
Mostly identical to the base implementation, except that now it can return
|
||||||
|
a list of order_field objects rather than just one.
|
||||||
|
"""
|
||||||
|
params = self.params
|
||||||
|
ordering = list(self.model_admin.get_ordering(request) or self._get_default_ordering())
|
||||||
|
|
||||||
|
if ORDER_VAR in params:
|
||||||
|
# Clear ordering and used params
|
||||||
|
ordering = []
|
||||||
|
|
||||||
|
order_params = params[ORDER_VAR].split(".")
|
||||||
|
for p in order_params:
|
||||||
|
try:
|
||||||
|
none, pfx, idx = p.rpartition("-")
|
||||||
|
field_name = self.list_display[int(idx)]
|
||||||
|
|
||||||
|
order_fields = self.get_ordering_field(field_name)
|
||||||
|
|
||||||
|
if isinstance(order_fields, list):
|
||||||
|
for order_field in order_fields:
|
||||||
|
if order_field:
|
||||||
|
ordering.append(pfx + order_field)
|
||||||
|
else:
|
||||||
|
ordering.append(pfx + order_fields)
|
||||||
|
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
continue # Invalid ordering specified, skip it.
|
||||||
|
|
||||||
|
# Add the given query's ordering fields, if any.
|
||||||
|
ordering.extend(queryset.query.order_by)
|
||||||
|
|
||||||
|
# Ensure that the primary key is systematically present in the list of
|
||||||
|
# ordering fields so we can guarantee a deterministic order across all
|
||||||
|
# database backends.
|
||||||
|
pk_name = self.lookup_opts.pk.name
|
||||||
|
if not (set(ordering) & set(["pk", "-pk", pk_name, "-" + pk_name])):
|
||||||
|
# The two sets do not intersect, meaning the pk isn't present. So
|
||||||
|
# we add it.
|
||||||
|
ordering.append("-pk")
|
||||||
|
|
||||||
|
return ordering
|
||||||
|
|
||||||
|
|
||||||
class CustomLogEntryAdmin(LogEntryAdmin):
|
class CustomLogEntryAdmin(LogEntryAdmin):
|
||||||
"""Overwrite the generated LogEntry admin class"""
|
"""Overwrite the generated LogEntry admin class"""
|
||||||
|
|
||||||
|
@ -119,8 +188,19 @@ class AuditedAdmin(admin.ModelAdmin):
|
||||||
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class ListHeaderAdmin(AuditedAdmin):
|
class ListHeaderAdmin(AuditedAdmin, OrderableFieldsMixin):
|
||||||
"""Custom admin to add a descriptive subheader to list views."""
|
"""Custom admin to add a descriptive subheader to list views
|
||||||
|
and custom table sort behaviour"""
|
||||||
|
|
||||||
|
def get_changelist(self, request, **kwargs):
|
||||||
|
"""Returns a custom ChangeList class, as opposed to the default.
|
||||||
|
This is so we can override the behaviour of the `admin_order_field` field.
|
||||||
|
By default, django does not support ordering by multiple fields for this
|
||||||
|
particular field (i.e. self.admin_order_field=["first_name", "last_name"] is invalid).
|
||||||
|
|
||||||
|
Reference: https://code.djangoproject.com/ticket/31975
|
||||||
|
"""
|
||||||
|
return MultiFieldSortableChangeList
|
||||||
|
|
||||||
def changelist_view(self, request, extra_context=None):
|
def changelist_view(self, request, extra_context=None):
|
||||||
if extra_context is None:
|
if extra_context is None:
|
||||||
|
@ -399,6 +479,11 @@ class UserDomainRoleAdmin(ListHeaderAdmin):
|
||||||
"role",
|
"role",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
orderable_fk_fields = [
|
||||||
|
("domain", "name"),
|
||||||
|
("user", ["first_name", "last_name", "email"]),
|
||||||
|
]
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
search_fields = [
|
search_fields = [
|
||||||
"user__first_name",
|
"user__first_name",
|
||||||
|
@ -468,6 +553,11 @@ class DomainInformationAdmin(ListHeaderAdmin):
|
||||||
"submitter",
|
"submitter",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
orderable_fk_fields = [
|
||||||
|
("domain", "name"),
|
||||||
|
("submitter", ["first_name", "last_name"]),
|
||||||
|
]
|
||||||
|
|
||||||
# Filters
|
# Filters
|
||||||
list_filter = ["organization_type"]
|
list_filter = ["organization_type"]
|
||||||
|
|
||||||
|
@ -624,6 +714,12 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
||||||
"investigator",
|
"investigator",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
orderable_fk_fields = [
|
||||||
|
("requested_domain", "name"),
|
||||||
|
("submitter", ["first_name", "last_name"]),
|
||||||
|
("investigator", ["first_name", "last_name"]),
|
||||||
|
]
|
||||||
|
|
||||||
# Filters
|
# Filters
|
||||||
list_filter = ("status", "organization_type", InvestigatorFilter)
|
list_filter = ("status", "organization_type", InvestigatorFilter)
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ import uuid
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from unittest.mock import MagicMock, Mock, patch
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
from typing import List, Dict
|
from typing import List, Dict
|
||||||
|
from django.contrib.sessions.middleware import SessionMiddleware
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model, login
|
from django.contrib.auth import get_user_model, login
|
||||||
|
|
||||||
|
@ -93,6 +93,73 @@ def less_console_noise(output_stream=None):
|
||||||
output_stream.close()
|
output_stream.close()
|
||||||
|
|
||||||
|
|
||||||
|
class GenericTestHelper(TestCase):
|
||||||
|
"""A helper class that contains various helper functions for TestCases"""
|
||||||
|
|
||||||
|
def __init__(self, admin, model=None, url=None, user=None, factory=None, **kwargs):
|
||||||
|
"""
|
||||||
|
Parameters:
|
||||||
|
admin (ModelAdmin): The Django ModelAdmin instance associated with the model.
|
||||||
|
model (django.db.models.Model, optional): The Django model associated with the admin page.
|
||||||
|
url (str, optional): The URL of the Django Admin page to test.
|
||||||
|
user (User, optional): The Django User who is making the request.
|
||||||
|
factory (RequestFactory, optional): An instance of Django's RequestFactory.
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
|
self.factory = factory
|
||||||
|
self.user = user
|
||||||
|
self.admin = admin
|
||||||
|
self.model = model
|
||||||
|
self.url = url
|
||||||
|
|
||||||
|
def assert_table_sorted(self, o_index, sort_fields):
|
||||||
|
"""
|
||||||
|
This helper function validates the sorting functionality of a Django Admin table view.
|
||||||
|
|
||||||
|
It creates a mock HTTP GET request to the provided URL with a specific ordering parameter,
|
||||||
|
and compares the returned sorted queryset with the expected sorted queryset.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
o_index (str): The index of the field in the table to sort by. This is passed as a string
|
||||||
|
to the 'o' parameter in the GET request.
|
||||||
|
sort_fields (tuple): The fields of the model to sort by. These fields are used to generate
|
||||||
|
the expected sorted queryset.
|
||||||
|
|
||||||
|
|
||||||
|
Example Usage:
|
||||||
|
```
|
||||||
|
self.assert_sort_helper(
|
||||||
|
self.factory, self.superuser, self.admin, self.url, DomainInformation, "1", ("domain__name",)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The function asserts that the returned sorted queryset from the admin page matches the
|
||||||
|
expected sorted queryset. If the assertion fails, it means the sorting functionality
|
||||||
|
on the admin page is not working as expected.
|
||||||
|
"""
|
||||||
|
# 'o' is a search param defined by the current index position in the
|
||||||
|
# table, plus one.
|
||||||
|
dummy_request = self.factory.get(
|
||||||
|
self.url,
|
||||||
|
{"o": o_index},
|
||||||
|
)
|
||||||
|
dummy_request.user = self.user
|
||||||
|
|
||||||
|
# Mock a user request
|
||||||
|
middleware = SessionMiddleware(lambda req: req)
|
||||||
|
middleware.process_request(dummy_request)
|
||||||
|
dummy_request.session.save()
|
||||||
|
|
||||||
|
expected_sort_order = list(self.model.objects.order_by(*sort_fields))
|
||||||
|
|
||||||
|
# Use changelist_view to get the sorted queryset
|
||||||
|
response = self.admin.changelist_view(dummy_request)
|
||||||
|
response.render() # Render the response before accessing its content
|
||||||
|
returned_sort_order = list(response.context_data["cl"].result_list)
|
||||||
|
|
||||||
|
self.assertEqual(expected_sort_order, returned_sort_order)
|
||||||
|
|
||||||
|
|
||||||
class MockUserLogin:
|
class MockUserLogin:
|
||||||
def __init__(self, get_response):
|
def __init__(self, get_response):
|
||||||
self.get_response = get_response
|
self.get_response = get_response
|
||||||
|
@ -273,6 +340,7 @@ class AuditedAdminMockData:
|
||||||
creator: User = self.dummy_user(item_name, "creator"),
|
creator: User = self.dummy_user(item_name, "creator"),
|
||||||
}
|
}
|
||||||
""" # noqa
|
""" # noqa
|
||||||
|
creator = self.dummy_user(item_name, "creator")
|
||||||
common_args = dict(
|
common_args = dict(
|
||||||
organization_type=org_type,
|
organization_type=org_type,
|
||||||
federal_type=federal_type,
|
federal_type=federal_type,
|
||||||
|
@ -287,7 +355,7 @@ class AuditedAdminMockData:
|
||||||
anything_else="There is more",
|
anything_else="There is more",
|
||||||
authorizing_official=self.dummy_contact(item_name, "authorizing_official"),
|
authorizing_official=self.dummy_contact(item_name, "authorizing_official"),
|
||||||
submitter=self.dummy_contact(item_name, "submitter"),
|
submitter=self.dummy_contact(item_name, "submitter"),
|
||||||
creator=self.dummy_user(item_name, "creator"),
|
creator=creator,
|
||||||
)
|
)
|
||||||
return common_args
|
return common_args
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,6 @@ from django.contrib.admin.sites import AdminSite
|
||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from registrar.admin import (
|
from registrar.admin import (
|
||||||
DomainAdmin,
|
DomainAdmin,
|
||||||
DomainApplicationAdmin,
|
DomainApplicationAdmin,
|
||||||
|
@ -13,11 +12,13 @@ from registrar.admin import (
|
||||||
MyUserAdmin,
|
MyUserAdmin,
|
||||||
AuditedAdmin,
|
AuditedAdmin,
|
||||||
ContactAdmin,
|
ContactAdmin,
|
||||||
|
DomainInformationAdmin,
|
||||||
UserDomainRoleAdmin,
|
UserDomainRoleAdmin,
|
||||||
)
|
)
|
||||||
from registrar.models import Domain, DomainApplication, DomainInformation, User, DomainInvitation, Contact, Website
|
from registrar.models import Domain, DomainApplication, DomainInformation, User, DomainInvitation, Contact, Website
|
||||||
from registrar.models.user_domain_role import UserDomainRole
|
from registrar.models.user_domain_role import UserDomainRole
|
||||||
from .common import (
|
from .common import (
|
||||||
|
AuditedAdminMockData,
|
||||||
completed_application,
|
completed_application,
|
||||||
generic_domain_object,
|
generic_domain_object,
|
||||||
mock_user,
|
mock_user,
|
||||||
|
@ -26,6 +27,7 @@ from .common import (
|
||||||
create_ready_domain,
|
create_ready_domain,
|
||||||
multiple_unalphabetical_domain_objects,
|
multiple_unalphabetical_domain_objects,
|
||||||
MockEppLib,
|
MockEppLib,
|
||||||
|
GenericTestHelper,
|
||||||
)
|
)
|
||||||
from django.contrib.sessions.backends.db import SessionStore
|
from django.contrib.sessions.backends.db import SessionStore
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
@ -317,6 +319,85 @@ class TestDomainApplicationAdmin(MockEppLib):
|
||||||
self.superuser = create_superuser()
|
self.superuser = create_superuser()
|
||||||
self.staffuser = create_user()
|
self.staffuser = create_user()
|
||||||
self.client = Client(HTTP_HOST="localhost:8080")
|
self.client = Client(HTTP_HOST="localhost:8080")
|
||||||
|
self.test_helper = GenericTestHelper(
|
||||||
|
factory=self.factory,
|
||||||
|
user=self.superuser,
|
||||||
|
admin=self.admin,
|
||||||
|
url="/admin/registrar/DomainApplication/",
|
||||||
|
model=DomainApplication,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_domain_sortable(self):
|
||||||
|
"""Tests if the DomainApplication sorts by domain correctly"""
|
||||||
|
p = "adminpass"
|
||||||
|
self.client.login(username="superuser", password=p)
|
||||||
|
|
||||||
|
multiple_unalphabetical_domain_objects("application")
|
||||||
|
|
||||||
|
# Assert that our sort works correctly
|
||||||
|
self.test_helper.assert_table_sorted("1", ("requested_domain__name",))
|
||||||
|
|
||||||
|
# Assert that sorting in reverse works correctly
|
||||||
|
self.test_helper.assert_table_sorted("-1", ("-requested_domain__name",))
|
||||||
|
|
||||||
|
def test_submitter_sortable(self):
|
||||||
|
"""Tests if the DomainApplication sorts by domain correctly"""
|
||||||
|
p = "adminpass"
|
||||||
|
self.client.login(username="superuser", password=p)
|
||||||
|
|
||||||
|
multiple_unalphabetical_domain_objects("application")
|
||||||
|
|
||||||
|
additional_application = generic_domain_object("application", "Xylophone")
|
||||||
|
new_user = User.objects.filter(username=additional_application.investigator.username).get()
|
||||||
|
new_user.first_name = "Xylophonic"
|
||||||
|
new_user.save()
|
||||||
|
|
||||||
|
# Assert that our sort works correctly
|
||||||
|
self.test_helper.assert_table_sorted(
|
||||||
|
"5",
|
||||||
|
(
|
||||||
|
"submitter__first_name",
|
||||||
|
"submitter__last_name",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert that sorting in reverse works correctly
|
||||||
|
self.test_helper.assert_table_sorted(
|
||||||
|
"-5",
|
||||||
|
(
|
||||||
|
"-submitter__first_name",
|
||||||
|
"-submitter__last_name",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_investigator_sortable(self):
|
||||||
|
"""Tests if the DomainApplication sorts by domain correctly"""
|
||||||
|
p = "adminpass"
|
||||||
|
self.client.login(username="superuser", password=p)
|
||||||
|
|
||||||
|
multiple_unalphabetical_domain_objects("application")
|
||||||
|
additional_application = generic_domain_object("application", "Xylophone")
|
||||||
|
new_user = User.objects.filter(username=additional_application.investigator.username).get()
|
||||||
|
new_user.first_name = "Xylophonic"
|
||||||
|
new_user.save()
|
||||||
|
|
||||||
|
# Assert that our sort works correctly
|
||||||
|
self.test_helper.assert_table_sorted(
|
||||||
|
"6",
|
||||||
|
(
|
||||||
|
"investigator__first_name",
|
||||||
|
"investigator__last_name",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert that sorting in reverse works correctly
|
||||||
|
self.test_helper.assert_table_sorted(
|
||||||
|
"-6",
|
||||||
|
(
|
||||||
|
"-investigator__first_name",
|
||||||
|
"-investigator__last_name",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def test_short_org_name_in_applications_list(self):
|
def test_short_org_name_in_applications_list(self):
|
||||||
"""
|
"""
|
||||||
|
@ -928,52 +1009,6 @@ class TestDomainApplicationAdmin(MockEppLib):
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
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):
|
def test_investigator_dropdown_displays_only_staff(self):
|
||||||
"""
|
"""
|
||||||
This test verifies that the dropdown for the 'investigator' field in the DomainApplicationAdmin
|
This test verifies that the dropdown for the 'investigator' field in the DomainApplicationAdmin
|
||||||
|
@ -1098,14 +1133,98 @@ class DomainInvitationAdminTest(TestCase):
|
||||||
self.assertContains(response, retrieved_html, count=1)
|
self.assertContains(response, retrieved_html, count=1)
|
||||||
|
|
||||||
|
|
||||||
|
class DomainInformationAdminTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
"""Setup environment for a mock admin user"""
|
||||||
|
self.site = AdminSite()
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.admin = DomainInformationAdmin(model=DomainInformation, admin_site=self.site)
|
||||||
|
self.client = Client(HTTP_HOST="localhost:8080")
|
||||||
|
self.superuser = create_superuser()
|
||||||
|
self.mock_data_generator = AuditedAdminMockData()
|
||||||
|
|
||||||
|
self.test_helper = GenericTestHelper(
|
||||||
|
factory=self.factory,
|
||||||
|
user=self.superuser,
|
||||||
|
admin=self.admin,
|
||||||
|
url="/admin/registrar/DomainInformation/",
|
||||||
|
model=DomainInformation,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create fake DomainInformation objects
|
||||||
|
DomainInformation.objects.create(
|
||||||
|
creator=self.mock_data_generator.dummy_user("fake", "creator"),
|
||||||
|
domain=self.mock_data_generator.dummy_domain("Apple"),
|
||||||
|
submitter=self.mock_data_generator.dummy_contact("Zebra", "submitter"),
|
||||||
|
)
|
||||||
|
|
||||||
|
DomainInformation.objects.create(
|
||||||
|
creator=self.mock_data_generator.dummy_user("fake", "creator"),
|
||||||
|
domain=self.mock_data_generator.dummy_domain("Zebra"),
|
||||||
|
submitter=self.mock_data_generator.dummy_contact("Apple", "submitter"),
|
||||||
|
)
|
||||||
|
|
||||||
|
DomainInformation.objects.create(
|
||||||
|
creator=self.mock_data_generator.dummy_user("fake", "creator"),
|
||||||
|
domain=self.mock_data_generator.dummy_domain("Circus"),
|
||||||
|
submitter=self.mock_data_generator.dummy_contact("Xylophone", "submitter"),
|
||||||
|
)
|
||||||
|
|
||||||
|
DomainInformation.objects.create(
|
||||||
|
creator=self.mock_data_generator.dummy_user("fake", "creator"),
|
||||||
|
domain=self.mock_data_generator.dummy_domain("Xylophone"),
|
||||||
|
submitter=self.mock_data_generator.dummy_contact("Circus", "submitter"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""Delete all Users, Domains, and UserDomainRoles"""
|
||||||
|
DomainInformation.objects.all().delete()
|
||||||
|
DomainApplication.objects.all().delete()
|
||||||
|
Domain.objects.all().delete()
|
||||||
|
Contact.objects.all().delete()
|
||||||
|
User.objects.all().delete()
|
||||||
|
|
||||||
|
def test_domain_sortable(self):
|
||||||
|
"""Tests if DomainInformation sorts by domain correctly"""
|
||||||
|
p = "adminpass"
|
||||||
|
self.client.login(username="superuser", password=p)
|
||||||
|
|
||||||
|
# Assert that our sort works correctly
|
||||||
|
self.test_helper.assert_table_sorted("1", ("domain__name",))
|
||||||
|
|
||||||
|
# Assert that sorting in reverse works correctly
|
||||||
|
self.test_helper.assert_table_sorted("-1", ("-domain__name",))
|
||||||
|
|
||||||
|
def test_submitter_sortable(self):
|
||||||
|
"""Tests if DomainInformation sorts by submitter correctly"""
|
||||||
|
p = "adminpass"
|
||||||
|
self.client.login(username="superuser", password=p)
|
||||||
|
|
||||||
|
# Assert that our sort works correctly
|
||||||
|
self.test_helper.assert_table_sorted(
|
||||||
|
"4",
|
||||||
|
("submitter__first_name", "submitter__last_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert that sorting in reverse works correctly
|
||||||
|
self.test_helper.assert_table_sorted("-4", ("-submitter__first_name", "-submitter__last_name"))
|
||||||
|
|
||||||
|
|
||||||
class UserDomainRoleAdminTest(TestCase):
|
class UserDomainRoleAdminTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""Setup environment for a mock admin user"""
|
"""Setup environment for a mock admin user"""
|
||||||
self.site = AdminSite()
|
self.site = AdminSite()
|
||||||
self.factory = RequestFactory()
|
self.factory = RequestFactory()
|
||||||
self.admin = ListHeaderAdmin(model=UserDomainRoleAdmin, admin_site=None)
|
self.admin = UserDomainRoleAdmin(model=UserDomainRole, admin_site=self.site)
|
||||||
self.client = Client(HTTP_HOST="localhost:8080")
|
self.client = Client(HTTP_HOST="localhost:8080")
|
||||||
self.superuser = create_superuser()
|
self.superuser = create_superuser()
|
||||||
|
self.test_helper = GenericTestHelper(
|
||||||
|
factory=self.factory,
|
||||||
|
user=self.superuser,
|
||||||
|
admin=self.admin,
|
||||||
|
url="/admin/registrar/UserDomainRole/",
|
||||||
|
model=UserDomainRole,
|
||||||
|
)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
"""Delete all Users, Domains, and UserDomainRoles"""
|
"""Delete all Users, Domains, and UserDomainRoles"""
|
||||||
|
@ -1113,6 +1232,48 @@ class UserDomainRoleAdminTest(TestCase):
|
||||||
Domain.objects.all().delete()
|
Domain.objects.all().delete()
|
||||||
UserDomainRole.objects.all().delete()
|
UserDomainRole.objects.all().delete()
|
||||||
|
|
||||||
|
def test_domain_sortable(self):
|
||||||
|
"""Tests if the UserDomainrole sorts by domain correctly"""
|
||||||
|
p = "adminpass"
|
||||||
|
self.client.login(username="superuser", password=p)
|
||||||
|
|
||||||
|
fake_user = User.objects.create(
|
||||||
|
username="dummyuser", first_name="Stewart", last_name="Jones", email="AntarcticPolarBears@example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a list of UserDomainRoles that are in random order
|
||||||
|
mocks_to_create = ["jkl.gov", "ghi.gov", "abc.gov", "def.gov"]
|
||||||
|
for name in mocks_to_create:
|
||||||
|
fake_domain = Domain.objects.create(name=name)
|
||||||
|
UserDomainRole.objects.create(user=fake_user, domain=fake_domain, role="manager")
|
||||||
|
|
||||||
|
# Assert that our sort works correctly
|
||||||
|
self.test_helper.assert_table_sorted("2", ("domain__name",))
|
||||||
|
|
||||||
|
# Assert that sorting in reverse works correctly
|
||||||
|
self.test_helper.assert_table_sorted("-2", ("-domain__name",))
|
||||||
|
|
||||||
|
def test_user_sortable(self):
|
||||||
|
"""Tests if the UserDomainrole sorts by user correctly"""
|
||||||
|
p = "adminpass"
|
||||||
|
self.client.login(username="superuser", password=p)
|
||||||
|
|
||||||
|
mock_data_generator = AuditedAdminMockData()
|
||||||
|
|
||||||
|
fake_domain = Domain.objects.create(name="igorville.gov")
|
||||||
|
# Create a list of UserDomainRoles that are in random order
|
||||||
|
mocks_to_create = ["jkl", "ghi", "abc", "def"]
|
||||||
|
for name in mocks_to_create:
|
||||||
|
# Creates a fake "User" object
|
||||||
|
fake_user = mock_data_generator.dummy_user(name, "user")
|
||||||
|
UserDomainRole.objects.create(user=fake_user, domain=fake_domain, role="manager")
|
||||||
|
|
||||||
|
# Assert that our sort works correctly
|
||||||
|
self.test_helper.assert_table_sorted("1", ("user__first_name", "user__last_name"))
|
||||||
|
|
||||||
|
# Assert that sorting in reverse works correctly
|
||||||
|
self.test_helper.assert_table_sorted("-1", ("-user__first_name", "-user__last_name"))
|
||||||
|
|
||||||
def test_email_not_in_search(self):
|
def test_email_not_in_search(self):
|
||||||
"""Tests the search bar in Django Admin for UserDomainRoleAdmin.
|
"""Tests the search bar in Django Admin for UserDomainRoleAdmin.
|
||||||
Should return no results for an invalid email."""
|
Should return no results for an invalid email."""
|
||||||
|
|
|
@ -22,6 +22,7 @@ from django_fsm import TransitionNotAllowed
|
||||||
boto3_mocking.clients.register_handler("sesv2", MockSESClient)
|
boto3_mocking.clients.register_handler("sesv2", MockSESClient)
|
||||||
|
|
||||||
|
|
||||||
|
# Test comment for push -- will remove
|
||||||
# The DomainApplication submit method has a side effect of sending an email
|
# The DomainApplication submit method has a side effect of sending an email
|
||||||
# with AWS SES, so mock that out in all of these test cases
|
# with AWS SES, so mock that out in all of these test cases
|
||||||
@boto3_mocking.patching
|
@boto3_mocking.patching
|
||||||
|
|
|
@ -15,6 +15,136 @@ import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderableFieldsMixin:
|
||||||
|
"""
|
||||||
|
Mixin to add multi-field ordering capabilities to a Django ModelAdmin on admin_order_field.
|
||||||
|
"""
|
||||||
|
|
||||||
|
custom_sort_name_prefix = "get_sortable_"
|
||||||
|
orderable_fk_fields = [] # type: ignore
|
||||||
|
|
||||||
|
def __new__(cls, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
This magic method is called when a new instance of the class (or subclass) is created.
|
||||||
|
It dynamically adds a new method to the class for each field in `orderable_fk_fields`.
|
||||||
|
Then, it will update the `list_display` attribute such that it uses these generated methods.
|
||||||
|
"""
|
||||||
|
new_class = super().__new__(cls)
|
||||||
|
|
||||||
|
# If the class doesn't define anything for orderable_fk_fields, then we should
|
||||||
|
# just skip this additional logic
|
||||||
|
if not hasattr(cls, "orderable_fk_fields") or len(cls.orderable_fk_fields) == 0:
|
||||||
|
return new_class
|
||||||
|
|
||||||
|
# Check if the list_display attribute exists, and if it does, create a local copy of that list.
|
||||||
|
list_display_exists = hasattr(cls, "list_display") and isinstance(cls.list_display, list)
|
||||||
|
new_list_display = cls.list_display.copy() if list_display_exists else []
|
||||||
|
|
||||||
|
for field, sort_field in cls.orderable_fk_fields:
|
||||||
|
updated_name = cls.custom_sort_name_prefix + field
|
||||||
|
|
||||||
|
# For each item in orderable_fk_fields, create a function and associate it with admin_order_field.
|
||||||
|
setattr(new_class, updated_name, cls._create_orderable_field_method(field, sort_field))
|
||||||
|
|
||||||
|
# Update the list_display variable to use our newly created functions
|
||||||
|
if list_display_exists and field in cls.list_display:
|
||||||
|
index = new_list_display.index(field)
|
||||||
|
new_list_display[index] = updated_name
|
||||||
|
elif list_display_exists:
|
||||||
|
new_list_display.append(updated_name)
|
||||||
|
|
||||||
|
# Replace the old list with the updated one
|
||||||
|
if list_display_exists:
|
||||||
|
cls.list_display = new_list_display
|
||||||
|
|
||||||
|
return new_class
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _create_orderable_field_method(cls, field, sort_field):
|
||||||
|
"""
|
||||||
|
This class method is a factory for creating dynamic methods that will be attached
|
||||||
|
to the ModelAdmin subclass.
|
||||||
|
It is used to customize how fk fields are ordered.
|
||||||
|
|
||||||
|
In essence, this function will more or less generate code that looks like this,
|
||||||
|
for a given tuple defined in orderable_fk_fields:
|
||||||
|
|
||||||
|
```
|
||||||
|
def get_sortable_requested_domain(self, obj):
|
||||||
|
return obj.requested_domain
|
||||||
|
# Allows column order sorting
|
||||||
|
get_sortable_requested_domain.admin_order_field = "requested_domain__name"
|
||||||
|
# Sets column's header name
|
||||||
|
get_sortable_requested_domain.short_description = "requested domain"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or for fields with multiple order_fields:
|
||||||
|
|
||||||
|
```
|
||||||
|
def get_sortable_submitter(self, obj):
|
||||||
|
return obj.submitter
|
||||||
|
# Allows column order sorting
|
||||||
|
get_sortable_submitter.admin_order_field = ["submitter__first_name", "submitter__last_name"]
|
||||||
|
# Sets column's header
|
||||||
|
get_sortable_submitter.short_description = "submitter"
|
||||||
|
```
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
cls: The class that this method is being called on. In the context of this mixin,
|
||||||
|
it would be the ModelAdmin subclass.
|
||||||
|
field: A string representing the name of the attribute that
|
||||||
|
the dynamic method will fetch from the model instance.
|
||||||
|
sort_field: A string or list of strings representing the
|
||||||
|
field(s) to sort by (ex: "name" or "creator")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
method: The dynamically created method.
|
||||||
|
|
||||||
|
The dynamically created method has the following attributes:
|
||||||
|
__name__: A string representing the name of the method. This is set to "get_{field}".
|
||||||
|
admin_order_field: A string or list of strings representing the field(s) that
|
||||||
|
Django should sort by when the column is clicked in the admin interface.
|
||||||
|
short_description: A string used as the column header in the admin interface.
|
||||||
|
Will replace underscores with spaces.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def method(obj):
|
||||||
|
"""
|
||||||
|
Template method for patterning.
|
||||||
|
|
||||||
|
Returns (example):
|
||||||
|
```
|
||||||
|
def get_submitter(self, obj):
|
||||||
|
return obj.submitter
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
attr = getattr(obj, field)
|
||||||
|
return attr
|
||||||
|
|
||||||
|
# Set the function name. For instance, if the field is "domain",
|
||||||
|
# then this will generate a function called "get_sort_domain".
|
||||||
|
# This is done rather than just setting the name to the attribute to avoid
|
||||||
|
# naming conflicts.
|
||||||
|
method.__name__ = cls.custom_sort_name_prefix + field
|
||||||
|
|
||||||
|
# Check if a list is passed in, or just a string.
|
||||||
|
if isinstance(sort_field, list):
|
||||||
|
sort_list = []
|
||||||
|
for sort_field_item in sort_field:
|
||||||
|
order_field_string = f"{field}__{sort_field_item}"
|
||||||
|
sort_list.append(order_field_string)
|
||||||
|
# If its a list, return an array of fields to sort on.
|
||||||
|
# For instance, ["creator__first_name", "creator__last_name"]
|
||||||
|
method.admin_order_field = sort_list
|
||||||
|
else:
|
||||||
|
# If its not a list, just return a string
|
||||||
|
method.admin_order_field = f"{field}__{sort_field}"
|
||||||
|
|
||||||
|
# Infer the column name in a similar manner to how Django does
|
||||||
|
method.short_description = field.replace("_", " ")
|
||||||
|
return method
|
||||||
|
|
||||||
|
|
||||||
class PermissionsLoginMixin(PermissionRequiredMixin):
|
class PermissionsLoginMixin(PermissionRequiredMixin):
|
||||||
|
|
||||||
"""Mixin that redirects to login page if not logged in, otherwise 403."""
|
"""Mixin that redirects to login page if not logged in, otherwise 403."""
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue