diff --git a/src/registrar/admin.py b/src/registrar/admin.py index df39a36cc..aaa3b1f03 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -19,17 +19,94 @@ from registrar.utility import csv_export from registrar.utility.errors import ApplicationStatusError, FSMErrorCodes from registrar.views.utility.mixins import OrderableFieldsMixin from django.contrib.admin.views.main import ORDER_VAR +from registrar.widgets import NoAutocompleteFilteredSelectMultiple from . import models from auditlog.models import LogEntry # type: ignore from auditlog.admin import LogEntryAdmin # type: ignore from django_fsm import TransitionNotAllowed # type: ignore from django.utils.safestring import mark_safe from django.utils.html import escape +from django.contrib.auth.forms import UserChangeForm, UsernameField + from django.utils.translation import gettext_lazy as _ logger = logging.getLogger(__name__) +class MyUserAdminForm(UserChangeForm): + """This form utilizes the custom widget for its class's ManyToMany UIs. + + It inherits from UserChangeForm which has special handling for the password and username fields.""" + + class Meta: + model = models.User + fields = "__all__" + field_classes = {"username": UsernameField} + widgets = { + "groups": NoAutocompleteFilteredSelectMultiple("groups", False), + "user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False), + } + + +class DomainInformationAdminForm(forms.ModelForm): + """This form utilizes the custom widget for its class's ManyToMany UIs.""" + + class Meta: + model = models.DomainInformation + fields = "__all__" + widgets = { + "other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False), + } + + +class DomainInformationInlineForm(forms.ModelForm): + """This form utilizes the custom widget for its class's ManyToMany UIs.""" + + class Meta: + model = models.DomainInformation + fields = "__all__" + widgets = { + "other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False), + } + + +class DomainApplicationAdminForm(forms.ModelForm): + """Custom form to limit transitions to available transitions. + This form utilizes the custom widget for its class's ManyToMany UIs.""" + + class Meta: + model = models.DomainApplication + fields = "__all__" + widgets = { + "current_websites": NoAutocompleteFilteredSelectMultiple("current_websites", False), + "alternative_domains": NoAutocompleteFilteredSelectMultiple("alternative_domains", False), + "other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + application = kwargs.get("instance") + if application and application.pk: + current_state = application.status + + # first option in status transitions is current state + available_transitions = [(current_state, application.get_status_display())] + + transitions = get_available_FIELD_transitions( + application, models.DomainApplication._meta.get_field("status") + ) + + for transition in transitions: + available_transitions.append((transition.target, transition.target.label)) + + # only set the available transitions if the user is not restricted + # from editing the domain application; otherwise, the form will be + # readonly and the status field will not have a widget + if not application.creator.is_restricted(): + self.fields["status"].widget.choices = available_transitions + + # Based off of this excellent example: https://djangosnippets.org/snippets/10471/ class MultiFieldSortableChangeList(admin.views.main.ChangeList): """ @@ -289,6 +366,8 @@ class UserContactInline(admin.StackedInline): class MyUserAdmin(BaseUserAdmin): """Custom user admin class to use our inlines.""" + form = MyUserAdminForm + class Meta: """Contains meta information about this class""" @@ -674,6 +753,8 @@ class DomainInvitationAdmin(ListHeaderAdmin): class DomainInformationAdmin(ListHeaderAdmin): """Customize domain information admin class.""" + form = DomainInformationAdminForm + # Columns list_display = [ "domain", @@ -891,6 +972,8 @@ class DomainApplicationAdminForm(forms.ModelForm): class DomainApplicationAdmin(ListHeaderAdmin): """Custom domain applications admin class.""" + form = DomainApplicationAdminForm + class InvestigatorFilter(admin.SimpleListFilter): """Custom investigator filter that only displays users with the manager role""" @@ -910,13 +993,19 @@ class DomainApplicationAdmin(ListHeaderAdmin): ) # 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(), + 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") + .values_list("investigator__id", "full_name") + .distinct() + ) return privileged_users_annotated @@ -992,8 +1081,6 @@ class DomainApplicationAdmin(ListHeaderAdmin): ] search_help_text = "Search by domain or submitter." - # Detail view - form = DomainApplicationAdminForm fieldsets = [ (None, {"fields": ["status", "rejection_reason", "investigator", "creator", "approved_domain", "notes"]}), ( @@ -1270,6 +1357,8 @@ class DomainInformationInline(admin.StackedInline): classes conflict, so we'll just pull what we need from DomainInformationAdmin""" + form = DomainInformationInlineForm + model = models.DomainInformation fieldsets = DomainInformationAdmin.fieldsets diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index fc6af408a..ff73acb65 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -162,7 +162,11 @@ function initializeWidgetOnToList(toList, toListId) { 'websites': '/admin/registrar/website/__fk__/change/?_to_field=id', 'alternative_domains': '/admin/registrar/website/__fk__/change/?_to_field=id', }, - false, + // NOTE: If we open view in the same window then use the back button + // to go back, the 'chosen' list will fail to initialize correctly in + // sandbozes (but will work fine on local). This is related to how the + // Django JS runs (SelectBox.js) and is probably due to a race condition. + true, false ); diff --git a/src/registrar/widgets.py b/src/registrar/widgets.py new file mode 100644 index 000000000..dc21477e3 --- /dev/null +++ b/src/registrar/widgets.py @@ -0,0 +1,16 @@ +# widgets.py + +from django.contrib.admin.widgets import FilteredSelectMultiple +from django.utils.safestring import mark_safe + + +class NoAutocompleteFilteredSelectMultiple(FilteredSelectMultiple): + """Firefox and Edge are unable to correctly initialize the source select in filter_horizontal + widgets. We add the attribute autocomplete=off to fix that.""" + + def render(self, name, value, attrs=None, renderer=None): + if attrs is None: + attrs = {} + attrs["autocomplete"] = "off" + output = super().render(name, value, attrs=attrs, renderer=renderer) + return mark_safe(output) # nosec