├── .gitignore ├── LICENSE ├── LICENSE-NOTE ├── README.md ├── admin_unchained ├── __init__.py ├── admin.py ├── apps.py ├── models.py ├── templates │ ├── admin │ │ ├── au_delete_confirmation.html │ │ ├── au_submit_line.html │ │ ├── pagination.html │ │ └── submit_line.html │ └── admin_unchained │ │ ├── change_form.html │ │ └── change_list.html ├── templatetags │ ├── .admin_unchained_list.py.swp │ ├── __init__.py │ └── admin_unchained_list.py ├── tests.py └── views.py ├── example_app ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_book_out_of_print.py │ ├── 0003_book_main_author.py │ └── __init__.py ├── models.py ├── tests.py ├── urls.py └── views.py └── manage.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 andrei kulakov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LICENSE-NOTE: -------------------------------------------------------------------------------- 1 | The following code are copied from Django Admin package and are therefore under Django LICENSE: 2 | 3 | admin_unchained/templatetags/admin_unchained_list.py 4 | 5 | admin_unchained/templates/* 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Admin Unchained 3 | === 4 | 5 | The goal of this package is to make some of the functionality of `contrib.admin` available 6 | outside of the admin site. 7 | 8 | Tested with Django 1.11 and 2.0.2 9 | 10 | This package may be useful for: 11 | --- 12 | 13 | - Customization that is impossible or too complex in the admin. 14 | - Making admin listing or change form available to non-staff users. 15 | - Making an alternative admin listing with different columns or other elements, e.g. a 16 | simpler more compact listing for some use cases while a full admin listing is still 17 | available in the admin. 18 | - Making admin listing available to users who do not have the change permissions for 19 | the respective model. 20 | - Custom / explicit permissioning for each view. 21 | - Making unlimited ChangeList pages / views with different configurations for the same model, e.g. 22 | each with different columns, filters and actions, set up for different types of users, for example 23 | a simplified listing with 2-3 columns for reviewers or other non-technical users, more columns or actions for 24 | users who may need to do more extensive changes. 25 | 26 | The following admin functionality is made available: 27 | --- 28 | 29 | - Sorting, filtering, pagination, search and actions. 30 | - Declaration of columns using the same attribute (`list_display`) as in the admin. 31 | - Add, change, and delete confirmation forms with the same actions as in the admin. 32 | 33 | Limitations: 34 | --- 35 | 36 | - The Admin can look up and create related records, since it usually has all of the models 37 | loaded and managed in the admin site. Admin Unchained package is not meant and would not 38 | make much sense for this use-case so it currently doesn't support it. 39 | 40 | TODO 41 | --- 42 | - Add inlines to change / add views. 43 | - Add history view. 44 | 45 | Quickstart 46 | --- 47 | 48 | For example, if we have a model `Book` as shown: 49 | 50 | class Book(models.Model): 51 | authors = models.ManyToManyField(Author) 52 | title = models.CharField(max_length=150) 53 | published = models.DateField() 54 | num_pages = models.IntegerField() 55 | out_of_print = models.BooleanField(default=False) 56 | 57 | In our views, we'll first import the admin and base views: 58 | 59 | from admin_unchained.admin import AUAdmin 60 | from admin_unchained.views import AUListView, AUAddOrChangeView, AUDeleteView 61 | 62 | Then we'll inherit from the `AUAdmin` and set it up: 63 | 64 | class BookAdmin(AUAdmin): 65 | model = Book 66 | list_display = ('pk', 'title', 'published') 67 | search_fields = ('title', 'authors__last_name') 68 | list_filter = ('published', 'authors') 69 | actions_on_top = True 70 | list_per_page = 20 71 | raw_id_fields = () 72 | 73 | # add_url_name = 'book_add' 74 | # change_url_name = 'book_change' 75 | # actions = (make_published,) 76 | 77 | Making an admin-like listing is as simple as inheriting from `AUListView`: 78 | 79 | class BookListView(AUListView): 80 | model = Book 81 | admin_class = BookAdmin 82 | 83 | You can now add a url for it to your urls: 84 | 85 | url(r'^$', views.BookListView.as_view(), name='books'), 86 | 87 | If you need add, change and delete views, you can add them as follows: 88 | 89 | class BookAddOrChangeView(AUAddOrChangeView): 90 | model = Book 91 | admin_class = BookAdmin 92 | success_url = reverse_lazy('books') 93 | delete_url_name = 'book_delete' 94 | 95 | class BookDeleteView(AUDeleteView): 96 | model = Book 97 | admin_class = BookAdmin 98 | success_url = reverse_lazy('books') 99 | 100 | To link them to the listing admin, you can uncomment the commented lines above in the admin 101 | setup, and add the following to your urls file: 102 | 103 | url(r'^(?P.+)/change/$', 104 | views.BookAddOrChangeView.as_view(), 105 | name='book_change'), 106 | url(r'^(?P.+)/delete/$', 107 | views.BookDeleteView.as_view(), 108 | name='book_delete'), 109 | url(r'^add/$', 110 | views.BookAddOrChangeView.as_view(), 111 | name='book_add'), 112 | 113 | Finally, you can uncomment the actions line in the admin setup and add an action function 114 | somewhere before the admin class: 115 | 116 | def make_published(modeladmin, request, queryset): 117 | print ('In make_published()', queryset) 118 | make_published.short_description = 'Set books as published' 119 | 120 | Example app 121 | --- 122 | You can look under `example_app` to see the example admin, views and urls. 123 | -------------------------------------------------------------------------------- /admin_unchained/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (0,4,0) 2 | -------------------------------------------------------------------------------- /admin_unchained/admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from collections import OrderedDict 5 | import json 6 | 7 | from django.utils import six 8 | from django.shortcuts import render, reverse 9 | from django.http import HttpResponse, HttpResponseRedirect 10 | from django.template.response import SimpleTemplateResponse, TemplateResponse 11 | from django.utils.translation import ugettext as _, ungettext 12 | 13 | from django.contrib import admin 14 | from django.contrib.admin.templatetags.admin_urls import add_preserved_filters 15 | 16 | from django.contrib import messages 17 | from django.utils.encoding import force_text, python_2_unicode_compatible 18 | from django.utils.html import format_html 19 | from django.utils.http import urlencode, urlquote 20 | 21 | from django.contrib.admin import actions as adm_actions 22 | 23 | from django.db import models, router, transaction 24 | from django.contrib.admin.utils import ( 25 | NestedObjects, construct_change_message, flatten_fieldsets, 26 | get_deleted_objects, lookup_needs_distinct, model_format_dict, quote, 27 | unquote, 28 | ) 29 | 30 | IS_POPUP_VAR = '_popup' 31 | TO_FIELD_VAR = '_to_field' 32 | 33 | 34 | class AUAdmin(admin.ModelAdmin): 35 | model = None 36 | list_display = None 37 | search_fields = None 38 | list_filter = None 39 | actions = () 40 | actions_on_top = False 41 | actions_on_bottom = False 42 | list_per_page = 100 43 | add_url_name = None 44 | change_url_name = None 45 | delete_url_name = None 46 | raw_id_fields = None 47 | show_history = False # Currently not supported 48 | 49 | change_form_template = 'admin_unchained/change_form.html' 50 | add_form_template = 'admin_unchained/change_form.html' 51 | delete_confirmation_template = 'admin/au_delete_confirmation.html' 52 | 53 | def get_field_queryset(self, db, db_field, request): 54 | return 55 | 56 | def get_actions(self, request): 57 | if not self.actions_on_top and not self.actions_on_bottom: 58 | return 59 | actions = OrderedDict({'delete_selected': self.get_action(adm_actions.delete_selected)}) 60 | for action in self.actions: 61 | func, name, desc = self.get_action(action) 62 | actions[name] = (func,name,desc) 63 | label = getattr(func, 'short_description', None) \ 64 | or action.replace('_', ' ') 65 | return actions 66 | 67 | def get_add_url(self, request): 68 | return reverse(self.add_url_name) 69 | 70 | def get_change_url(self, result, pk_attname): 71 | pk = getattr(result, pk_attname) 72 | return reverse(self.change_url_name, kwargs=dict(pk=pk)) 73 | 74 | def _delete_view(self, request, object_id, extra_context): 75 | "The 'delete' admin view for this model." 76 | opts = self.model._meta 77 | app_label = opts.app_label 78 | 79 | to_field = request.POST.get(TO_FIELD_VAR, request.GET.get(TO_FIELD_VAR)) 80 | if to_field and not self.to_field_allowed(request, to_field): 81 | raise DisallowedModelAdminToField("The field %s cannot be referenced." % to_field) 82 | 83 | obj = self.get_object(request, unquote(object_id), to_field) 84 | 85 | if not self.has_delete_permission(request, obj): 86 | raise PermissionDenied 87 | 88 | if obj is None: 89 | return self._get_obj_does_not_exist_redirect(request, opts, object_id) 90 | 91 | using = router.db_for_write(self.model) 92 | 93 | # Populate deleted_objects, a data structure of all related objects that 94 | # will also be deleted. 95 | (deleted_objects, model_count, perms_needed, protected) = get_deleted_objects( 96 | [obj], opts, request.user, self.admin_site, using) 97 | 98 | if request.POST and not protected: # The user has confirmed the deletion. 99 | if perms_needed: 100 | raise PermissionDenied 101 | obj_display = force_text(obj) 102 | attr = str(to_field) if to_field else opts.pk.attname 103 | obj_id = obj.serializable_value(attr) 104 | self.log_deletion(request, obj, obj_display) 105 | self.delete_model(request, obj) 106 | 107 | return self.response_delete(request, obj_display, obj_id) 108 | 109 | object_name = force_text(opts.verbose_name) 110 | 111 | if perms_needed or protected: 112 | title = _("Cannot delete %(name)s") % {"name": object_name} 113 | else: 114 | title = _("Are you sure?") 115 | 116 | context = dict( 117 | self.admin_site.each_context(request), 118 | title=title, 119 | object_name=object_name, 120 | object=obj, 121 | deleted_objects=deleted_objects, 122 | model_count=dict(model_count).items(), 123 | perms_lacking=perms_needed, 124 | protected=protected, 125 | opts=opts, 126 | app_label=app_label, 127 | preserved_filters=self.get_preserved_filters(request), 128 | is_popup=(IS_POPUP_VAR in request.POST or 129 | IS_POPUP_VAR in request.GET), 130 | to_field=to_field, 131 | ) 132 | context.update(extra_context or {}) 133 | 134 | return self.render_delete_form(request, context) 135 | 136 | def response_delete(self, request, obj_display, obj_id): 137 | """ 138 | Determines the HttpResponse for the delete_view stage. 139 | """ 140 | 141 | opts = self.model._meta 142 | 143 | if IS_POPUP_VAR in request.POST: 144 | popup_response_data = json.dumps({ 145 | 'action': 'delete', 146 | 'value': str(obj_id), 147 | }) 148 | return TemplateResponse(request, self.popup_response_template or 149 | self.delete_template, { 150 | 'popup_response_data': popup_response_data, 151 | }) 152 | 153 | self.message_user( 154 | request, 155 | _('The %(name)s "%(obj)s" was deleted successfully.') % { 156 | 'name': force_text(opts.verbose_name), 157 | 'obj': force_text(obj_display), 158 | }, 159 | messages.SUCCESS, 160 | ) 161 | 162 | post_url = self.redirect_url 163 | preserved_filters = self.get_preserved_filters(request) 164 | post_url = add_preserved_filters( 165 | {'preserved_filters': preserved_filters, 'opts': opts}, post_url 166 | ) 167 | return HttpResponseRedirect(post_url) 168 | 169 | def response_add(self, request, obj, post_url_continue=None): 170 | """ 171 | Determines the HttpResponse for the add_view stage. 172 | """ 173 | opts = obj._meta 174 | pk_value = obj._get_pk_val() 175 | preserved_filters = self.get_preserved_filters(request) 176 | obj_url = reverse( 177 | self.change_url_name, 178 | args=(quote(pk_value),), 179 | ) 180 | # Add a link to the object's change form if the user can edit the obj. 181 | if self.has_change_permission(request, obj): 182 | obj_repr = format_html('{}', urlquote(obj_url), obj) 183 | else: 184 | obj_repr = force_text(obj) 185 | msg_dict = { 186 | 'name': force_text(opts.verbose_name), 187 | 'obj': obj_repr, 188 | } 189 | # Here, we distinguish between different save types by checking for 190 | # the presence of keys in request.POST. 191 | 192 | if IS_POPUP_VAR in request.POST: 193 | to_field = request.POST.get(TO_FIELD_VAR) 194 | if to_field: 195 | attr = str(to_field) 196 | else: 197 | attr = obj._meta.pk.attname 198 | value = obj.serializable_value(attr) 199 | popup_response_data = json.dumps({ 200 | 'value': six.text_type(value), 201 | 'obj': six.text_type(obj), 202 | }) 203 | return TemplateResponse(request, self.popup_response_template or 204 | self.add_form_template, { 205 | 'popup_response_data': popup_response_data, 206 | }) 207 | 208 | elif "_continue" in request.POST or ( 209 | # Redirecting after "Save as new". 210 | "_saveasnew" in request.POST and self.save_as_continue and 211 | self.has_change_permission(request, obj) 212 | ): 213 | msg = format_html( 214 | _('The {name} "{obj}" was added successfully. You may edit it again below.'), 215 | **msg_dict 216 | ) 217 | self.message_user(request, msg, messages.SUCCESS) 218 | if post_url_continue is None: 219 | post_url_continue = obj_url 220 | post_url_continue = add_preserved_filters( 221 | {'preserved_filters': preserved_filters, 'opts': opts}, 222 | post_url_continue 223 | ) 224 | return HttpResponseRedirect(post_url_continue) 225 | 226 | elif "_addanother" in request.POST: 227 | msg = format_html( 228 | _('The {name} "{obj}" was added successfully. You may add another {name} below.'), 229 | **msg_dict 230 | ) 231 | self.message_user(request, msg, messages.SUCCESS) 232 | redirect_url = request.path 233 | redirect_url = add_preserved_filters({'preserved_filters': preserved_filters, 'opts': opts}, redirect_url) 234 | return HttpResponseRedirect(redirect_url) 235 | 236 | else: 237 | msg = format_html( 238 | _('The {name} "{obj}" was added successfully.'), 239 | **msg_dict 240 | ) 241 | self.message_user(request, msg, messages.SUCCESS) 242 | post_url = add_preserved_filters( 243 | {'preserved_filters': preserved_filters, 'opts': opts}, 244 | str(self.redirect_url) 245 | ) 246 | return HttpResponseRedirect(post_url) 247 | 248 | def response_change(self, request, obj): 249 | """ 250 | Determines the HttpResponse for the change_view stage. 251 | """ 252 | 253 | if IS_POPUP_VAR in request.POST: 254 | opts = obj._meta 255 | to_field = request.POST.get(TO_FIELD_VAR) 256 | attr = str(to_field) if to_field else opts.pk.attname 257 | # Retrieve the `object_id` from the resolved pattern arguments. 258 | value = request.resolver_match.args[0] 259 | new_value = obj.serializable_value(attr) 260 | popup_response_data = json.dumps({ 261 | 'action': 'change', 262 | 'value': six.text_type(value), 263 | 'obj': six.text_type(obj), 264 | 'new_value': six.text_type(new_value), 265 | }) 266 | return TemplateResponse(request, self.popup_response_template or [ 267 | 'admin/%s/%s/popup_response.html' % (opts.app_label, opts.model_name), 268 | 'admin/%s/popup_response.html' % opts.app_label, 269 | 'admin/popup_response.html', 270 | ], { 271 | 'popup_response_data': popup_response_data, 272 | }) 273 | 274 | opts = self.model._meta 275 | pk_value = obj._get_pk_val() 276 | preserved_filters = self.get_preserved_filters(request) 277 | 278 | msg_dict = { 279 | 'name': force_text(opts.verbose_name), 280 | 'obj': format_html('{}', urlquote(request.path), obj), 281 | } 282 | if "_continue" in request.POST: 283 | msg = format_html( 284 | _('The {name} "{obj}" was changed successfully. You may edit it again below.'), 285 | **msg_dict 286 | ) 287 | self.message_user(request, msg, messages.SUCCESS) 288 | redirect_url = request.path 289 | redirect_url = add_preserved_filters({'preserved_filters': preserved_filters, 'opts': opts}, redirect_url) 290 | return HttpResponseRedirect(redirect_url) 291 | 292 | elif "_saveasnew" in request.POST: 293 | msg = format_html( 294 | _('The {name} "{obj}" was added successfully. You may edit it again below.'), 295 | **msg_dict 296 | ) 297 | self.message_user(request, msg, messages.SUCCESS) 298 | return HttpResponseRedirect(self.redirect_url) 299 | 300 | elif "_addanother" in request.POST: 301 | msg = format_html( 302 | _('The {name} "{obj}" was changed successfully. You may add another {name} below.'), 303 | **msg_dict 304 | ) 305 | self.message_user(request, msg, messages.SUCCESS) 306 | return HttpResponseRedirect(reverse(self.add_url_name)) 307 | 308 | else: 309 | msg = format_html( 310 | _('The {name} "{obj}" was changed successfully.'), 311 | **msg_dict 312 | ) 313 | self.message_user(request, msg, messages.SUCCESS) 314 | post_url = add_preserved_filters({'preserved_filters': preserved_filters, 'opts': opts}, self.redirect_url) 315 | return HttpResponseRedirect(post_url) 316 | -------------------------------------------------------------------------------- /admin_unchained/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AdminUnchainedConfig(AppConfig): 5 | name = 'admin_unchained' 6 | -------------------------------------------------------------------------------- /admin_unchained/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /admin_unchained/templates/admin/au_delete_confirmation.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n admin_urls static %} 3 | 4 | {% block extrahead %} 5 | {{ block.super }} 6 | {{ media }} 7 | 8 | {% endblock %} 9 | 10 | {% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} delete-confirmation{% endblock %} 11 | 12 | {% block breadcrumbs %} 13 | {% endblock %} 14 | 15 | {% block content %} 16 | {% if perms_lacking %} 17 |

{% blocktrans with escaped_object=object %}Deleting the {{ object_name }} '{{ escaped_object }}' would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktrans %}

18 | 23 | {% elif protected %} 24 |

{% blocktrans with escaped_object=object %}Deleting the {{ object_name }} '{{ escaped_object }}' would require deleting the following protected related objects:{% endblocktrans %}

25 | 30 | {% else %} 31 |

{% blocktrans with escaped_object=object %}Are you sure you want to delete the {{ object_name }} "{{ escaped_object }}"? All of the following related items will be deleted:{% endblocktrans %}

32 | {% include "admin/includes/object_delete_summary.html" %} 33 |

{% trans "Objects" %}

34 | 35 |
{% csrf_token %} 36 |
37 | 38 | {% if is_popup %}{% endif %} 39 | {% if to_field %}{% endif %} 40 | 41 | {% trans "No, take me back" %} 42 |
43 |
44 | {% endif %} 45 | {% endblock %} 46 | -------------------------------------------------------------------------------- /admin_unchained/templates/admin/au_submit_line.html: -------------------------------------------------------------------------------- 1 | {% load i18n admin_urls %} 2 |
3 | {% if show_save %}{% endif %} 4 | {% if show_delete_link and delete_url %} 5 | 6 | {% endif %} 7 | {% if show_save_as_new %}{% endif %} 8 | {% if show_save_and_add_another %}{% endif %} 9 | {% if show_save_and_continue %}{% endif %} 10 |
11 | -------------------------------------------------------------------------------- /admin_unchained/templates/admin/pagination.html: -------------------------------------------------------------------------------- 1 | {% load admin_list %} 2 | {% load i18n %} 3 |

4 | {% if pagination_required %} 5 | {% for i in page_range %} 6 | {% paginator_number cl i %} 7 | {% endfor %} 8 | {% endif %} 9 | {{ cl.result_count }} {% if cl.result_count == 1 %}{{ cl.opts.verbose_name }}{% else %}{{ cl.opts.verbose_name_plural }}{% endif %} 10 | {% if show_all_url %}  {% trans 'Show all' %}{% endif %} 11 | {% if cl.formset and cl.result_count %}{% endif %} 12 |

13 | -------------------------------------------------------------------------------- /admin_unchained/templates/admin/submit_line.html: -------------------------------------------------------------------------------- 1 | {% load i18n admin_urls %} 2 |
3 | TEST 4 | {% if show_save %}{% endif %} 5 | {% if show_delete_link and delete_url %} 6 | 7 | {% endif %} 8 | {% if show_save_as_new %}{% endif %} 9 | {% if show_save_and_add_another %}{% endif %} 10 | {% if show_save_and_continue %}{% endif %} 11 |
12 | -------------------------------------------------------------------------------- /admin_unchained/templates/admin_unchained/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n admin_urls static admin_modify admin_unchained_list %} 3 | 4 | {% block extrahead %}{{ block.super }} 5 | 6 | {{ media }} 7 | {% endblock %} 8 | 9 | {% block title %}Edit record: {{ original|truncatewords:'18' }}{% endblock %} 10 | {% block branding %}

Edit: {{ original|truncatewords:'18' }}

{% endblock %} 11 | 12 | {% block extrastyle %}{{ block.super }}{% endblock %} 13 | 14 | {% block coltype %}colM{% endblock %} 15 | 16 | {% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-form{% endblock %} 17 | 18 | {% if not is_popup %} 19 | {% block breadcrumbs %} 20 | {% endblock %} 21 | {% endif %} 22 | 23 | {% block content %}
24 | {% block object-tools %} 25 | {% if change %}{% if not is_popup %} 26 |
    27 | {% block object-tools-items %} 28 | {% if show_history %} 29 |
  • 30 | {% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %} 31 | {% trans "History" %} 32 |
  • 33 | {% endif %} 34 | {% if has_absolute_url %}
  • {% trans "View on site" %}
  • {% endif %} 35 | {% endblock %} 36 |
37 | {% endif %}{% endif %} 38 | {% endblock %} 39 |
{% csrf_token %}{% block form_top %}{% endblock %} 40 |
41 | {% if is_popup %}{% endif %} 42 | {% if to_field %}{% endif %} 43 | {% if save_on_top %}{% block submit_buttons_top %}{% submit_row %}{% endblock %}{% endif %} 44 | {% if errors %} 45 |

46 | {% if errors|length == 1 %}{% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}{% endif %} 47 |

48 | {{ adminform.form.non_field_errors }} 49 | {% endif %} 50 | 51 | {% block field_sets %} 52 | {% for fieldset in adminform %} 53 | {% include "admin/includes/fieldset.html" %} 54 | {% endfor %} 55 | {% endblock %} 56 | 57 | {% block after_field_sets %}{% endblock %} 58 | 59 | {% block inline_field_sets %} 60 | {% for inline_admin_formset in inline_admin_formsets %} 61 | {% include inline_admin_formset.opts.template %} 62 | {% endfor %} 63 | {% endblock %} 64 | 65 | {% block after_related_objects %}{% endblock %} 66 | 67 | {% block submit_buttons_bottom %}{% au_submit_row %}{% endblock %} 68 | 69 | {% block admin_change_form_document_ready %} 70 | 77 | {% endblock %} 78 | 79 | {# JavaScript for prepopulated fields #} 80 | {% prepopulated_fields_js %} 81 | 82 |
83 |
84 | {% endblock %} 85 | -------------------------------------------------------------------------------- /admin_unchained/templates/admin_unchained/change_list.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n admin_urls static admin_unchained_list admin_list %} 3 | 4 | {% block title %}List of records{% endblock %} 5 | {% block branding %}

List of records

{% endblock %} 6 | 7 | {% block extrastyle %} 8 | {{ block.super }} 9 | 10 | {% if cl.formset %} 11 | 12 | {% endif %} 13 | {% if cl.formset or action_form %} 14 | 15 | {% endif %} 16 | {{ media.css }} 17 | {% if not actions_on_top and not actions_on_bottom %} 18 | 21 | {% endif %} 22 | {% endblock %} 23 | 24 | {% block extrahead %} 25 | {{ block.super }} 26 | {{ media.js }} 27 | {% endblock %} 28 | 29 | {% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-list{% endblock %} 30 | 31 | {% if not is_popup %} 32 | {% block breadcrumbs %} 33 | {% endblock %} 34 | {% endif %} 35 | 36 | {% block coltype %}flex{% endblock %} 37 | 38 | {% block content %} 39 |
40 | {% block object-tools %} 41 | 59 | {% endblock %} 60 | {% if cl.formset.errors %} 61 |

62 | {% if cl.formset.total_error_count == 1 %}{% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}{% endif %} 63 |

64 | {{ cl.formset.non_form_errors }} 65 | {% endif %} 66 |
67 | {% block search %}{% search_form cl %}{% endblock %} 68 | {% block date_hierarchy %}{% date_hierarchy cl %}{% endblock %} 69 | 70 | {% block filters %} 71 | {% if cl.has_filters %} 72 |
73 |

{% trans 'Filter' %}

74 | {% for spec in cl.filter_specs %}{% admin_list_filter cl spec %}{% endfor %} 75 |
76 | {% endif %} 77 | {% endblock %} 78 | 79 |
{% csrf_token %} 80 | {% if cl.formset %} 81 |
{{ cl.formset.management_form }}
82 | {% endif %} 83 | 84 | {% block result_list %} 85 | {% if action_form and actions_on_top and cl.show_admin_actions %}{% admin_actions %}{% endif %} 86 | {% result_list cl %} 87 | {% if action_form and actions_on_bottom and cl.show_admin_actions %}{% admin_actions %}{% endif %} 88 | {% endblock %} 89 | {% block pagination %}{% pagination cl %}{% endblock %} 90 |
91 |
92 |
93 | {% endblock %} 94 | -------------------------------------------------------------------------------- /admin_unchained/templatetags/.admin_unchained_list.py.swp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akulakov/django-admin-unchained/8bf9489267e6e9010fd6494ea2bb01f79a5b8856/admin_unchained/templatetags/.admin_unchained_list.py.swp -------------------------------------------------------------------------------- /admin_unchained/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akulakov/django-admin-unchained/8bf9489267e6e9010fd6494ea2bb01f79a5b8856/admin_unchained/templatetags/__init__.py -------------------------------------------------------------------------------- /admin_unchained/templatetags/admin_unchained_list.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import datetime 4 | import warnings 5 | 6 | from django.contrib.admin.templatetags.admin_urls import add_preserved_filters 7 | from django.contrib.admin.utils import ( 8 | display_for_field, display_for_value, get_fields_from_path, 9 | label_for_field, lookup_field, 10 | ) 11 | from django.contrib.admin.views.main import ( 12 | ALL_VAR, ORDER_VAR, PAGE_VAR, SEARCH_VAR, 13 | ) 14 | from django.core.exceptions import ObjectDoesNotExist 15 | from django.db import models 16 | from django.template import Library 17 | from django.template.loader import get_template 18 | from django.templatetags.static import static 19 | from django.urls import NoReverseMatch 20 | from django.utils import formats 21 | try: 22 | from django.utils.deprecation import RemovedInDjango20Warning 23 | except ImportError: 24 | pass 25 | from django.utils.encoding import force_text 26 | from django.utils.html import format_html 27 | from django.utils.safestring import mark_safe 28 | from django.utils.text import capfirst 29 | from django.utils.translation import ugettext as _ 30 | from django.template.context import Context 31 | 32 | register = Library() 33 | 34 | DOT = '.' 35 | 36 | @register.inclusion_tag('admin/au_submit_line.html', takes_context=True) 37 | def au_submit_row(context): 38 | """ 39 | Displays the row of buttons for delete and save. 40 | """ 41 | change = context['change'] 42 | is_popup = context['is_popup'] 43 | save_as = context['save_as'] 44 | show_save = context.get('show_save', True) 45 | show_save_and_continue = context.get('show_save_and_continue', True) 46 | ctx = Context(context) 47 | ctx.update({ 48 | 'show_delete_link': ( 49 | not is_popup and context['has_delete_permission'] and 50 | change and context.get('show_delete', True) 51 | ), 52 | 'show_save_as_new': not is_popup and change and save_as, 53 | 'show_save_and_add_another': ( 54 | context['has_add_permission'] and not is_popup and 55 | (not save_as or context['add']) 56 | ), 57 | 'show_save_and_continue': not is_popup and context['has_change_permission'] and show_save_and_continue, 58 | 'show_save': show_save, 59 | }) 60 | return ctx 61 | 62 | 63 | @register.simple_tag 64 | def paginator_number(cl, i): 65 | """ 66 | Generates an individual page index link in a paginated list. 67 | """ 68 | if i == DOT: 69 | return '... ' 70 | elif i == cl.page_num: 71 | return format_html('{} ', i + 1) 72 | else: 73 | return format_html('{} ', 74 | cl.get_query_string({PAGE_VAR: i}), 75 | mark_safe(' class="end"' if i == cl.paginator.num_pages - 1 else ''), 76 | i + 1) 77 | 78 | 79 | def result_headers(cl): 80 | """ 81 | Generates the list column headers. 82 | """ 83 | ordering_field_columns = cl.get_ordering_field_columns() 84 | for i, field_name in enumerate(cl.list_display): 85 | text, attr = label_for_field( 86 | field_name, cl.model, 87 | model_admin=cl.model_admin, 88 | return_attr=True 89 | ) 90 | if attr: 91 | field_name = _coerce_field_name(field_name, i) 92 | # Potentially not sortable 93 | 94 | # if the field is the action checkbox: no sorting and special class 95 | if field_name == 'action_checkbox': 96 | yield { 97 | "text": text, 98 | "class_attrib": mark_safe(' class="action-checkbox-column"'), 99 | "sortable": False, 100 | } 101 | continue 102 | 103 | admin_order_field = getattr(attr, "admin_order_field", None) 104 | if not admin_order_field: 105 | # Not sortable 106 | yield { 107 | "text": text, 108 | "class_attrib": format_html(' class="column-{}"', field_name), 109 | "sortable": False, 110 | } 111 | continue 112 | 113 | # OK, it is sortable if we got this far 114 | th_classes = ['sortable', 'column-{}'.format(field_name)] 115 | order_type = '' 116 | new_order_type = 'asc' 117 | sort_priority = 0 118 | sorted = False 119 | # Is it currently being sorted on? 120 | if i in ordering_field_columns: 121 | sorted = True 122 | order_type = ordering_field_columns.get(i).lower() 123 | sort_priority = list(ordering_field_columns).index(i) + 1 124 | th_classes.append('sorted %sending' % order_type) 125 | new_order_type = {'asc': 'desc', 'desc': 'asc'}[order_type] 126 | 127 | # build new ordering param 128 | o_list_primary = [] # URL for making this field the primary sort 129 | o_list_remove = [] # URL for removing this field from sort 130 | o_list_toggle = [] # URL for toggling order type for this field 131 | 132 | def make_qs_param(t, n): 133 | return ('-' if t == 'desc' else '') + str(n) 134 | 135 | for j, ot in ordering_field_columns.items(): 136 | if j == i: # Same column 137 | param = make_qs_param(new_order_type, j) 138 | # We want clicking on this header to bring the ordering to the 139 | # front 140 | o_list_primary.insert(0, param) 141 | o_list_toggle.append(param) 142 | # o_list_remove - omit 143 | else: 144 | param = make_qs_param(ot, j) 145 | o_list_primary.append(param) 146 | o_list_toggle.append(param) 147 | o_list_remove.append(param) 148 | 149 | if i not in ordering_field_columns: 150 | o_list_primary.insert(0, make_qs_param(new_order_type, i)) 151 | 152 | yield { 153 | "text": text, 154 | "sortable": True, 155 | "sorted": sorted, 156 | "ascending": order_type == "asc", 157 | "sort_priority": sort_priority, 158 | "url_primary": cl.get_query_string({ORDER_VAR: '.'.join(o_list_primary)}), 159 | "url_remove": cl.get_query_string({ORDER_VAR: '.'.join(o_list_remove)}), 160 | "url_toggle": cl.get_query_string({ORDER_VAR: '.'.join(o_list_toggle)}), 161 | "class_attrib": format_html(' class="{}"', ' '.join(th_classes)) if th_classes else '', 162 | } 163 | 164 | 165 | def _boolean_icon(field_val): 166 | icon_url = static('admin/img/icon-%s.svg' % 167 | {True: 'yes', False: 'no', None: 'unknown'}[field_val]) 168 | return format_html('{}', icon_url, field_val) 169 | 170 | 171 | def _coerce_field_name(field_name, field_index): 172 | """ 173 | Coerce a field_name (which may be a callable) to a string. 174 | """ 175 | if callable(field_name): 176 | if field_name.__name__ == '': 177 | return 'lambda' + str(field_index) 178 | else: 179 | return field_name.__name__ 180 | return field_name 181 | 182 | 183 | def items_for_result(cl, result, form): 184 | """ 185 | Generates the actual list of data. 186 | """ 187 | 188 | def link_in_col(is_first, field_name, cl): 189 | if cl.list_display_links is None: 190 | return False 191 | if is_first and not cl.list_display_links: 192 | return True 193 | return field_name in cl.list_display_links 194 | 195 | first = True 196 | pk = cl.lookup_opts.pk.attname 197 | for field_index, field_name in enumerate(cl.list_display): 198 | empty_value_display = cl.model_admin.get_empty_value_display() 199 | row_classes = ['field-%s' % _coerce_field_name(field_name, field_index)] 200 | try: 201 | f, attr, value = lookup_field(field_name, result, cl.model_admin) 202 | except ObjectDoesNotExist: 203 | result_repr = empty_value_display 204 | else: 205 | empty_value_display = getattr(attr, 'empty_value_display', empty_value_display) 206 | if f is None or f.auto_created: 207 | if field_name == 'action_checkbox': 208 | row_classes = ['action-checkbox'] 209 | allow_tags = getattr(attr, 'allow_tags', False) 210 | boolean = getattr(attr, 'boolean', False) 211 | result_repr = display_for_value(value, empty_value_display, boolean) 212 | if allow_tags: 213 | warnings.warn( 214 | "Deprecated allow_tags attribute used on field {}. " 215 | "Use django.utils.html.format_html(), format_html_join(), " 216 | "or django.utils.safestring.mark_safe() instead.".format(field_name), 217 | RemovedInDjango20Warning 218 | ) 219 | result_repr = mark_safe(result_repr) 220 | if isinstance(value, (datetime.date, datetime.time)): 221 | row_classes.append('nowrap') 222 | else: 223 | if isinstance(f.remote_field, models.ManyToOneRel): 224 | field_val = getattr(result, f.name) 225 | if field_val is None: 226 | result_repr = empty_value_display 227 | else: 228 | result_repr = field_val 229 | else: 230 | result_repr = display_for_field(value, f, empty_value_display) 231 | if isinstance(f, (models.DateField, models.TimeField, models.ForeignKey)): 232 | row_classes.append('nowrap') 233 | if force_text(result_repr) == '': 234 | result_repr = mark_safe(' ') 235 | row_class = mark_safe(' class="%s"' % ' '.join(row_classes)) 236 | # If list_display_links not defined, add the link tag to the first field 237 | if link_in_col(first, field_name, cl): 238 | table_tag = 'th' if first else 'td' 239 | first = False 240 | 241 | # Display link to the result's change_view if the url exists, else 242 | # display just the result's representation. 243 | try: 244 | url = cl.url_for_result(result) 245 | except NoReverseMatch: 246 | link_or_text = result_repr 247 | else: 248 | url = add_preserved_filters({'preserved_filters': cl.preserved_filters, 'opts': cl.opts}, url) 249 | # Convert the pk to something that can be used in Javascript. 250 | # Problem cases are long ints (23L) and non-ASCII strings. 251 | if cl.to_field: 252 | attr = str(cl.to_field) 253 | else: 254 | attr = pk 255 | value = result.serializable_value(attr) 256 | link_or_text = format_html( 257 | '{}', 258 | url, 259 | format_html( 260 | ' data-popup-opener="{}"', value 261 | ) if cl.is_popup else '', 262 | result_repr) 263 | 264 | yield format_html('<{}{}>{}', 265 | table_tag, 266 | row_class, 267 | link_or_text, 268 | table_tag) 269 | else: 270 | # By default the fields come from ModelAdmin.list_editable, but if we pull 271 | # the fields out of the form instead of list_editable custom admins 272 | # can provide fields on a per request basis 273 | if (form and field_name in form.fields and not ( 274 | field_name == cl.model._meta.pk.name and 275 | form[cl.model._meta.pk.name].is_hidden)): 276 | bf = form[field_name] 277 | result_repr = mark_safe(force_text(bf.errors) + force_text(bf)) 278 | yield format_html('{}', row_class, result_repr) 279 | if form and not form[cl.model._meta.pk.name].is_hidden: 280 | yield format_html('{}', force_text(form[cl.model._meta.pk.name])) 281 | 282 | 283 | class ResultList(list): 284 | # Wrapper class used to return items in a list_editable 285 | # changelist, annotated with the form object for error 286 | # reporting purposes. Needed to maintain backwards 287 | # compatibility with existing admin templates. 288 | def __init__(self, form, *items): 289 | self.form = form 290 | super(ResultList, self).__init__(*items) 291 | 292 | 293 | def results(cl): 294 | if cl.formset: 295 | for res, form in zip(cl.result_list, cl.formset.forms): 296 | yield ResultList(form, items_for_result(cl, res, form)) 297 | else: 298 | for res in cl.result_list: 299 | yield ResultList(None, items_for_result(cl, res, None)) 300 | 301 | 302 | def result_hidden_fields(cl): 303 | if cl.formset: 304 | for res, form in zip(cl.result_list, cl.formset.forms): 305 | if form[cl.model._meta.pk.name].is_hidden: 306 | yield mark_safe(force_text(form[cl.model._meta.pk.name])) 307 | 308 | 309 | @register.inclusion_tag("admin/change_list_results.html") 310 | def result_list(cl): 311 | """ 312 | Displays the headers and data list together 313 | """ 314 | headers = list(result_headers(cl)) 315 | num_sorted_fields = 0 316 | for h in headers: 317 | if h['sortable'] and h['sorted']: 318 | num_sorted_fields += 1 319 | return {'cl': cl, 320 | 'result_hidden_fields': list(result_hidden_fields(cl)), 321 | 'result_headers': headers, 322 | 'num_sorted_fields': num_sorted_fields, 323 | 'results': list(results(cl))} 324 | 325 | 326 | @register.inclusion_tag('admin/date_hierarchy.html') 327 | def date_hierarchy(cl): 328 | """ 329 | Displays the date hierarchy for date drill-down functionality. 330 | """ 331 | if cl.date_hierarchy: 332 | field_name = cl.date_hierarchy 333 | field = get_fields_from_path(cl.model, field_name)[-1] 334 | dates_or_datetimes = 'datetimes' if isinstance(field, models.DateTimeField) else 'dates' 335 | year_field = '%s__year' % field_name 336 | month_field = '%s__month' % field_name 337 | day_field = '%s__day' % field_name 338 | field_generic = '%s__' % field_name 339 | year_lookup = cl.params.get(year_field) 340 | month_lookup = cl.params.get(month_field) 341 | day_lookup = cl.params.get(day_field) 342 | 343 | def link(filters): 344 | return cl.get_query_string(filters, [field_generic]) 345 | 346 | if not (year_lookup or month_lookup or day_lookup): 347 | # select appropriate start level 348 | date_range = cl.queryset.aggregate(first=models.Min(field_name), 349 | last=models.Max(field_name)) 350 | if date_range['first'] and date_range['last']: 351 | if date_range['first'].year == date_range['last'].year: 352 | year_lookup = date_range['first'].year 353 | if date_range['first'].month == date_range['last'].month: 354 | month_lookup = date_range['first'].month 355 | 356 | if year_lookup and month_lookup and day_lookup: 357 | day = datetime.date(int(year_lookup), int(month_lookup), int(day_lookup)) 358 | return { 359 | 'show': True, 360 | 'back': { 361 | 'link': link({year_field: year_lookup, month_field: month_lookup}), 362 | 'title': capfirst(formats.date_format(day, 'YEAR_MONTH_FORMAT')) 363 | }, 364 | 'choices': [{'title': capfirst(formats.date_format(day, 'MONTH_DAY_FORMAT'))}] 365 | } 366 | elif year_lookup and month_lookup: 367 | days = cl.queryset.filter(**{year_field: year_lookup, month_field: month_lookup}) 368 | days = getattr(days, dates_or_datetimes)(field_name, 'day') 369 | return { 370 | 'show': True, 371 | 'back': { 372 | 'link': link({year_field: year_lookup}), 373 | 'title': str(year_lookup) 374 | }, 375 | 'choices': [{ 376 | 'link': link({year_field: year_lookup, month_field: month_lookup, day_field: day.day}), 377 | 'title': capfirst(formats.date_format(day, 'MONTH_DAY_FORMAT')) 378 | } for day in days] 379 | } 380 | elif year_lookup: 381 | months = cl.queryset.filter(**{year_field: year_lookup}) 382 | months = getattr(months, dates_or_datetimes)(field_name, 'month') 383 | return { 384 | 'show': True, 385 | 'back': { 386 | 'link': link({}), 387 | 'title': _('All dates') 388 | }, 389 | 'choices': [{ 390 | 'link': link({year_field: year_lookup, month_field: month.month}), 391 | 'title': capfirst(formats.date_format(month, 'YEAR_MONTH_FORMAT')) 392 | } for month in months] 393 | } 394 | else: 395 | years = getattr(cl.queryset, dates_or_datetimes)(field_name, 'year') 396 | return { 397 | 'show': True, 398 | 'choices': [{ 399 | 'link': link({year_field: str(year.year)}), 400 | 'title': str(year.year), 401 | } for year in years] 402 | } 403 | 404 | 405 | @register.inclusion_tag('admin/search_form.html') 406 | def search_form(cl): 407 | """ 408 | Displays a search form for searching the list. 409 | """ 410 | return { 411 | 'cl': cl, 412 | 'show_result_count': cl.result_count != cl.full_result_count, 413 | 'search_var': SEARCH_VAR 414 | } 415 | 416 | 417 | @register.simple_tag 418 | def admin_list_filter(cl, spec): 419 | tpl = get_template(spec.template) 420 | return tpl.render({ 421 | 'title': spec.title, 422 | 'choices': list(spec.choices(cl)), 423 | 'spec': spec, 424 | }) 425 | 426 | 427 | @register.inclusion_tag('admin/actions.html', takes_context=True) 428 | def admin_actions(context): 429 | """ 430 | Track the number of times the action field has been rendered on the page, 431 | so we know which value to use. 432 | """ 433 | context['action_index'] = context.get('action_index', -1) + 1 434 | return context 435 | -------------------------------------------------------------------------------- /admin_unchained/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /admin_unchained/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | from django.views.generic import ListView, TemplateView 4 | from django.contrib.admin.views.main import ChangeList 5 | try: 6 | from django.core.urlresolvers import reverse_lazy 7 | except ImportError: 8 | from django.urls import reverse_lazy 9 | from django.contrib.admin import helpers, widgets 10 | from django.shortcuts import render, reverse 11 | from django.contrib.admin import actions 12 | 13 | 14 | class AUChangeList(ChangeList): 15 | def url_for_result(self, result): 16 | return self.model_admin.get_change_url(result, self.pk_attname) 17 | 18 | class MockAdminSite: 19 | name = '' 20 | empty_value_display = '' 21 | actions = () 22 | 23 | def __init__(self, *args, **kwargs): 24 | self._registry = {} 25 | 26 | def each_context(self, request): 27 | return {} 28 | 29 | def get_action(self, request): 30 | return 31 | 32 | def is_registered(self, model): 33 | return True 34 | 35 | 36 | class AUAddOrChangeView(TemplateView): 37 | model = None 38 | pk_url_kwarg = "object_id" 39 | fields = [] 40 | success_url = None 41 | admin_class = None 42 | delete_url_name = None 43 | 44 | def dispatch(self, request, *args, **kwargs): 45 | if request.method in ('GET', 'POST'): 46 | model_admin = self.admin_class(self.model, MockAdminSite()) 47 | model_admin.redirect_url = str(self.success_url) 48 | pk = self.kwargs.get('pk') 49 | delete_url = reverse(self.delete_url_name, kwargs=dict(pk=pk)) 50 | extra_context = dict(show_history=model_admin.show_history, delete_url=delete_url) 51 | if pk: 52 | return model_admin.change_view(request, pk, extra_context=extra_context) 53 | else: 54 | return model_admin.add_view(request, extra_context=extra_context) 55 | 56 | class AUDeleteView(TemplateView): 57 | model = None 58 | pk_url_kwarg = "object_id" 59 | fields = [] 60 | success_url = None 61 | admin_class = None 62 | 63 | def dispatch(self, request, *args, **kwargs): 64 | if request.method in ('GET', 'POST'): 65 | model_admin = self.admin_class(self.model, MockAdminSite()) 66 | model_admin.redirect_url = str(self.success_url) 67 | pk = self.kwargs.get('pk') 68 | return model_admin.delete_view(request, pk) 69 | 70 | 71 | class AUListView(ListView): 72 | model = None 73 | pk_url_kwarg = "object_id" 74 | fields = [] 75 | admin_class = None 76 | template_name = 'admin_unchained/change_list.html' 77 | 78 | def get_context_data(self, **kwargs): 79 | context = super(AUListView, self).get_context_data(**kwargs) 80 | 81 | model_admin = self.admin_class(self.model, MockAdminSite()) 82 | opts = model_admin.model._meta 83 | request = self.request 84 | 85 | list_display = model_admin.get_list_display(request) 86 | list_display_links = model_admin.get_list_display_links(request, list_display) 87 | list_filter = model_admin.get_list_filter(request) 88 | search_fields = model_admin.get_search_fields(request) 89 | list_select_related = model_admin.get_list_select_related(request) 90 | 91 | actions = model_admin.get_actions(request) 92 | media = model_admin.media 93 | if actions: 94 | action_form = model_admin.action_form(auto_id=None) 95 | action_form.fields['action'].choices = model_admin.get_action_choices(request) 96 | media += action_form.media 97 | else: 98 | action_form = None 99 | 100 | if actions: 101 | list_display = ['action_checkbox'] + list(list_display) 102 | 103 | cl = AUChangeList( 104 | request, model_admin.model, list_display, 105 | list_display_links, list_filter, model_admin.date_hierarchy, 106 | search_fields, list_select_related, model_admin.list_per_page, 107 | model_admin.list_max_show_all, model_admin.list_editable, model_admin, 108 | ) 109 | 110 | self.model_admin = model_admin 111 | self.actions = actions 112 | 113 | cl.formset=None 114 | context.update({ 115 | 'opts': opts, 116 | 'cl': cl, 117 | 'action_form': action_form, 118 | 'add_url': model_admin.get_add_url(request), 119 | 'actions_on_top': model_admin.actions_on_top, 120 | 'actions_on_bottom': model_admin.actions_on_bottom, 121 | 'has_add_permission': model_admin.has_add_permission(request), 122 | }) 123 | 124 | return context 125 | 126 | def post(self, request, *args, **kwargs): 127 | self.object_list = self.model.objects.all() 128 | data = self.get_context_data() 129 | action_failed = False 130 | cl = data.get('cl') 131 | selected = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME) 132 | 133 | # Actions with no confirmation 134 | if (self.actions and 'index' in request.POST and '_save' not in request.POST): 135 | if selected: 136 | response = self.model_admin.response_action(request, queryset=cl.get_queryset(request)) 137 | if response: 138 | return response 139 | else: 140 | action_failed = True 141 | else: 142 | msg = _("Items must be selected in order to perform " 143 | "actions on them. No items have been changed.") 144 | self.model_admin.message_user(request, msg, messages.WARNING) 145 | action_failed = True 146 | 147 | # Actions with confirmation 148 | if (self.actions and helpers.ACTION_CHECKBOX_NAME in request.POST and 149 | 'index' not in request.POST and '_save' not in request.POST): 150 | if selected: 151 | response = self.model_admin.response_action(request, queryset=cl.get_queryset(request)) 152 | if response: 153 | return response 154 | else: 155 | action_failed = True 156 | 157 | if action_failed: 158 | # Redirect back to the changelist page to avoid resubmitting the 159 | # form if the user refreshes the browser or uses the "No, take 160 | # me back" button on the action confirmation page. 161 | return HttpResponseRedirect(request.get_full_path()) 162 | 163 | -------------------------------------------------------------------------------- /example_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akulakov/django-admin-unchained/8bf9489267e6e9010fd6494ea2bb01f79a5b8856/example_app/__init__.py -------------------------------------------------------------------------------- /example_app/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Author, Book 3 | 4 | class AuthorAdmin(admin.ModelAdmin): 5 | pass 6 | 7 | class BookAdmin(admin.ModelAdmin): 8 | list_display = ('pk', 'title', 'published') 9 | 10 | admin.site.register(Author, AuthorAdmin) 11 | admin.site.register(Book, BookAdmin) 12 | -------------------------------------------------------------------------------- /example_app/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class App1Config(AppConfig): 5 | name = 'app1' 6 | -------------------------------------------------------------------------------- /example_app/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11 on 2018-02-16 19:54 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Author', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('first_name', models.CharField(max_length=50)), 21 | ('last_name', models.CharField(max_length=50)), 22 | ('born', models.DateField()), 23 | ], 24 | ), 25 | migrations.CreateModel( 26 | name='Book', 27 | fields=[ 28 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 29 | ('title', models.CharField(max_length=150)), 30 | ('published', models.DateField()), 31 | ('num_pages', models.IntegerField()), 32 | ('authors', models.ManyToManyField(to='app1.Author')), 33 | ], 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /example_app/migrations/0002_book_out_of_print.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2018-02-16 21:36 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('app1', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='book', 17 | name='out_of_print', 18 | field=models.BooleanField(default=False), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /example_app/migrations/0003_book_main_author.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.7 on 2018-02-18 01:34 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('app1', '0002_book_out_of_print'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='book', 18 | name='main_author', 19 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='booklist', to='app1.Author'), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /example_app/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akulakov/django-admin-unchained/8bf9489267e6e9010fd6494ea2bb01f79a5b8856/example_app/migrations/__init__.py -------------------------------------------------------------------------------- /example_app/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | class Author(models.Model): 4 | first_name = models.CharField(max_length=50) 5 | last_name = models.CharField(max_length=50) 6 | born = models.DateField() 7 | 8 | def __str__(self): 9 | return self.last_name 10 | 11 | class Book(models.Model): 12 | main_author = models.ForeignKey(Author, related_name='booklist', blank=True, null=True, on_delete=models.CASCADE) 13 | authors = models.ManyToManyField(Author) 14 | title = models.CharField(max_length=150) 15 | published = models.DateField() 16 | num_pages = models.IntegerField() 17 | out_of_print = models.BooleanField(default=False) 18 | 19 | def get_authors(self): 20 | return ', '.join((str(a) for a in self.authors.all())) 21 | get_authors.short_description = 'authors' 22 | 23 | def __str__(self): 24 | return self.title 25 | -------------------------------------------------------------------------------- /example_app/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /example_app/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | url(r'^authors/$', views.AuthorListView.as_view(), name='authors'), 7 | 8 | url(r'^author/(?P.+)/change/$', 9 | views.AuthorAddOrChangeView.as_view(), 10 | name='author_change'), 11 | 12 | url(r'^$', views.BookListView.as_view(), name='books'), 13 | 14 | url(r'^(?P.+)/change/$', 15 | views.BookAddOrChangeView.as_view(), 16 | name='book_change'), 17 | 18 | url(r'^(?P.+)/delete/$', 19 | views.BookDeleteView.as_view(), 20 | name='book_delete'), 21 | url(r'^add/$', 22 | views.BookAddOrChangeView.as_view(), 23 | name='book_add'), 24 | ] 25 | -------------------------------------------------------------------------------- /example_app/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | try: 3 | from django.core.urlresolvers import reverse_lazy 4 | except ImportError: 5 | from django.urls import reverse_lazy 6 | from django.contrib import admin 7 | 8 | from admin_unchained.admin import AUAdmin 9 | from admin_unchained.views import AUListView, AUAddOrChangeView, AUDeleteView 10 | 11 | from .models import Book, Author 12 | 13 | def make_published(modeladmin, request, queryset): 14 | print ('In make_published()', queryset) 15 | make_published.short_description = 'Set books as published' 16 | 17 | # class AuthorInline(admin.StackedInline): 18 | # model = Author 19 | class BookInline(admin.StackedInline): 20 | model = Book 21 | extra = 1 22 | 23 | class AuthorAdmin(AUAdmin): 24 | model = Author 25 | list_display = ('pk', 'last_name') 26 | list_per_page = 20 27 | add_url_name = 'book_add' 28 | # change_url_name = 'book_change' 29 | raw_id_fields = () 30 | inlines = [BookInline] 31 | 32 | class BookAdmin(AUAdmin): 33 | model = Book 34 | list_display = ('pk', 'title', 'published', 'get_authors') 35 | search_fields = ('title', 'authors__last_name') 36 | list_filter = ('published', 'authors') 37 | actions = (make_published,) 38 | actions_on_top = True 39 | list_per_page = 20 40 | add_url_name = 'book_add' 41 | change_url_name = 'book_change' 42 | raw_id_fields = () 43 | # inlines = [AuthorInline] 44 | 45 | class AuthorListView(AUListView): 46 | model = Author 47 | admin_class = AuthorAdmin 48 | 49 | class BookListView(AUListView): 50 | model = Book 51 | admin_class = BookAdmin 52 | 53 | class BookAddOrChangeView(AUAddOrChangeView): 54 | model = Book 55 | admin_class = BookAdmin 56 | success_url = reverse_lazy('books') 57 | delete_url_name = 'book_delete' 58 | 59 | class AuthorAddOrChangeView(AUAddOrChangeView): 60 | model = Author 61 | admin_class = AuthorAdmin 62 | success_url = reverse_lazy('authors') 63 | 64 | class BookDeleteView(AUDeleteView): 65 | model = Book 66 | admin_class = BookAdmin 67 | success_url = reverse_lazy('books') 68 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproj.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | # The above import may fail for some other reason. Ensure that the 11 | # issue is really that Django is missing to avoid masking other 12 | # exceptions on Python 2. 13 | try: 14 | import django 15 | except ImportError: 16 | raise ImportError( 17 | "Couldn't import Django. Are you sure it's installed and " 18 | "available on your PYTHONPATH environment variable? Did you " 19 | "forget to activate a virtual environment?" 20 | ) 21 | raise 22 | execute_from_command_line(sys.argv) 23 | --------------------------------------------------------------------------------