diff --git a/django/contrib/admin/templates/admin/actions.html b/django/contrib/admin/templates/admin/actions.html index 80ffa066ed..ef2232e13f 100644 --- a/django/contrib/admin/templates/admin/actions.html +++ b/django/contrib/admin/templates/admin/actions.html @@ -1,7 +1,13 @@ {% load i18n %}
+ {% block actions %} + {% block actions-form %} {% for field in action_form %}{% if field.label %}{% endif %}{% endfor %} + {% endblock %} + {% block actions-submit %} + {% endblock %} + {% block actions-counter %} {% if actions_selection_counter %} {{ selection_note }} {% if cl.result_count != cl.result_list|length %} @@ -12,4 +18,6 @@ {% trans "Clear selection" %} {% endif %} {% endif %} + {% endblock %} + {% endblock %}
diff --git a/django/contrib/admin/templates/admin/change_form.html b/django/contrib/admin/templates/admin/change_form.html index f77e50a130..604747e6d9 100644 --- a/django/contrib/admin/templates/admin/change_form.html +++ b/django/contrib/admin/templates/admin/change_form.html @@ -28,11 +28,7 @@ {% if change %}{% if not is_popup %} {% endif %}{% endif %} diff --git a/django/contrib/admin/templates/admin/change_form_object_tools.html b/django/contrib/admin/templates/admin/change_form_object_tools.html new file mode 100644 index 0000000000..32487493a2 --- /dev/null +++ b/django/contrib/admin/templates/admin/change_form_object_tools.html @@ -0,0 +1,8 @@ +{% load i18n admin_urls %} +{% block object-tools-items %} +
  • + {% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %} + {% trans "History" %} +
  • +{% if has_absolute_url %}
  • {% trans "View on site" %}
  • {% endif %} +{% endblock %} diff --git a/django/contrib/admin/templates/admin/change_list.html b/django/contrib/admin/templates/admin/change_list.html index 34a4031799..0ffca672c0 100644 --- a/django/contrib/admin/templates/admin/change_list.html +++ b/django/contrib/admin/templates/admin/change_list.html @@ -42,14 +42,7 @@ {% block object-tools %} {% endblock %} diff --git a/django/contrib/admin/templates/admin/change_list_object_tools.html b/django/contrib/admin/templates/admin/change_list_object_tools.html new file mode 100644 index 0000000000..5d6d458276 --- /dev/null +++ b/django/contrib/admin/templates/admin/change_list_object_tools.html @@ -0,0 +1,12 @@ +{% load i18n admin_urls %} + +{% block object-tools-items %} + {% if has_add_permission %} +
  • + {% url cl.opts|admin_urlname:'add' as add_url %} + + {% blocktrans with cl.opts.verbose_name as name %}Add {{ name }}{% endblocktrans %} + +
  • + {% endif %} +{% endblock %} diff --git a/django/contrib/admin/templates/admin/date_hierarchy.html b/django/contrib/admin/templates/admin/date_hierarchy.html index ecbd2a1a00..65ae800134 100644 --- a/django/contrib/admin/templates/admin/date_hierarchy.html +++ b/django/contrib/admin/templates/admin/date_hierarchy.html @@ -1,10 +1,16 @@ {% if show %}

    {% endif %} diff --git a/django/contrib/admin/templates/admin/submit_line.html b/django/contrib/admin/templates/admin/submit_line.html index 2e6cf057f0..26f3920ffa 100644 --- a/django/contrib/admin/templates/admin/submit_line.html +++ b/django/contrib/admin/templates/admin/submit_line.html @@ -1,5 +1,6 @@ {% load i18n admin_urls %}
    +{% block submit-row %} {% if show_save %}{% endif %} {% if show_delete_link %} {% url opts|admin_urlname:'delete' original.pk|admin_urlquote as delete_url %} @@ -8,4 +9,5 @@ {% if show_save_as_new %}{% endif %} {% if show_save_and_add_another %}{% endif %} {% if show_save_and_continue %}{% endif %} +{% endblock %}
    diff --git a/django/contrib/admin/templatetags/admin_list.py b/django/contrib/admin/templatetags/admin_list.py index eeb56c0aa4..ff04faed94 100644 --- a/django/contrib/admin/templatetags/admin_list.py +++ b/django/contrib/admin/templatetags/admin_list.py @@ -19,6 +19,8 @@ from django.utils.safestring import mark_safe from django.utils.text import capfirst from django.utils.translation import gettext as _ +from .base import InclusionAdminNode + register = Library() DOT = '.' @@ -40,7 +42,6 @@ def paginator_number(cl, i): i + 1) -@register.inclusion_tag('admin/pagination.html') def pagination(cl): """ Generate the series of links to the pages in a paginated list. @@ -89,6 +90,16 @@ def pagination(cl): } +@register.tag(name='pagination') +def pagination_tag(parser, token): + return InclusionAdminNode( + parser, token, + func=pagination, + template_name='pagination.html', + takes_context=False, + ) + + def result_headers(cl): """ Generate the list column headers. @@ -314,7 +325,6 @@ def result_hidden_fields(cl): yield mark_safe(form[cl.model._meta.pk.name]) -@register.inclusion_tag("admin/change_list_results.html") def result_list(cl): """ Display the headers and data list together. @@ -331,7 +341,16 @@ def result_list(cl): 'results': list(results(cl))} -@register.inclusion_tag('admin/date_hierarchy.html') +@register.tag(name='result_list') +def result_list_tag(parser, token): + return InclusionAdminNode( + parser, token, + func=result_list, + template_name='change_list_results.html', + takes_context=False, + ) + + def date_hierarchy(cl): """ Display the date hierarchy for date drill-down functionality. @@ -406,7 +425,16 @@ def date_hierarchy(cl): } -@register.inclusion_tag('admin/search_form.html') +@register.tag(name='date_hierarchy') +def date_hierarchy_tag(parser, token): + return InclusionAdminNode( + parser, token, + func=date_hierarchy, + template_name='date_hierarchy.html', + takes_context=False, + ) + + def search_form(cl): """ Display a search form for searching the list. @@ -418,6 +446,11 @@ def search_form(cl): } +@register.tag(name='search_form') +def search_form_tag(parser, token): + return InclusionAdminNode(parser, token, func=search_form, template_name='search_form.html', takes_context=False) + + @register.simple_tag def admin_list_filter(cl, spec): tpl = get_template(spec.template) @@ -428,7 +461,6 @@ def admin_list_filter(cl, spec): }) -@register.inclusion_tag('admin/actions.html', takes_context=True) def admin_actions(context): """ Track the number of times the action field has been rendered on the page, @@ -436,3 +468,24 @@ def admin_actions(context): """ 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') + + +def change_list_object_tools(context): + """ + Displays the row of change list object tools. + """ + return context + + +@register.tag(name='change_list_object_tools') +def change_list_object_tools_tag(parser, token): + return InclusionAdminNode( + parser, token, + func=change_list_object_tools, + template_name='change_list_object_tools.html', + ) diff --git a/django/contrib/admin/templatetags/admin_modify.py b/django/contrib/admin/templatetags/admin_modify.py index 50ec00bb25..95f0fc977c 100644 --- a/django/contrib/admin/templatetags/admin_modify.py +++ b/django/contrib/admin/templatetags/admin_modify.py @@ -3,10 +3,11 @@ import json from django import template from django.template.context import Context +from .base import InclusionAdminNode + register = template.Library() -@register.inclusion_tag('admin/prepopulated_fields_js.html', takes_context=True) def prepopulated_fields_js(context): """ Create a list of prepopulated_fields that should render Javascript for @@ -39,7 +40,11 @@ def prepopulated_fields_js(context): return context -@register.inclusion_tag('admin/submit_line.html', takes_context=True) +@register.tag(name='prepopulated_fields_js') +def prepopulated_fields_js_tag(parser, token): + return InclusionAdminNode(parser, token, func=prepopulated_fields_js, template_name="prepopulated_fields_js.html") + + def submit_row(context): """ Display the row of buttons for delete and save. @@ -66,6 +71,27 @@ def submit_row(context): return ctx +@register.tag(name='submit_row') +def submit_row_tag(parser, token): + return InclusionAdminNode(parser, token, func=submit_row, template_name='submit_line.html') + + +def change_form_object_tools(context): + """ + Displays the row of change form object tools. + """ + return context + + +@register.tag(name='change_form_object_tools') +def change_form_object_tools_tag(parser, token): + return InclusionAdminNode( + parser, token, + func=change_form_object_tools, + template_name='change_form_object_tools.html', + ) + + @register.filter def cell_count(inline_admin_form): """Return the number of cells used in a tabular inline.""" diff --git a/django/contrib/admin/templatetags/base.py b/django/contrib/admin/templatetags/base.py new file mode 100644 index 0000000000..a26a84d0de --- /dev/null +++ b/django/contrib/admin/templatetags/base.py @@ -0,0 +1,31 @@ +from inspect import getfullargspec + +from django.template.library import InclusionNode, parse_bits + + +class InclusionAdminNode(InclusionNode): + def __init__(self, parser, token, func, template_name, takes_context=True): + self.template_name = template_name + + params, varargs, varkw, defaults, kwonly, kwonly_defaults, _ = getfullargspec(func) + if len(params) > 0 and params[0] == 'self': + params = params[1:] # ignore 'self' + bits = token.split_contents() + args, kwargs = parse_bits( + parser, bits[1:], params, varargs, varkw, defaults, kwonly, kwonly_defaults, takes_context, bits[0] + ) + super().__init__( + func=func, takes_context=takes_context, args=args, kwargs=kwargs, filename=None + ) + + def render(self, context): + opts = context['opts'] + app_label = opts.app_label.lower() + object_name = opts.object_name.lower() + self.filename = [ + 'admin/%s/%s/%s' % (app_label, object_name, self.template_name), + 'admin/%s/%s' % (app_label, self.template_name), + 'admin/%s' % (self.template_name,), + ] + + return super().render(context) diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt index d1138076ff..5ea78d2278 100644 --- a/docs/ref/contrib/admin/index.txt +++ b/docs/ref/contrib/admin/index.txt @@ -2680,12 +2680,28 @@ Templates which may be overridden per app or model Not every template in ``contrib/admin/templates/admin`` may be overridden per app or per model. The following can: +* ``actions.html`` * ``app_index.html`` * ``change_form.html`` +* ``change_form_object_tools.html`` * ``change_list.html`` +* ``change_list_object_tools.html`` +* ``change_list_results.html`` +* ``date_hierarchy.html`` * ``delete_confirmation.html`` * ``object_history.html`` +* ``pagination.html`` * ``popup_response.html`` +* ``prepopulated_fields_js.html`` +* ``search_form.html`` +* ``submit_line.html`` + +.. versionchanged:: 2.1 + + The ability to override the ``actions.html``, ``change_form_object_tools.html``, + ``change_list_object_tools.html``, ``change_list_results.html``, + ``date_hierarchy.html``, ``pagination.html``, ``prepopulated_fields_js.html``, + ``search_form.html``, ``submit_line.html`` templates were added. For those templates that cannot be overridden in this way, you may still override them for your entire project. Just place the new version in your diff --git a/docs/releases/2.1.txt b/docs/releases/2.1.txt index 65b41b2eed..d6cb44caa1 100644 --- a/docs/releases/2.1.txt +++ b/docs/releases/2.1.txt @@ -52,6 +52,14 @@ Minor features * The new :meth:`.ModelAdmin.get_deleted_objects()` method allows customizing the deletion process of the delete view and the "delete selected" action. +* The ``actions.html``, ``change_list_results.html``, ``date_hierarchy.html``, + ``pagination.html``, ``prepopulated_fields_js.html``, ``search_form.html`` + and ``submit_line.html`` templates can be overridden even per app or + per model, other than globally. + +* The admin change list and change form object tools can now be overridden per app, + per model or globally with ``change_list_object_tools.html`` and + ``change_form_object_tools.html`` templates. :mod:`django.contrib.admindocs` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/admin_changelist/tests.py b/tests/admin_changelist/tests.py index 5be70ee6ad..f37e3fa039 100644 --- a/tests/admin_changelist/tests.py +++ b/tests/admin_changelist/tests.py @@ -119,7 +119,7 @@ class ChangeListTests(TestCase): cl = m.get_changelist_instance(request) cl.formset = None template = Template('{% load admin_list %}{% spaceless %}{% result_list cl %}{% endspaceless %}') - context = Context({'cl': cl}) + context = Context({'cl': cl, 'opts': Child._meta}) table_output = template.render(context) link = reverse('admin:admin_changelist_child_change', args=(new_child.id,)) row_html = build_tbody_html(new_child.id, link, '-') @@ -137,7 +137,7 @@ class ChangeListTests(TestCase): cl = m.get_changelist_instance(request) cl.formset = None template = Template('{% load admin_list %}{% spaceless %}{% result_list cl %}{% endspaceless %}') - context = Context({'cl': cl}) + context = Context({'cl': cl, 'opts': Child._meta}) table_output = template.render(context) link = reverse('admin:admin_changelist_child_change', args=(new_child.id,)) row_html = build_tbody_html(new_child.id, link, '???') @@ -153,7 +153,7 @@ class ChangeListTests(TestCase): cl = m.get_changelist_instance(request) cl.formset = None template = Template('{% load admin_list %}{% spaceless %}{% result_list cl %}{% endspaceless %}') - context = Context({'cl': cl}) + context = Context({'cl': cl, 'opts': Child._meta}) table_output = template.render(context) link = reverse('admin:admin_changelist_child_change', args=(new_child.id,)) row_html = build_tbody_html( @@ -176,7 +176,7 @@ class ChangeListTests(TestCase): cl = m.get_changelist_instance(request) cl.formset = None template = Template('{% load admin_list %}{% spaceless %}{% result_list cl %}{% endspaceless %}') - context = Context({'cl': cl}) + context = Context({'cl': cl, 'opts': Child._meta}) table_output = template.render(context) link = reverse('admin:admin_changelist_child_change', args=(new_child.id,)) row_html = build_tbody_html(new_child.id, link, '%s' % new_parent) @@ -204,7 +204,7 @@ class ChangeListTests(TestCase): FormSet = m.get_changelist_formset(request) cl.formset = FormSet(queryset=cl.result_list) template = Template('{% load admin_list %}{% spaceless %}{% result_list cl %}{% endspaceless %}') - context = Context({'cl': cl}) + context = Context({'cl': cl, 'opts': Child._meta}) table_output = template.render(context) # make sure that hidden fields are in the correct place hiddenfields_div = ( diff --git a/tests/admin_views/templates/admin/admin_views/article/actions.html b/tests/admin_views/templates/admin/admin_views/article/actions.html new file mode 100644 index 0000000000..9aa238fd2a --- /dev/null +++ b/tests/admin_views/templates/admin/admin_views/article/actions.html @@ -0,0 +1,6 @@ +{% extends "admin/actions.html" %} +{% load i18n %} + +{% block actions-submit %} + +{% endblock %} diff --git a/tests/admin_views/templates/admin/admin_views/article/change_form_object_tools.html b/tests/admin_views/templates/admin/admin_views/article/change_form_object_tools.html new file mode 100644 index 0000000000..609974bb95 --- /dev/null +++ b/tests/admin_views/templates/admin/admin_views/article/change_form_object_tools.html @@ -0,0 +1,7 @@ +{% extends "admin/change_form_object_tools.html" %} +{% load i18n admin_urls %} + +{% block object-tools-items %} +
  • {% trans "Export" %}
  • +{{ block.super }} +{% endblock %} diff --git a/tests/admin_views/templates/admin/admin_views/article/change_list_object_tools.html b/tests/admin_views/templates/admin/admin_views/article/change_list_object_tools.html new file mode 100644 index 0000000000..1a9be91952 --- /dev/null +++ b/tests/admin_views/templates/admin/admin_views/article/change_list_object_tools.html @@ -0,0 +1,7 @@ +{% extends "admin/change_list_object_tools.html" %} +{% load i18n admin_urls %} + +{% block object-tools-items %} +
  • {% trans "Export" %}
  • +{{ block.super }} +{% endblock %} diff --git a/tests/admin_views/templates/admin/admin_views/article/change_list_results.html b/tests/admin_views/templates/admin/admin_views/article/change_list_results.html new file mode 100644 index 0000000000..ceb581f8ef --- /dev/null +++ b/tests/admin_views/templates/admin/admin_views/article/change_list_results.html @@ -0,0 +1,38 @@ +{% load i18n static %} +{% if result_hidden_fields %} +
    {# DIV for HTML validation #} +{% for item in result_hidden_fields %}{{ item }}{% endfor %} +
    +{% endif %} +{% if results %} +
    + + + +{% for header in result_headers %} +{% endfor %} + + + +{% for result in results %} +{% if result.form.non_field_errors %} + +{% endif %} +{% for item in result %}{{ item }}{% endfor %} +{% endfor %} + +
    + {% if header.sortable %} + {% if header.sort_priority > 0 %} +
    + + {% if num_sorted_fields > 1 %}{{ header.sort_priority }}{% endif %} + +
    + {% endif %} + {% endif %} +
    {% if header.sortable %}{{ header.text|capfirst }}{% else %}{{ header.text|capfirst }}{% endif %}
    +
    +
    {{ result.form.non_field_errors }}
    +
    +{% endif %} diff --git a/tests/admin_views/templates/admin/admin_views/article/date_hierarchy.html b/tests/admin_views/templates/admin/admin_views/article/date_hierarchy.html new file mode 100644 index 0000000000..de1cb747b5 --- /dev/null +++ b/tests/admin_views/templates/admin/admin_views/article/date_hierarchy.html @@ -0,0 +1,9 @@ +{% extends "admin/date_hierarchy.html" %} +{% load i18n %} + +{% block date-hierarchy-choices %} + + +{% endblock %} diff --git a/tests/admin_views/templates/admin/admin_views/article/pagination.html b/tests/admin_views/templates/admin/admin_views/article/pagination.html new file mode 100644 index 0000000000..e072cacd3c --- /dev/null +++ b/tests/admin_views/templates/admin/admin_views/article/pagination.html @@ -0,0 +1,12 @@ +{% load admin_list %} +{% load i18n %} +

    +{% if pagination_required %} +{% for i in page_range %} + {% paginator_number cl i %} +{% endfor %} +{% endif %} +{{ cl.result_count }} {% if cl.result_count == 1 %}{{ cl.opts.verbose_name }}{% else %}{{ cl.opts.verbose_name_plural }}{% endif %} +{% if show_all_url %}  {% trans 'Show all' %}{% endif %} +{% if cl.formset and cl.result_count %}{% endif %} +

    diff --git a/tests/admin_views/templates/admin/admin_views/article/prepopulated_fields_js.html b/tests/admin_views/templates/admin/admin_views/article/prepopulated_fields_js.html new file mode 100644 index 0000000000..0ee8c7a06c --- /dev/null +++ b/tests/admin_views/templates/admin/admin_views/article/prepopulated_fields_js.html @@ -0,0 +1,7 @@ +{% load l10n static %} + diff --git a/tests/admin_views/templates/admin/admin_views/article/search_form.html b/tests/admin_views/templates/admin/admin_views/article/search_form.html new file mode 100644 index 0000000000..5b5e6a58f6 --- /dev/null +++ b/tests/admin_views/templates/admin/admin_views/article/search_form.html @@ -0,0 +1,16 @@ +{% load i18n static %} +{% if cl.search_fields %} +
    +{% endif %} diff --git a/tests/admin_views/templates/admin/admin_views/article/submit_line.html b/tests/admin_views/templates/admin/admin_views/article/submit_line.html new file mode 100644 index 0000000000..4a2ca08890 --- /dev/null +++ b/tests/admin_views/templates/admin/admin_views/article/submit_line.html @@ -0,0 +1,7 @@ +{% extends "admin/submit_line.html" %} +{% load i18n admin_urls %} + +{% block submit-row %} +{% if show_publish %}{% endif %} +{{ block.super }} +{% endblock %} diff --git a/tests/admin_views/test_templatetags.py b/tests/admin_views/test_templatetags.py index 44a08f32cd..db78636d5d 100644 --- a/tests/admin_views/test_templatetags.py +++ b/tests/admin_views/test_templatetags.py @@ -7,9 +7,10 @@ from django.contrib.auth.admin import UserAdmin from django.contrib.auth.models import User from django.test import RequestFactory, TestCase from django.urls import reverse +from django.utils.encoding import force_text -from .admin import site -from .models import Question +from .admin import ArticleAdmin, site +from .models import Article, Question from .tests import AdminViewBasicTestCase @@ -28,6 +29,46 @@ class AdminTemplateTagsTest(AdminViewBasicTestCase): self.assertIs(template_context['extra'], True) self.assertIs(template_context['show_save'], True) + def test_can_override_change_form_templatetags(self): + """ + admin_modify templatetags can follow the 'standard' search patter admin/app_label/model/template.html + """ + factory = RequestFactory() + article = Article.objects.all()[0] + request = factory.get(reverse('admin:admin_views_article_change', args=[article.pk])) + request.user = self.superuser + admin = ArticleAdmin(Article, site) + extra_context = {'show_publish': True, 'extra': True} + response = admin.change_view(request, str(article.pk), extra_context=extra_context) + response.render() + self.assertIs(response.context_data['show_publish'], True) + self.assertIs(response.context_data['extra'], True) + content = force_text(response.content) + self.assertIs('name="_save"' in content, True) + self.assertIs('name="_publish"' in content, True) + self.assertIs('override-change_form_object_tools' in content, True) + self.assertIs('override-prepopulated_fields_js' in content, True) + + def test_can_override_change_list_templatetags(self): + """ + admin_list templatetags can follow the 'standard' search patter admin/app_label/model/template.html + """ + factory = RequestFactory() + request = factory.get(reverse('admin:admin_views_article_changelist')) + request.user = self.superuser + admin = ArticleAdmin(Article, site) + admin.date_hierarchy = 'date' + admin.search_fields = ('title', 'content',) + response = admin.changelist_view(request) + response.render() + content = force_text(response.content) + self.assertIs('override-actions' in content, True) + self.assertIs('override-change_list_object_tools' in content, True) + self.assertIs('override-change_list_results' in content, True) + self.assertIs('override-date_hierarchy' in content, True) + self.assertIs('override-pagination' in content, True) + self.assertIs('override-search_form' in content, True) + class DateHierarchyTests(TestCase): factory = RequestFactory()