Silly Freak
Silly Freak

Reputation: 4231

Django: how to add an action button next to the History button in an admin form - cleanly?

In my Tournament admin, I want to add a button for creating the match structure, which depends on the previously registered competing Teams. That is an action that concerns a single tournament, and has no "result" - it just does a moderately complicated update to the database.

I implemented the functionality as an admin action, it works well, and it seems like best way to implement that functionality. However, most of the time, the user will want to execute that action from the tournament page, instead of from the tournament list.

This answer shows how to add the admin action form to the relevant page, but I don't need the drop down list, which also includes the "delete" action; a simple button would be better.

This question is about adding buttons (though it's for the list page), but the proper styling only works for a href tags, not for buttons; I'm executing an action, not showing a new document. I want to avoid duplicating the relevant CSS to work with a form submit button. I'd also like to avoid hardcoding the action name, or generally repeating the things that the admin action dropdown would infer for me.

So my specific questions are:

Relevant code follows:

admin.py

# from https://stackoverflow.com/a/20379270/371191
class ActionInChangeFormMixin(object):
    def response_action(self, request, queryset):
        """
        Prefer http referer for redirect
        """
        response = super(ActionInChangeFormMixin, self).response_action(request, queryset)
        if isinstance(response, HttpResponseRedirect):
            response['Location'] = request.META.get('HTTP_REFERER', response.url)
        return response

    def change_view(self, request, object_id, form_url='', extra_context=None):
        actions = self.get_actions(request)
        if actions:
            action_form = self.action_form(auto_id=None)
            action_form.fields['action'].choices = self.get_action_choices(request)
        else:
            action_form = None
        extra_context = extra_context or {}
        extra_context['action_form'] = action_form
        return super(ActionInChangeFormMixin, self).change_view(request, object_id, extra_context=extra_context)


class TournamentAdmin(ActionInChangeFormMixin, admin.ModelAdmin):
    actions = ['setup_matches']

    def setup_matches(self, request, queryset):
        for tournament in queryset:
            try:
                tournament.setup()
            except ValueError as ex:
                messages.error(request, 'Could not update %s: %s'  % (tournament, ex))
            else:
                messages.success(request, 'Updated %s' % (tournament,))

    setup_matches.short_description = 'Setup matches for selected tournaments'

change_form.py

{% extends "admin/change_form.html" %}
{% load i18n admin_urls %}

{% block object-tools-items %}
    <li>
        <form action="{% url opts|admin_urlname:'changelist' %}" method="POST">{% csrf_token %}
            <input type="hidden" name="action" value="setup_matches">
            <input type="hidden" name="_selected_action" value="{{ object_id }}">
            <button value="0" name="index" title="Setup matches for selected tournaments" type="submit">Setup matches</button>
        </form>
    </li>
    {{ block.super }}
{% endblock %}

Upvotes: 3

Views: 3834

Answers (1)

Silly Freak
Silly Freak

Reputation: 4231

Most of the code in ActionInChangeFormMixin.change_view() is specifically setup code for the dropdown list, and thus dead code when used with the template shown above. The logic happens here:

action_form = self.action_form(auto_id=None)
action_form.fields['action'].choices = self.get_action_choices(request)

action_form creates the actual form - which we don't want to render anyway. get_action_choices populates the <select> with tuples to use as options.

To be as flexible as possible, I'll introduce a new method, which retrieves only the actions we want to show. Also, let's get rid of the unnecessary code (the new code is inspired by get_action_choices):

class ActionInChangeFormMixin(object):
    # ...

    def get_change_actions(self, request):
        return self.get_actions(request)

    def change_view(self, request, object_id, form_url='', extra_context=None):
        actions = self.get_change_actions(request) or OrderedDict()
        extra_context = extra_context or {}
        extra_context['change_actions'] = [(name, description % admin.utils.model_format_dict(self.opts))
                                           for func, name, description in six.itervalues(actions)]
        return super(ActionInChangeFormMixin, self).change_view(request, object_id, extra_context=extra_context)

In TournamentAdmin, we can then filter which actions we want to see. In this case, I don't want to show a button for the bulk delete action:

def get_change_actions(self, request):
    result = self.get_actions(request)
    del result['delete_selected']
    return result

change_form.html now needs some logic to render the relevant buttons:

{% extends "admin/change_form.html" %}
{% load i18n admin_urls %}

{% block object-tools-items %}
    {% for action_name, action_description in change_actions %}
    <li>
        <form id="action_{{ action_name }}" action="{% url opts|admin_urlname:'changelist' %}" method="POST">{% csrf_token %}
            <input type="hidden" name="action" value="{{ action_name }}">
            <input type="hidden" name="_selected_action" value="{{ object_id }}">
            <a href="#" onclick="document.getElementById('action_{{ action_name }}').submit(); return false;" title="{{  action_description }}">{{  action_description }}</a>
        </form>
    </li>
    {% endfor %}
    {{ block.super }}
{% endblock %}

This uses JavaScript for submitting the form; I don't think there's a cleaner way to get the styling right.

Upvotes: 3

Related Questions