1
0
mirror of https://github.com/django/django.git synced 2025-03-31 11:37:06 +00:00

Fixed #12090 -- Show admin actions on the edit pages too

This commit is contained in:
Marcelo Galigniana 2022-08-29 23:19:37 -03:00
parent 082fe2b5a8
commit caae4f34f6
7 changed files with 290 additions and 90 deletions

View File

@ -251,7 +251,7 @@ class BaseModelAdmin(metaclass=forms.MediaDefiningClass):
def get_field_queryset(self, db, db_field, request):
"""
If the ModelAdmin specifies ordering, the queryset should respect that
ordering. Otherwise don't specify the queryset, let the field decide
ordering. Otherwise don't specify the queryset, let the field decide
(return None in that case).
"""
try:
@ -1086,7 +1086,7 @@ class ModelAdmin(BaseModelAdmin):
def get_action_choices(self, request, default_choices=models.BLANK_CHOICE_DASH):
"""
Return a list of choices for use in a form object. Each choice is a
Return a list of choices for use in a form object. Each choice is a
tuple (name, description).
"""
choices = [] + default_choices
@ -1098,7 +1098,7 @@ class ModelAdmin(BaseModelAdmin):
def get_action(self, action):
"""
Return a given action from a parameter, which can either be a callable,
or the name of a method on the ModelAdmin. Return is a tuple of
or the name of a method on the ModelAdmin. Return is a tuple of
(callable, name, description).
"""
# If the action is a callable, just use it.
@ -1606,11 +1606,13 @@ class ModelAdmin(BaseModelAdmin):
"""
return self._response_post_save(request, obj)
def response_action(self, request, queryset):
def response_action(self, request, queryset, change):
"""
Handle an admin action. This is called if a request is POSTed to the
changelist; it returns an HttpResponse if the action was handled, and
None otherwise.
changelist or changeform; it returns an HttpResponse if the action was
handled, and None otherwise.
`change` is True if it's called from change_view; False if comes from
changelist_view or add_view.
"""
# There can be multiple action forms on the page (at the top
@ -1641,25 +1643,26 @@ class ModelAdmin(BaseModelAdmin):
# If the form's valid we can handle the action.
if action_form.is_valid():
action = action_form.cleaned_data["action"]
select_across = action_form.cleaned_data["select_across"]
func = self.get_actions(request)[action][0]
if not change:
select_across = action_form.cleaned_data["select_across"]
# Get the list of selected PKs. If nothing's selected, we can't
# perform an action on it, so bail. Except we want to perform
# the action explicitly on all objects.
selected = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
if not selected and not select_across:
# Reminder that something needs to be selected or nothing will
# happen
msg = _(
"Items must be selected in order to perform "
"actions on them. No items have been changed."
)
self.message_user(request, msg, messages.WARNING)
return None
# Get the list of selected PKs. If nothing's selected, we can't
# perform an action on it, so bail. Except we want to perform
# the action explicitly on all objects.
selected = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
if not selected and not select_across:
# Reminder that something needs to be selected or nothing will happen
msg = _(
"Items must be selected in order to perform "
"actions on them. No items have been changed."
)
self.message_user(request, msg, messages.WARNING)
return None
if not select_across:
# Perform the action only on the selected objects
queryset = queryset.filter(pk__in=selected)
if not select_across:
# Perform the action only on the selected objects
queryset = queryset.filter(pk__in=selected)
response = func(self, request, queryset)
@ -1856,7 +1859,23 @@ class ModelAdmin(BaseModelAdmin):
ModelForm = self.get_form(
request, obj, change=not add, fields=flatten_fieldsets(fieldsets)
)
actions = self.get_actions(request)
if request.method == "POST":
if actions and request.POST.get("action", ""):
action_failed = False
response = self.response_action(request, obj, change=not add)
if response:
return response
else:
action_failed = True
if action_failed:
# Redirect back to the changelist page to avoid resubmitting the
# form if the user refreshes the browser or uses the "No, take
# me back" button on the action confirmation page.
return HttpResponseRedirect(request.get_full_path())
form = ModelForm(request.POST, request.FILES, instance=obj)
formsets, inline_instances = self._create_formsets(
request,
@ -1925,6 +1944,19 @@ class ModelAdmin(BaseModelAdmin):
title = _("Change %s")
else:
title = _("View %s")
# Build the action form and populate it with available actions.
if actions and not add:
action_form = self.action_form(auto_id=None)
action_choices = self.get_action_choices(request)
# Remove "delete" action; change view already has a button for
# that action.
action_choices.pop(1)
action_form.fields["action"].choices = action_choices
media += action_form.media
else:
action_form = None
context = {
**self.admin_site.each_context(request),
"title": title % self.opts.verbose_name,
@ -1935,6 +1967,7 @@ class ModelAdmin(BaseModelAdmin):
"is_popup": IS_POPUP_VAR in request.POST or IS_POPUP_VAR in request.GET,
"to_field": to_field,
"media": media,
"action_form": action_form,
"inline_admin_formsets": inline_formsets,
"errors": helpers.AdminErrorList(form, formsets),
"preserved_filters": self.get_preserved_filters(request),
@ -2033,7 +2066,7 @@ class ModelAdmin(BaseModelAdmin):
):
if selected:
response = self.response_action(
request, queryset=cl.get_queryset(request)
request, queryset=cl.get_queryset(request), change=False
)
if response:
return response
@ -2057,7 +2090,7 @@ class ModelAdmin(BaseModelAdmin):
):
if selected:
response = self.response_action(
request, queryset=cl.get_queryset(request)
request, queryset=cl.get_queryset(request), change=False
)
if response:
return response

View File

@ -1176,3 +1176,81 @@ a.deletelink:focus, a.deletelink:hover {
color: var(--body-fg);
background-color: var(--body-bg);
}
/* ACTIONS */
#changelist .actions,
#changeform .actions {
padding: 10px;
background: var(--body-bg);
border-top: none;
border-bottom: none;
line-height: 1.5rem;
color: var(--body-quiet-color);
width: 100%;
}
#changelist .actions span.all,
#changelist .actions span.action-counter,
#changelist .actions span.clear,
#changelist .actions span.question
#changeform .actions span.all,
#changeform .actions span.action-counter,
#changeform .actions span.clear,
#changeform .actions span.question {
font-size: 0.8125rem;
margin: 0 0.5em;
}
#changelist .actions:last-child,
#changeform .actions:last-child {
border-bottom: none;
}
#changelist .actions select,
#changeform .actions select {
vertical-align: top;
height: 1.5rem;
color: var(--body-fg);
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 0.875rem;
padding: 0 0 0 4px;
margin: 0;
margin-left: 10px;
}
#changelist .actions select:focus,
#changeform .actions select:focus {
border-color: var(--body-quiet-color);
}
#changelist .actions label,
#changeform .actions label {
display: inline-block;
vertical-align: middle;
font-size: 0.8125rem;
}
#changelist .actions .button,
#changeform .actions .button {
font-size: 0.8125rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--body-bg);
box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset;
cursor: pointer;
height: 1.5rem;
line-height: 1;
padding: 4px 8px;
margin: 0;
color: var(--body-fg);
}
#changelist .actions .button:focus,
#changelist .actions .button:hover,
#changeform .actions .button:focus,
#changeform .actions .button:hover {
border-color: var(--body-quiet-color);
}

View File

@ -279,65 +279,3 @@
background-color: SelectedItem;
}
}
#changelist .actions {
padding: 10px;
background: var(--body-bg);
border-top: none;
border-bottom: none;
line-height: 1.5rem;
color: var(--body-quiet-color);
width: 100%;
}
#changelist .actions span.all,
#changelist .actions span.action-counter,
#changelist .actions span.clear,
#changelist .actions span.question {
font-size: 0.8125rem;
margin: 0 0.5em;
}
#changelist .actions:last-child {
border-bottom: none;
}
#changelist .actions select {
vertical-align: top;
height: 1.5rem;
color: var(--body-fg);
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 0.875rem;
padding: 0 0 0 4px;
margin: 0;
margin-left: 10px;
}
#changelist .actions select:focus {
border-color: var(--body-quiet-color);
}
#changelist .actions label {
display: inline-block;
vertical-align: middle;
font-size: 0.8125rem;
}
#changelist .actions .button {
font-size: 0.8125rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--body-bg);
box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset;
cursor: pointer;
height: 1.5rem;
line-height: 1;
padding: 4px 8px;
margin: 0;
color: var(--body-fg);
}
#changelist .actions .button:focus, #changelist .actions .button:hover {
border-color: var(--body-quiet-color);
}

View File

@ -6,7 +6,13 @@
{{ media }}
{% endblock %}
{% block extrastyle %}{{ block.super }}<link rel="stylesheet" href="{% static "admin/css/forms.css" %}">{% endblock %}
{% block extrastyle %}
{{ block.super }}
<link rel="stylesheet" href="{% static "admin/css/forms.css" %}">
{% if action_form %}
<script src="{% url 'admin:jsi18n' %}"></script>
{% endif %}
{% endblock %}
{% block coltype %}colM{% endblock %}
@ -34,7 +40,8 @@
{% endif %}
{% endblock %}
<form {% if has_file_field %}enctype="multipart/form-data" {% endif %}{% if form_url %}action="{{ form_url }}" {% endif %}method="post" id="{{ opts.model_name }}_form" novalidate>{% csrf_token %}{% block form_top %}{% endblock %}
<div>
<div id="changeform">
{% if action_form %}{% admin_actions %}{% endif %}
{% if is_popup %}<input type="hidden" name="{{ is_popup_var }}" value="1">{% endif %}
{% if to_field %}<input type="hidden" name="{{ to_field_var }}" value="{{ to_field }}">{% endif %}
{% if save_on_top %}{% block submit_buttons_top %}{% submit_row %}{% endblock %}{% endif %}

View File

@ -5,6 +5,8 @@ from django.contrib.admin.utils import quote
from django.urls import Resolver404, get_script_prefix, resolve
from django.utils.http import urlencode
from .base import InclusionAdminNode
register = template.Library()
@ -68,3 +70,30 @@ def add_preserved_filters(context, url, popup=False, to_field=None):
parsed_url[3] = urlencode(merged_qs)
return urlunsplit(parsed_url)
def admin_actions(context):
"""
Track the number of times the action field has been rendered on the page,
so we know which value to use.
"""
context["action_index"] = context.get("action_index", -1) + 1
return context
@register.tag(name="admin_actions")
def admin_actions_tag(parser, token):
return InclusionAdminNode(
parser, token, func=admin_actions, template_name="actions.html"
)
@register.tag(name="change_list_object_tools")
def change_list_object_tools_tag(parser, token):
"""Display the row of change list object tools."""
return InclusionAdminNode(
parser,
token,
func=lambda context: context,
template_name="change_list_object_tools.html",
)

View File

@ -413,7 +413,13 @@ def redirect_to(modeladmin, request, selected):
@admin.action(description="Download subscription")
def download(modeladmin, request, selected):
buf = StringIO("This is the content of the file")
from django.db.models.query import QuerySet
if isinstance(selected, QuerySet):
buf = StringIO("This is the content of the file")
else:
buf = StringIO(f"This is the content of the file written by {selected.name}")
return StreamingHttpResponse(FileWrapper(buf))

View File

@ -544,3 +544,112 @@ class AdminActionsPermissionTests(TestCase):
reverse("admin:admin_views_subscriber_changelist"), delete_confirmation_data
)
self.assertEqual(response.status_code, 403)
@override_settings(ROOT_URLCONF="admin_views.urls")
class AdminDetailActionsTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.superuser = User.objects.create_superuser(
username="super", password="secret", email="super@example.com"
)
cls.s1 = ExternalSubscriber.objects.create(
name="John Doe", email="john@example.org"
)
def setUp(self):
self.client.force_login(self.superuser)
def test_available_detail_actions(self):
"""
- Action 1 has not description.
- Action 2 has custom description.
- Action 3 ("Detail download") is not displayed because user does not
have permission.
- "Delete" action is not displayed in change view because already
exists a button for it.
"""
response = self.client.get(
reverse("admin:admin_views_externalsubscriber_change", args=[self.s1.pk])
)
self.assertContains(
response,
"""<label>Action: <select name="action" required>
<option value="" selected>---------</option>
<option value="redirect_to">Redirect to (Awesome action)</option>
<option value="external_mail">External mail (Another awesome
action)</option>
<option value="download">Download subscription</option>
<option value="no_perm">No permission to run</option>
</select>""",
html=True,
)
def test_detail_actions_are_not_present_in_add_view(self):
"""
`add_view` inherits the same function as `change_view` but actions are
not present here.
"""
response = self.client.get(reverse("admin:admin_views_externalsubscriber_add"))
self.assertNotContains(
response,
"<div class='actions'>",
html=True,
)
def test_update_external_subscriber_without_select_an_action(self):
"""
Select an action is not required in change view.
"""
self.client.post(
reverse("admin:admin_views_externalsubscriber_change", args=[self.s1.pk]),
{
"name": "Foo Bar",
"email": "foo@bar.com",
"_save": "Save",
"action": "",
},
)
external_subscriber = ExternalSubscriber.objects.get()
self.assertEqual(external_subscriber.name, "Foo Bar")
self.assertEqual(external_subscriber.email, "foo@bar.com")
def test_external_subscriber_is_not_updated_when_select_an_action(self):
"""
Edit the Subscriber and then choose an action won't save the changes.
"""
self.client.post(
reverse("admin:admin_views_externalsubscriber_change", args=[self.s1.pk]),
{
"name": "Foo Bar",
"email": "foo@bar.com",
"_save": "Save",
"action": "external_mail",
},
)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, "Greetings from a function action")
external_subscriber = ExternalSubscriber.objects.get()
self.assertEqual(external_subscriber.name, "John Doe")
self.assertEqual(external_subscriber.email, "john@example.org")
def test_custom_function_action_streaming_response(self):
"""
A custom action may return a StreamingHttpResponse with the
name of the External Subscriber.
"""
response = self.client.post(
reverse("admin:admin_views_externalsubscriber_change", args=[self.s1.pk]),
{"action": "download"},
)
content = b"".join(list(response))
self.assertEqual(
content,
f"This is the content of the file written by {self.s1.name}".encode(),
)
self.assertEqual(response.status_code, 200)