├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── django_admin_row_actions ├── __init__.py ├── admin.py ├── components.py ├── locale │ ├── ru_RU │ │ └── LC_MESSAGES │ │ │ └── django.po │ └── zh_Hant │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── static │ ├── css │ │ └── jquery.dropdown.min.css │ └── js │ │ ├── jquery.dropdown.js │ │ └── jquery.dropdown.min.js ├── templates │ └── django_admin_row_actions │ │ └── dropdown.html ├── utils.py └── views.py └── pyproject.toml /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | 59 | # IDE/Editor stuff 60 | .idea/* 61 | 62 | .DS_Store 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Andy Baker 4 | Included is parts of jquery-dropdown: Copyright: A Beautiful Site, LLC 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | recursive-include django_admin_row_actions/static * 4 | recursive-include django_admin_row_actions/templates * 5 | recursive-include django_admin_row_actions/locale * 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Django Admin Row Actions 2 | ======================== 3 | 4 | Allows you to easily define a drop-down 'actions' menu that is appended as the final column in your model's changelist and perform actions on that row. 5 | 6 | Menu items can call urls or methods, can be disabled, have tooltips, etc. 7 | 8 | I've extracted this from code written for http://hireablehq.com/. The admin there has Bootstrap available but I've modified this version to use a standalone jQuery dropdown. 9 | 10 | 11 | Installation 12 | ============ 13 | 14 | 1. Install from PyPI: 15 | 16 | ```bash 17 | pip install django-admin-row-actions 18 | ``` 19 | 20 | or install using pip and git: 21 | 22 | ```bash 23 | pip install git+https://github.com/DjangoAdminHackers/django-admin-row-actions.git 24 | ``` 25 | 26 | 2. Add to INSTALLED_APPS: 27 | 28 | ```python 29 | INSTALLED_APPS = [ 30 | ... 31 | 'django_admin_row_actions', 32 | ... 33 | ] 34 | ``` 35 | 36 | 3. Add the mixin to your ModelAdmin: 37 | 38 | ```python 39 | from django_admin_row_actions import AdminRowActionsMixin 40 | ... 41 | 42 | class ExampleAdmin(AdminRowActionsMixin, admin.ModelAdmin): 43 | ... 44 | ``` 45 | 46 | 4. Define a `get_row_actions` method on your ModelAdmin 47 | 48 | ```python 49 | def get_row_actions(self, obj): 50 | row_actions = [ 51 | { 52 | 'label': 'Edit', 53 | 'url': obj.get_edit_url(), 54 | 'enabled': obj.status is not 'cancelled', 55 | }, { 56 | 'label': 'Download PDF', 57 | 'url': obj.get_pdf_url(), 58 | }, { 59 | 'label': 'Convert', 60 | 'url': reverse('convert_stuff', args=[obj.id]), 61 | 'tooltip': 'Convert stuff', 62 | }, { 63 | 'divided': True, 64 | 'label': 'Cancel', 65 | 'action': 'mark_cancelled', 66 | }, 67 | ] 68 | row_actions += super(ExampleAdmin, self).get_row_actions(obj) 69 | return row_actions 70 | ``` 71 | 72 | The first three menu items are simple links to a url you provide by whatever means you choose. 73 | 74 | The final one defines 'action' instead of 'url'. This should be the name of a callable on your `ModelAdmin` or `Model` class (similar to [ModelAdmin.list_display](https://docs.djangoproject.com/en/1.8/ref/contrib/admin/#django.contrib.admin.ModelAdmin.list_display)). 75 | 76 | You can add mouseover tooltips to each individual actions with the 'tooltip' dictionary key, and enable/disable individual actions for each individual object with the 'enabled'. 77 | 78 | Special option 'divided' can be passed to any item to display horizontal rule above it. 79 | 80 | 81 | Credits 82 | ======= 83 | 84 | Inspired (and code based on): [django-object-actions](https://github.com/crccheck/django-object-actions) 85 | 86 | Includes parts of [jquery-dropdown](http://labs.abeautifulsite.net/jquery-dropdown/); credits go to Cory LaViska. 87 | -------------------------------------------------------------------------------- /django_admin_row_actions/__init__.py: -------------------------------------------------------------------------------- 1 | from .admin import AdminRowActionsMixin 2 | from .utils import takes_instance_or_queryset 3 | -------------------------------------------------------------------------------- /django_admin_row_actions/admin.py: -------------------------------------------------------------------------------- 1 | from django import VERSION 2 | from django import forms 3 | from django.urls import re_path 4 | from django.utils.safestring import mark_safe 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | from .components import Dropdown 8 | from .views import ModelToolsView 9 | 10 | 11 | class AdminRowActionsMixin: 12 | 13 | """ModelAdmin mixin to add row actions just like adding admin actions""" 14 | 15 | rowactions = [] 16 | _named_row_actions = {} 17 | 18 | @property 19 | def media(self): 20 | return super().media + forms.Media( 21 | css={'all': ["css/jquery.dropdown.min.css"]}, 22 | js=["js/jquery.dropdown.min.js"], 23 | ) 24 | 25 | def get_list_display(self, request): 26 | self._request = request 27 | list_display = super().get_list_display(request) 28 | if '_row_actions' not in list_display: 29 | list_display += ('_row_actions',) 30 | return list_display 31 | 32 | def get_actions_list(self, obj, includePk=True): 33 | 34 | def to_dict(tool_name): 35 | return dict( 36 | name=tool_name, 37 | label=getattr(tool, 'label', tool_name).replace('_', ' ').title(), 38 | ) 39 | 40 | items = [] 41 | 42 | row_actions = self.get_row_actions(obj) 43 | url_prefix = f'{obj.pk if includePk else ""}/' 44 | 45 | for tool in row_actions: 46 | if isinstance(tool, str): # Just a str naming a callable 47 | tool_dict = to_dict(tool) 48 | items.append({ 49 | 'label': tool_dict['label'], 50 | 'url': f'{url_prefix}rowactions/{tool}/', 51 | 'method': tool_dict.get('POST', 'GET') 52 | }) 53 | 54 | elif isinstance(tool, dict): # A parameter dict 55 | tool['enabled'] = tool.get('enabled', True) 56 | if 'action' in tool: # If 'action' is specified then use our generic url in preference to 'url' value 57 | if isinstance(tool['action'], tuple): 58 | self._named_row_actions[tool['action'][0]] = tool['action'][1] 59 | tool['url'] = f'{url_prefix}rowactions/{tool["action"][0]}/' 60 | else: 61 | tool['url'] = f'{url_prefix}rowactions/{tool["action"]}/' 62 | items.append(tool) 63 | 64 | return items 65 | 66 | def _row_actions(self, obj): 67 | 68 | items = self.get_actions_list(obj) 69 | if items: 70 | html = Dropdown( 71 | label=_("Actions"), 72 | items=items, 73 | request=getattr(self, '_request') 74 | ).render() 75 | if VERSION < (1, 9): 76 | return html 77 | else: 78 | return mark_safe(html) 79 | return '' 80 | _row_actions.short_description = '' 81 | 82 | if VERSION < (1, 9): 83 | _row_actions.allow_tags = True 84 | 85 | def get_tool_urls(self): 86 | 87 | """Gets the url patterns that route each tool to a special view""" 88 | 89 | my_urls = [ 90 | re_path(r'^(?P[0-9a-f-]+)/rowactions/(?P\w+)/$', 91 | self.admin_site.admin_view(ModelToolsView.as_view(model=self.model)) 92 | ) 93 | ] 94 | return my_urls 95 | 96 | ################################### 97 | # EXISTING ADMIN METHODS MODIFIED # 98 | ################################### 99 | 100 | def get_urls(self): 101 | 102 | """Prepends `get_urls` with our own patterns""" 103 | 104 | urls = super().get_urls() 105 | return self.get_tool_urls() + urls 106 | 107 | ################## 108 | # CUSTOM METHODS # 109 | ################## 110 | 111 | def get_row_actions(self, obj): 112 | return getattr(self, 'rowactions', False) or [] 113 | 114 | def get_change_actions(self, request, object_id, form_url): 115 | 116 | # If we're also using django_object_actions 117 | # then try to reuse row actions as object actions 118 | 119 | change_actions = super().get_change_actions(request, object_id, form_url) 120 | 121 | # Make this reuse opt-in 122 | if getattr(self, 'reuse_row_actions_as_object_actions', False): 123 | 124 | obj = self.model.objects.get(pk=object_id) 125 | row_actions = self.get_actions_list(obj, False) if obj else [] 126 | 127 | for row_action in row_actions: 128 | # Object actions only supports strings as action indentifiers 129 | if isinstance(row_action, str): 130 | change_actions.append(row_action) 131 | elif isinstance(row_action, dict): 132 | if isinstance(row_action['action'], str): 133 | change_actions.append(row_action['action']) 134 | elif isinstance(row_action['action'], tuple): 135 | change_actions.append(str(row_action['action'][1])) 136 | return change_actions 137 | -------------------------------------------------------------------------------- /django_admin_row_actions/components.py: -------------------------------------------------------------------------------- 1 | import weakref 2 | from django.template.loader import render_to_string 3 | 4 | 5 | class BaseComponent: 6 | 7 | template = None 8 | instances = [] 9 | 10 | def __init__(self, **kwargs): 11 | self.__class__.instances.append(weakref.proxy(self)) 12 | self.request = kwargs.pop('request') 13 | self.context = kwargs 14 | self.context['dom_id'] = self.get_unique_id() 15 | 16 | @classmethod 17 | def get_unique_id(cls): 18 | return f'{cls.__name__.lower()}-{len(cls.instances)}' 19 | 20 | def render(self): 21 | return render_to_string( 22 | self.template, 23 | self.context, 24 | request=self.request 25 | ) 26 | 27 | def __str__(self): 28 | return self.render() 29 | 30 | 31 | class Dropdown(BaseComponent): 32 | template = 'django_admin_row_actions/dropdown.html' 33 | -------------------------------------------------------------------------------- /django_admin_row_actions/locale/ru_RU/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2017-03-04 17:43+0300\n" 11 | "PO-Revision-Date: 2017-03-04 17:44+0300\n" 12 | "Language: ru_RU\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | "Last-Translator: \n" 17 | "Language-Team: \n" 18 | "X-Generator: Poedit 1.8.7.1\n" 19 | 20 | #: admin.py:80 21 | msgid "Actions" 22 | msgstr "Действия" 23 | -------------------------------------------------------------------------------- /django_admin_row_actions/locale/zh_Hant/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DjangoAdminHackers/django-admin-row-actions/f3f44a869838330575b7f1b7c270803535c18b42/django_admin_row_actions/locale/zh_Hant/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /django_admin_row_actions/locale/zh_Hant/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: django-admin-row-actions\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2017-03-04 17:43+0300\n" 11 | "PO-Revision-Date: 2017-06-05 15:28+0800\n" 12 | "Language: zh_TW\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | "Last-Translator: Xaver Y.R. Chen \n" 17 | "Language-Team: \n" 18 | "X-Generator: Poedit 1.8.12\n" 19 | "Plural-Forms: nplurals=1; plural=0;\n" 20 | "X-Poedit-SourceCharset: UTF-8\n" 21 | 22 | #: admin.py:80 23 | msgid "Actions" 24 | msgstr "動作" 25 | -------------------------------------------------------------------------------- /django_admin_row_actions/static/css/jquery.dropdown.min.css: -------------------------------------------------------------------------------- 1 | .jq-dropdown { 2 | position: absolute; 3 | z-index: 1039; 4 | display: none 5 | } 6 | 7 | .jq-dropdown .jq-dropdown-menu, .jq-dropdown .jq-dropdown-panel { 8 | min-width: 160px; 9 | max-width: 360px; 10 | list-style: none; 11 | background: #fff; 12 | border: solid 1px #ddd; 13 | border-radius: 4px; 14 | box-shadow: 0 5px 10px rgba(0, 0, 0, .2); 15 | overflow: visible; 16 | padding: 4px 0; 17 | margin: 0 18 | } 19 | 20 | .jq-dropdown .jq-dropdown-panel { 21 | padding: 10px 22 | } 23 | 24 | .jq-dropdown.jq-dropdown-tip { 25 | margin-top: 8px 26 | } 27 | 28 | .jq-dropdown.jq-dropdown-tip:before { 29 | position: absolute; 30 | top: -6px; 31 | left: 9px; 32 | content: ''; 33 | border-left: 7px solid transparent; 34 | border-right: 7px solid transparent; 35 | border-bottom: 7px solid #ddd; 36 | display: inline-block 37 | } 38 | 39 | .jq-dropdown.jq-dropdown-tip:after { 40 | position: absolute; 41 | top: -5px; 42 | left: 10px; 43 | content: ''; 44 | border-left: 6px solid transparent; 45 | border-right: 6px solid transparent; 46 | border-bottom: 6px solid #fff; 47 | display: inline-block 48 | } 49 | 50 | .jq-dropdown.jq-dropdown-tip.jq-dropdown-anchor-right:before { 51 | left: auto; 52 | right: 9px 53 | } 54 | 55 | .jq-dropdown.jq-dropdown-tip.jq-dropdown-anchor-right:after { 56 | left: auto; 57 | right: 10px 58 | } 59 | 60 | .jq-dropdown.jq-dropdown-scroll .jq-dropdown-menu, .jq-dropdown.jq-dropdown-scroll .jq-dropdown-panel { 61 | max-height: 180px; 62 | overflow: auto 63 | } 64 | 65 | .jq-dropdown .jq-dropdown-menu li { 66 | list-style: none; 67 | padding: 0 0; 68 | margin: 0; 69 | line-height: 18px 70 | } 71 | 72 | .jq-dropdown .jq-dropdown-menu label, .jq-dropdown .jq-dropdown-menu li > a { 73 | display: block; 74 | color: inherit; 75 | text-decoration: none; 76 | line-height: 18px; 77 | padding: 3px 15px; 78 | margin: 0; 79 | white-space: nowrap 80 | } 81 | 82 | .jq-dropdown .jq-dropdown-menu label:hover, .jq-dropdown .jq-dropdown-menu li > a:hover { 83 | background-color: #f2f2f2; 84 | color: inherit; 85 | cursor: pointer 86 | } 87 | 88 | .jq-dropdown .jq-dropdown-menu .jq-dropdown-divider { 89 | font-size: 1px; 90 | border-top: solid 1px #e5e5e5; 91 | padding: 0; 92 | margin: 5px 0 93 | } 94 | .jq-dropdown-caret { 95 | display: inline-block; 96 | width: 0; 97 | height: 0; 98 | margin-left: 5px; 99 | vertical-align: middle; 100 | border-top: 4px solid; 101 | border-right: 4px solid transparent; 102 | border-left: 4px solid transparent; 103 | } 104 | 105 | /* Extra rule to allow disabled menu items */ 106 | 107 | .jq-dropdown .jq-dropdown-menu li > span.jq-disabled { 108 | display: block; 109 | color: #CCC; 110 | text-decoration: none; 111 | line-height: 18px; 112 | padding: 3px 15px; 113 | margin: 0; 114 | white-space: nowrap; 115 | } -------------------------------------------------------------------------------- /django_admin_row_actions/static/js/jquery.dropdown.js: -------------------------------------------------------------------------------- 1 | /* 2 | * jQuery Dropdown: A simple dropdown plugin 3 | * 4 | * Contribute: https://github.com/claviska/jquery-dropdown 5 | * 6 | * @license: MIT license: http://opensource.org/licenses/MIT 7 | * 8 | */ 9 | (function(jQuery) { 10 | if (jQuery) (function ($) { 11 | 12 | $.extend($.fn, { 13 | jqDropdown: function (method, data) { 14 | 15 | switch (method) { 16 | case 'show': 17 | show(null, $(this)); 18 | return $(this); 19 | case 'hide': 20 | hide(); 21 | return $(this); 22 | case 'attach': 23 | return $(this).attr('data-jq-dropdown', data); 24 | case 'detach': 25 | hide(); 26 | return $(this).removeAttr('data-jq-dropdown'); 27 | case 'disable': 28 | return $(this).addClass('jq-dropdown-disabled'); 29 | case 'enable': 30 | hide(); 31 | return $(this).removeClass('jq-dropdown-disabled'); 32 | } 33 | 34 | } 35 | }); 36 | 37 | function show(event, object) { 38 | 39 | var trigger = event ? $(this) : object, 40 | jqDropdown = $(trigger.attr('data-jq-dropdown')), 41 | isOpen = trigger.hasClass('jq-dropdown-open'); 42 | 43 | // In some cases we don't want to show it 44 | if (event) { 45 | if ($(event.target).hasClass('jq-dropdown-ignore')) return; 46 | 47 | event.preventDefault(); 48 | event.stopPropagation(); 49 | } else { 50 | if (trigger !== object.target && $(object.target).hasClass('jq-dropdown-ignore')) return; 51 | } 52 | hide(); 53 | 54 | if (isOpen || trigger.hasClass('jq-dropdown-disabled')) return; 55 | 56 | // Show it 57 | trigger.addClass('jq-dropdown-open'); 58 | jqDropdown 59 | .data('jq-dropdown-trigger', trigger) 60 | .show(); 61 | 62 | // Position it (TODO: bug) 63 | // position(); 64 | 65 | // Trigger the show callback 66 | jqDropdown 67 | .trigger('show', { 68 | jqDropdown: jqDropdown, 69 | trigger: trigger 70 | }); 71 | 72 | } 73 | 74 | function hide(event) { 75 | 76 | // In some cases we don't hide them 77 | var targetGroup = event ? $(event.target).parents().addBack() : null; 78 | 79 | // Are we clicking anywhere in a jq-dropdown? 80 | if (targetGroup && targetGroup.is('.jq-dropdown')) { 81 | // Is it a jq-dropdown menu? 82 | if (targetGroup.is('.jq-dropdown-menu')) { 83 | // Did we click on an option? If so close it. 84 | if (!targetGroup.is('A')) return; 85 | } else { 86 | // Nope, it's a panel. Leave it open. 87 | return; 88 | } 89 | } 90 | 91 | // Hide any jq-dropdown that may be showing 92 | $(document).find('.jq-dropdown:visible').each(function () { 93 | var jqDropdown = $(this); 94 | jqDropdown 95 | .hide() 96 | .removeData('jq-dropdown-trigger') 97 | .trigger('hide', { jqDropdown: jqDropdown }); 98 | }); 99 | 100 | // Remove all jq-dropdown-open classes 101 | $(document).find('.jq-dropdown-open').removeClass('jq-dropdown-open'); 102 | 103 | } 104 | 105 | function position() { 106 | 107 | var jqDropdown = $('.jq-dropdown:visible').eq(0), 108 | trigger = jqDropdown.data('jq-dropdown-trigger'), 109 | hOffset = trigger ? parseInt(trigger.attr('data-horizontal-offset') || 0, 10) : null, 110 | vOffset = trigger ? parseInt(trigger.attr('data-vertical-offset') || 0, 10) : null; 111 | 112 | if (jqDropdown.length === 0 || !trigger) return; 113 | 114 | // Position the jq-dropdown relative-to-parent... 115 | if (jqDropdown.hasClass('jq-dropdown-relative')) { 116 | jqDropdown.css({ 117 | left: jqDropdown.hasClass('jq-dropdown-anchor-right') ? 118 | trigger.position().left - (jqDropdown.outerWidth(true) - trigger.outerWidth(true)) - parseInt(trigger.css('margin-right'), 10) + hOffset : 119 | trigger.position().left + parseInt(trigger.css('margin-left'), 10) + hOffset, 120 | top: trigger.position().top + trigger.outerHeight(true) - parseInt(trigger.css('margin-top'), 10) + vOffset 121 | }); 122 | } else { 123 | // ...or relative to document 124 | jqDropdown.css({ 125 | left: jqDropdown.hasClass('jq-dropdown-anchor-right') ? 126 | trigger.offset().left - (jqDropdown.outerWidth() - trigger.outerWidth()) + hOffset : trigger.offset().left + hOffset, 127 | top: trigger.offset().top + trigger.outerHeight() + vOffset 128 | }); 129 | } 130 | } 131 | 132 | $(document).on('click.jq-dropdown', '[data-jq-dropdown]', show); 133 | $(document).on('click.jq-dropdown', hide); 134 | // TODO: bug 135 | // $(window).on('resize', position); 136 | 137 | })(jQuery); 138 | })(django.jQuery); -------------------------------------------------------------------------------- /django_admin_row_actions/static/js/jquery.dropdown.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | * jQuery Dropdown: A simple dropdown plugin 3 | * 4 | * Contribute: https://github.com/claviska/jquery-dropdown 5 | * 6 | * @license: MIT license: http://opensource.org/licenses/MIT 7 | * 8 | */ 9 | !function(o){o&&function(t){function r(o,d){var r=o?t(this):d,n=t(r.attr("data-jq-dropdown")),e=r.hasClass("jq-dropdown-open");if(o){if(t(o.target).hasClass("jq-dropdown-ignore"))return;o.preventDefault(),o.stopPropagation()}else if(r!==d.target&&t(d.target).hasClass("jq-dropdown-ignore"))return;a(),e||r.hasClass("jq-dropdown-disabled")||(r.addClass("jq-dropdown-open"),n.data("jq-dropdown-trigger",r).show(),n.trigger("show",{jqDropdown:n,trigger:r}))}function a(o){var d=o?t(o.target).parents().addBack():null;if(d&&d.is(".jq-dropdown")){if(!d.is(".jq-dropdown-menu"))return;if(!d.is("A"))return}t(document).find(".jq-dropdown:visible").each(function(){var o=t(this);o.hide().removeData("jq-dropdown-trigger").trigger("hide",{jqDropdown:o})}),t(document).find(".jq-dropdown-open").removeClass("jq-dropdown-open")}t.extend(t.fn,{jqDropdown:function(o,d){switch(o){case"show":return r(null,t(this)),t(this);case"hide":return a(),t(this);case"attach":return t(this).attr("data-jq-dropdown",d);case"detach":return a(),t(this).removeAttr("data-jq-dropdown");case"disable":return t(this).addClass("jq-dropdown-disabled");case"enable":return a(),t(this).removeClass("jq-dropdown-disabled")}}}),t(document).on("click.jq-dropdown","[data-jq-dropdown]",r),t(document).on("click.jq-dropdown",a)}(o)}(django.jQuery); -------------------------------------------------------------------------------- /django_admin_row_actions/templates/django_admin_row_actions/dropdown.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {{ label }}  3 |
4 |
    5 | {% for item in items %} 6 | {% if item.divided %} 7 |
  • 8 | {% endif %} 9 |
  • 10 | {% if item.enabled %} 11 | {% if item.method == 'POST' %} 12 |
    13 | {% csrf_token %} 14 | 15 |
    16 | {% else %} 17 | 18 | {{ item.label }} 19 | 20 | {% endif %} 21 | {% else %} 22 | {{ item.label }} 23 | {% endif %} 24 |
  • 25 | {% endfor %} 26 |
