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:
parent
082fe2b5a8
commit
caae4f34f6
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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 %}
|
||||
|
@ -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",
|
||||
)
|
||||
|
@ -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))
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user