27 |
-------------------------------------------------------------------------------- /django_admin_row_actions/utils.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from django.db.models.query import QuerySet 3 | 4 | # Multiple admin sites only currently work in Django 1.11 due to use of all_sites 5 | try: 6 | from django.contrib.admin.sites import all_sites 7 | except ImportError: 8 | from django.contrib import admin 9 | all_sites = [admin.site] 10 | 11 | class QuerySetIsh(QuerySet): 12 | 13 | """Takes an instance and mimics it coming from a QuerySet""" 14 | 15 | def __init__(self, instance=None, *args, **kwargs): 16 | try: 17 | model = instance._meta.model 18 | except AttributeError: 19 | # Django 1.5 does this instead, getting the model may be overkill 20 | # we may be able to throw away all this logic 21 | model = instance._meta.concrete_model 22 | self._doa_instance = instance 23 | super().__init__(model, *args, **kwargs) 24 | self._result_cache = [instance] 25 | 26 | def _clone(self, *args, **kwargs): 27 | # don't clone me, bro 28 | return self 29 | 30 | def get(self, *args, **kwargs): 31 | # Starting in Django 1.7, `QuerySet.get` started slicing to `MAX_GET_RESULTS`, 32 | # so to avoid messing with `__getslice__`, override `.get`. 33 | return self._doa_instance 34 | 35 | 36 | def takes_instance_or_queryset(func): 37 | 38 | """Decorator that makes standard actions compatible""" 39 | 40 | @wraps(func) 41 | def decorated_function(self, request, queryset): 42 | # Function follows the prototype documented at: 43 | # https://docs.djangoproject.com/en/dev/ref/contrib/admin/actions/#writing-action-functions 44 | if not isinstance(queryset, QuerySet): 45 | queryset = QuerySetIsh(queryset) 46 | return func(self, request, queryset) 47 | return decorated_function 48 | 49 | 50 | def get_django_model_admin(model): 51 | """Search Django ModelAdmin for passed model. 52 | 53 | Returns instance if found, otherwise None. 54 | """ 55 | for admin_site in all_sites: 56 | registry = admin_site._registry 57 | if model in registry: 58 | return registry[model] 59 | return None 60 | -------------------------------------------------------------------------------- /django_admin_row_actions/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.http import Http404, HttpResponse, HttpResponseRedirect 3 | from django.views.generic import View 4 | from django.views.generic.detail import SingleObjectMixin 5 | 6 | from .utils import get_django_model_admin 7 | 8 | 9 | class ModelToolsView(SingleObjectMixin, View): 10 | 11 | """A special view that run the tool's callable""" 12 | 13 | def get(self, request, **kwargs): 14 | 15 | # SingleObjectMixin's `get_object`. Works because the view 16 | # is instantiated with `model` and the urlpattern has `pk`. 17 | 18 | obj = self.get_object() 19 | model_admin = get_django_model_admin(obj.__class__) 20 | if not model_admin: 21 | raise Http404('Can not find ModelAdmin for {}'.format(obj.__class__)) 22 | 23 | # Look up the action in the following order: 24 | # 1. in the named_row_actions dict (for lambdas etc) 25 | # 2. as a method on the model admin 26 | # 3. as a method on the model 27 | if kwargs['tool'] in model_admin._named_row_actions: 28 | action_method = model_admin._named_row_actions[kwargs['tool']] 29 | ret = action_method(request=request, obj=obj) 30 | elif getattr(model_admin, kwargs['tool'], False): 31 | action_method = getattr(model_admin, kwargs['tool']) 32 | ret = action_method(request=request, obj=obj) # TODO should the signature actually be (obj, request) for consistancy? 33 | elif getattr(obj, kwargs['tool'], False): 34 | action_method = getattr(obj, kwargs['tool']) 35 | ret = action_method() 36 | else: 37 | raise Http404 38 | 39 | # If the method returns a response use that, 40 | # otherwise redirect back to the url we were called from 41 | if isinstance(ret, HttpResponse): 42 | response = ret 43 | else: 44 | back = request.headers['referer'] 45 | response = HttpResponseRedirect(back) 46 | 47 | return response 48 | 49 | # Also allow POST 50 | post = get 51 | 52 | def message_user(self, request, message): 53 | # Copied from django.contrib.admin.options 54 | # Included to mimic admin actions 55 | messages.info(request, message) 56 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [tool.poetry] 6 | name = "django-admin-row-actions" 7 | description = "Add action buttons to individual rows in the Django Admin" 8 | readme = "README.md" 9 | version = "0.10.0" 10 | authors = [ 11 | "Andy Baker ", 12 | ] 13 | packages = [ 14 | { include = "django_admin_row_actions" }, 15 | ] 16 | homepage = "https://pypi.org/project/django-admin-row-actions/" 17 | repository = "https://github.com/DjangoAdminHackers/django-admin-row-actions" 18 | --------------------------------------------------------------------------------