├── Makefile ├── requirements.txt ├── bulk_admin ├── models.py ├── __init__.py ├── templates │ └── bulk_admin │ │ ├── bulk_change_list.html │ │ ├── bulk_popup_response.html │ │ └── bulk_change_form.html ├── static │ └── bulk_admin │ │ └── js │ │ ├── bulk-related.js │ │ └── bulk.js └── admin.py ├── example_project ├── views.py ├── __init__.py ├── wsgi.py ├── admin.py ├── models.py ├── urls.py ├── settings.py └── tests.py ├── screenshots ├── bulk_add_1.png ├── bulk_add_2.png ├── bulk_edit_1.png ├── bulk_select_1.png ├── bulk_select_2.png ├── bulk_select_3.png ├── bulk_upload_1.png └── bulk_upload_2.png ├── .gitignore ├── MANIFEST.in ├── manage.py ├── tox.ini ├── CHANGES.rst ├── setup.py ├── LICENSE └── README.rst /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | ./manage.py test 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==1.8.4 2 | tox==2.1.1 3 | -------------------------------------------------------------------------------- /bulk_admin/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | -------------------------------------------------------------------------------- /example_project/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | -------------------------------------------------------------------------------- /example_project/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | -------------------------------------------------------------------------------- /screenshots/bulk_add_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/purelabs/django-bulk-admin/HEAD/screenshots/bulk_add_1.png -------------------------------------------------------------------------------- /screenshots/bulk_add_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/purelabs/django-bulk-admin/HEAD/screenshots/bulk_add_2.png -------------------------------------------------------------------------------- /screenshots/bulk_edit_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/purelabs/django-bulk-admin/HEAD/screenshots/bulk_edit_1.png -------------------------------------------------------------------------------- /screenshots/bulk_select_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/purelabs/django-bulk-admin/HEAD/screenshots/bulk_select_1.png -------------------------------------------------------------------------------- /screenshots/bulk_select_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/purelabs/django-bulk-admin/HEAD/screenshots/bulk_select_2.png -------------------------------------------------------------------------------- /screenshots/bulk_select_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/purelabs/django-bulk-admin/HEAD/screenshots/bulk_select_3.png -------------------------------------------------------------------------------- /screenshots/bulk_upload_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/purelabs/django-bulk-admin/HEAD/screenshots/bulk_upload_1.png -------------------------------------------------------------------------------- /screenshots/bulk_upload_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/purelabs/django-bulk-admin/HEAD/screenshots/bulk_upload_2.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | db.sqlite3 2 | env/ 3 | media/ 4 | *.pyc 5 | .DS_Store 6 | .python-version 7 | .tox 8 | django_bulk_admin.egg-info/ 9 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | recursive-include bulk_admin/locale * 4 | recursive-include bulk_admin/static * 5 | recursive-include bulk_admin/templates * 6 | -------------------------------------------------------------------------------- /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", "example_project.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /bulk_admin/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from bulk_admin.admin import ( 4 | BulkModelAdmin, 5 | StackedBulkInlineModelAdmin, 6 | TabularBulkInlineModelAdmin, 7 | ) 8 | 9 | __all__ = [ 10 | BulkModelAdmin, 11 | StackedBulkInlineModelAdmin, 12 | TabularBulkInlineModelAdmin, 13 | ] 14 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{27,32,33,34}-django{17,18} 4 | 5 | [testenv] 6 | basepython = 7 | py27: python2.7 8 | py32: python3.2 9 | py33: python3.3 10 | py34: python3.4 11 | commands = make test 12 | deps = 13 | django17: Django>=1.7,<1.8 14 | django18: Django>=1.8,<1.9 15 | whitelist_externals = make 16 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 0.1.1 5 | ----- 6 | 7 | * Enforce the use of the same model inside BulkInlineModelAdmin and BulkModelAdmin 8 | * Added errors to change form context 9 | * Documented caveat: No admin logs are generated for bulk operations 10 | * Compatibility with python 2.7, 3.2, 3.3, 3.4 and Django 1.7 and 1.8 11 | * Added tox 12 | * Removed dependency to django-sortedm2m 13 | * Removed migrations in example_project 14 | -------------------------------------------------------------------------------- /bulk_admin/templates/bulk_admin/bulk_change_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/change_list.html' %} 2 | 3 | {% load i18n admin_urls %} 4 | 5 | {% block object-tools-items %} 6 |
  • 7 | {% url cl.opts|admin_urlname:'bulk' as bulk_url %} 8 | 9 | {% blocktrans with cl.opts.verbose_name_plural as name %}Bulk add {{ name }}{% endblocktrans %} 10 | 11 |
  • 12 | {{ block.super }} 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /example_project/wsgi.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | """ 4 | WSGI config for example_project project. 5 | 6 | It exposes the WSGI callable as a module-level variable named ``application``. 7 | 8 | For more information on this file, see 9 | https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ 10 | """ 11 | 12 | import os 13 | 14 | from django.core.wsgi import get_wsgi_application 15 | 16 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") 17 | 18 | application = get_wsgi_application() 19 | -------------------------------------------------------------------------------- /bulk_admin/templates/bulk_admin/bulk_popup_response.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ media }} 6 | 7 | 8 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /example_project/admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.contrib import admin 4 | from example_project import models 5 | 6 | import bulk_admin 7 | 8 | 9 | class ProjectInline(bulk_admin.StackedBulkInlineModelAdmin): 10 | model = models.Project 11 | raw_id_fields = ('images',) 12 | 13 | 14 | @admin.register(models.Image) 15 | class ImageAdmin(bulk_admin.BulkModelAdmin): 16 | search_fields = ('title',) 17 | 18 | 19 | @admin.register(models.Project) 20 | class ProjectAdmin(bulk_admin.BulkModelAdmin): 21 | raw_id_fields = ('images',) 22 | bulk_inline = ProjectInline 23 | -------------------------------------------------------------------------------- /example_project/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.db import models 4 | from django.utils.encoding import python_2_unicode_compatible 5 | 6 | 7 | @python_2_unicode_compatible 8 | class Image(models.Model): 9 | title = models.CharField(max_length=255, unique=True) 10 | data = models.FileField(null=True, blank=True) 11 | 12 | def __str__(self): 13 | return self.title 14 | 15 | 16 | @python_2_unicode_compatible 17 | class Project(models.Model): 18 | title = models.CharField(max_length=255, unique=True) 19 | images = models.ManyToManyField(Image, blank=True) 20 | 21 | def __str__(self): 22 | return self.title 23 | -------------------------------------------------------------------------------- /example_project/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | """example_project URL Configuration 4 | 5 | The `urlpatterns` list routes URLs to views. For more information please see: 6 | https://docs.djangoproject.com/en/1.8/topics/http/urls/ 7 | Examples: 8 | Function views 9 | 1. Add an import: from my_app import views 10 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 11 | Class-based views 12 | 1. Add an import: from other_app.views import Home 13 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 14 | Including another URLconf 15 | 1. Add an import: from blog import urls as blog_urls 16 | 2. Add a URL to urlpatterns: url(r'^blog/', include(blog_urls)) 17 | """ 18 | from django.conf.urls import include, url 19 | from django.contrib import admin 20 | 21 | urlpatterns = [ 22 | url(r'^admin/', include(admin.site.urls)), 23 | ] 24 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme: 5 | README = readme.read() 6 | 7 | # allow setup.py to be run from any path 8 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 9 | 10 | setup( 11 | name='django-bulk-admin', 12 | version='0.1.1', 13 | packages=find_packages(exclude=('example_project*', 'screenshots',)), 14 | include_package_data=True, 15 | license='BSD', 16 | description='Django bulk admin enables you to bulk add, bulk edit, bulk upload and bulk select in django admin.', 17 | long_description=README, 18 | url='https://github.com/purelabs/django-bulk-admin', 19 | author='Ruben Grill', 20 | author_email='ruben.grill@gmail.com', 21 | install_requires=[ 22 | 'Django>=1.7', 23 | ], 24 | classifiers=[ 25 | 'Environment :: Web Environment', 26 | 'Framework :: Django', 27 | 'Intended Audience :: Developers', 28 | 'License :: OSI Approved :: BSD License', 29 | 'Operating System :: OS Independent', 30 | 'Programming Language :: Python', 31 | 'Programming Language :: Python :: 2', 32 | 'Programming Language :: Python :: 2.7', 33 | 'Programming Language :: Python :: 3', 34 | 'Programming Language :: Python :: 3.2', 35 | 'Programming Language :: Python :: 3.3', 36 | 'Programming Language :: Python :: 3.4', 37 | 'Topic :: Software Development :: Libraries :: Python Modules', 38 | ], 39 | ) 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Ruben Grill and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Django nor the names of its contributors may be used 15 | to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /bulk_admin/static/bulk_admin/js/bulk-related.js: -------------------------------------------------------------------------------- 1 | (function($) { 2 | 'use strict'; 3 | 4 | function windowname_to_id(text) { 5 | text = text.replace(/__dot__/g, '.'); 6 | text = text.replace(/__dash__/g, '-'); 7 | return text; 8 | } 9 | 10 | function dismissAddRelatedObjectPopup(openerWindow, newIds, newReprs) { 11 | var name = windowname_to_id(window.name); 12 | var elem = openerWindow.document.getElementById(name); 13 | if (elem) { 14 | var elemName = elem.nodeName.toUpperCase(); 15 | if (elemName === 'SELECT') { 16 | for (var i = 0; i < newIds.length; i++) { 17 | elem.options[elem.options.length] = new Option(newReprs[i], newIds[i], true, true); 18 | } 19 | } else if (elemName === 'INPUT') { 20 | if (elem.className.indexOf('vManyToManyRawIdAdminField') !== -1 && elem.value) { 21 | elem.value += ',' + newIds.join(','); 22 | } else { 23 | elem.value = newIds.join(','); 24 | } 25 | } 26 | // Trigger a change event to update related links if required. 27 | openerWindow.django.jQuery(elem).trigger('change'); 28 | } else { 29 | var toId = name + "_to"; 30 | 31 | for (var i = 0; i < newIds.length; i++) { 32 | openerWindow.SelectBox.add_to_cache(toId, new Option(newRepr, newId)); 33 | openerWindow.SelectBox.redisplay(toId); 34 | } 35 | } 36 | window.close(); 37 | } 38 | 39 | window.dismissAddRelatedObjectPopup = dismissAddRelatedObjectPopup; 40 | 41 | })(); 42 | -------------------------------------------------------------------------------- /bulk_admin/templates/bulk_admin/bulk_change_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/change_form.html' %} 2 | 3 | {% load i18n admin_urls %} 4 | 5 | {% if not is_popup %} 6 | {% block breadcrumbs %} 7 | 13 | {% endblock %} 14 | {% endif %} 15 | 16 | {% block object-tools %} 17 | {% trans "Files are being uploaded..." as submitting_message %} 18 | {% if bulk %} 19 | 28 | 43 | {% else %} 44 | {{ block.super }} 45 | {% endif %} 46 | {% endblock %} 47 | -------------------------------------------------------------------------------- /example_project/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | """ 4 | Django settings for example_project project. 5 | 6 | Generated by 'django-admin startproject' using Django 1.8.4. 7 | 8 | For more information on this file, see 9 | https://docs.djangoproject.com/en/1.8/topics/settings/ 10 | 11 | For the full list of settings and their values, see 12 | https://docs.djangoproject.com/en/1.8/ref/settings/ 13 | """ 14 | 15 | import django 16 | 17 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 18 | import os 19 | 20 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 21 | 22 | 23 | # Quick-start development settings - unsuitable for production 24 | # See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/ 25 | 26 | # SECURITY WARNING: keep the secret key used in production secret! 27 | SECRET_KEY = 'h70^2zl-an7*k3*x4+mf3jc5)pzxt+p9i6uztrrb!_u_+6q38y' 28 | 29 | # SECURITY WARNING: don't run with debug turned on in production! 30 | DEBUG = True 31 | 32 | ALLOWED_HOSTS = [] 33 | 34 | 35 | # Application definition 36 | 37 | INSTALLED_APPS = ( 38 | 'django.contrib.admin', 39 | 'django.contrib.auth', 40 | 'django.contrib.contenttypes', 41 | 'django.contrib.sessions', 42 | 'django.contrib.messages', 43 | 'django.contrib.staticfiles', 44 | 'bulk_admin', 45 | 'example_project', 46 | ) 47 | 48 | MIDDLEWARE_CLASSES = ( 49 | 'django.contrib.sessions.middleware.SessionMiddleware', 50 | 'django.middleware.common.CommonMiddleware', 51 | 'django.middleware.csrf.CsrfViewMiddleware', 52 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 53 | 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 54 | 'django.contrib.messages.middleware.MessageMiddleware', 55 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 56 | ) 57 | 58 | ROOT_URLCONF = 'example_project.urls' 59 | 60 | if django.VERSION < (1, 8): 61 | TEMPLATE_DEBUG = True 62 | 63 | TEMPLATES = [ 64 | { 65 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 66 | 'DIRS': [], 67 | 'APP_DIRS': True, 68 | 'OPTIONS': { 69 | 'context_processors': [ 70 | 'django.template.context_processors.debug', 71 | 'django.template.context_processors.request', 72 | 'django.contrib.auth.context_processors.auth', 73 | 'django.contrib.messages.context_processors.messages', 74 | ], 75 | }, 76 | }, 77 | ] 78 | 79 | WSGI_APPLICATION = 'example_project.wsgi.application' 80 | 81 | 82 | # Database 83 | # https://docs.djangoproject.com/en/1.8/ref/settings/#databases 84 | 85 | DATABASES = { 86 | 'default': { 87 | 'ENGINE': 'django.db.backends.sqlite3', 88 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 89 | } 90 | } 91 | 92 | 93 | # Internationalization 94 | # https://docs.djangoproject.com/en/1.8/topics/i18n/ 95 | 96 | LANGUAGE_CODE = 'en-us' 97 | 98 | TIME_ZONE = 'UTC' 99 | 100 | USE_I18N = True 101 | 102 | USE_L10N = True 103 | 104 | USE_TZ = True 105 | 106 | 107 | # Static files (CSS, JavaScript, Images) 108 | # https://docs.djangoproject.com/en/1.8/howto/static-files/ 109 | 110 | STATIC_URL = '/static/' 111 | 112 | MEDIA_ROOT = os.path.join(BASE_DIR, 'media/') 113 | -------------------------------------------------------------------------------- /bulk_admin/static/bulk_admin/js/bulk.js: -------------------------------------------------------------------------------- 1 | (function($) { 2 | 'use strict'; 3 | 4 | $.fn.bulkUpload = function(opts) { 5 | var options = $.extend({}, $.fn.bulkUpload.defaults, opts); 6 | var $this = $(this); 7 | var submitted = false; 8 | 9 | $this.click(function() { 10 | var field = $this.data('field'); 11 | 12 | var $form = $('
    ') 13 | .attr('method', 'POST') 14 | .attr('action', '') 15 | .attr('enctype', 'multipart/form-data'); 16 | 17 | var $fileInput = $('') 18 | .attr('type', 'file') 19 | .attr('name', options.prefix + '-' + field) 20 | .attr('multiple', 'multiple'); 21 | $form.append($fileInput); 22 | 23 | var $csrfInput = $('') 24 | .attr('type', 'text') 25 | .attr('name', options.csrfTokenName) 26 | .attr('value', options.csrfToken); 27 | $form.append($csrfInput); 28 | 29 | var $totalFormCountInput = $('') 30 | .attr('type', 'number') 31 | .attr('name', options.prefix + '-' + 'TOTAL_FORMS'); 32 | $form.append($totalFormCountInput); 33 | 34 | var $initialFormCountInput = $('') 35 | .attr('type', 'number') 36 | .attr('name', options.prefix + '-' + 'INITIAL_FORMS') 37 | .attr('value', '0'); 38 | $form.append($initialFormCountInput); 39 | 40 | if (options.isPopup) { 41 | var $isPopupInput = $('') 42 | .attr('type', 'number') 43 | .attr('name', options.isPopupName) 44 | .attr('value', '1'); 45 | $form.append($isPopupInput); 46 | } 47 | 48 | if (options.toField) { 49 | var $toFieldInput = $('') 50 | .attr('type', 'text') 51 | .attr('name', options.toFieldName) 52 | .attr('value', options.toField); 53 | $form.append($toFieldInput); 54 | } 55 | 56 | if (options.continue) { 57 | var $continueField = $('') 58 | .attr('type', 'text') 59 | .attr('name', options.continueName) 60 | .attr('value', 'on'); 61 | $form.append($continueField); 62 | } 63 | 64 | $fileInput.change(function() { 65 | if (submitted) { 66 | return; 67 | } 68 | 69 | if (options.submittingMessage) { 70 | $this.text(options.submittingMessage); 71 | } 72 | 73 | $totalFormCountInput.attr('value', this.files.length); 74 | $form.submit(); 75 | 76 | submitted = true; 77 | }); 78 | 79 | $fileInput.trigger('click'); 80 | }); 81 | }; 82 | 83 | $.fn.bulkUpload.defaults = { 84 | prefix: 'form', 85 | csrfTokenName: 'csrfmiddlewaretoken', 86 | isPopupName: '_popup', 87 | isPopup: false, 88 | toFieldName: '_to_field', 89 | toField: '', 90 | continueName: '_continue', 91 | continue: true, 92 | submittingMessage: 'Files are being uploaded...', 93 | }; 94 | 95 | })(django.jQuery); 96 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | django-bulk-admin 3 | ================= 4 | 5 | Django bulk admin enables you to bulk add, bulk edit, bulk upload and bulk select in django admin. 6 | 7 | View the screenshots below to get an idea of how django bulk admin does look like. 8 | 9 | Requires Django >= 1.7. 10 | 11 | 12 | =========== 13 | Quick start 14 | =========== 15 | 16 | 1. Install with pip:: 17 | 18 | $ pip install django-bulk-admin 19 | 20 | 2. Add "bulk_admin" to your INSTALLED_APPS setting like this:: 21 | 22 | INSTALLED_APPS = ( 23 | ... 24 | 'bulk_admin', 25 | ) 26 | 27 | 3. Inherit from ``bulk_admin.BulkModelAdmin`` instead of ``django.contrib.admin.ModelAdmin``:: 28 | 29 | from django.contrib import admin 30 | from example_project import models 31 | 32 | import bulk_admin 33 | 34 | 35 | @admin.register(models.Image) 36 | class ImageAdmin(bulk_admin.BulkModelAdmin): 37 | search_fields = ('title',) 38 | 39 | 40 | @admin.register(models.Project) 41 | class ProjectAdmin(bulk_admin.BulkModelAdmin): 42 | raw_id_fields = ('images',) 43 | 44 | 4. Enjoy! 45 | 46 | 47 | =========== 48 | Bulk Upload 49 | =========== 50 | 51 | By default, django bulk admin provides a bulk upload button for each field type that has an ``upload_to`` attribute, like ``FileField`` or ``ImageField``. 52 | If you want to customize the provided buttons (or disable bulk upload at all), set ``bulk_upload_fields`` in the ``BulkAdminModel``:: 53 | 54 | @admin.register(models.Image) 55 | class ImageAdmin(bulk_admin.BulkModelAdmin): 56 | bulk_upload_fields = () 57 | 58 | When files are bulk uploaded, a model instance is created and saved for each file. 59 | If there are required fields, django bulk admin tries to set unique values (uuid) which can be edited by the uploading user in the next step. 60 | For setting custom values or to support non string fields that are required, override ``generate_data_for_file``:: 61 | 62 | @admin.register(models.Image) 63 | class ImageAdmin(bulk_admin.BulkModelAdmin): 64 | 65 | def generate_data_for_file(self, request, field_name, field_file, index): 66 | if field_name == 'data': 67 | return dict(title=field_file.name) 68 | return super(ImageAdmin, self).generate_data_for_file(request, field_name, file, index) 69 | 70 | 71 | ======= 72 | Caveats 73 | ======= 74 | 75 | - No admin logs are generated for bulk operations 76 | 77 | ================ 78 | Customize Inline 79 | ================ 80 | 81 | Django bulk admin provides two inlines that are similar to those provided by django admin: 82 | 83 | - ``bulk_admin.TabularBulkInlineModelAdmin`` (which is the default) 84 | - ``bulk_admin.StackedBulkInlineModelAdmin`` 85 | 86 | You can configure them exactly like django admin one's:: 87 | 88 | from django.contrib import admin 89 | from example_project import models 90 | 91 | import bulk_admin 92 | 93 | 94 | class ProjectInline(bulk_admin.StackedBulkInlineModelAdmin): 95 | model = models.Project 96 | raw_id_fields = ('images',) 97 | 98 | 99 | @admin.register(models.Image) 100 | class ImageAdmin(bulk_admin.BulkModelAdmin): 101 | search_fields = ('title',) 102 | 103 | 104 | @admin.register(models.Project) 105 | class ProjectAdmin(bulk_admin.BulkModelAdmin): 106 | raw_id_fields = ('images',) 107 | bulk_inline = ProjectInline 108 | 109 | 110 | =========== 111 | Screenshots 112 | =========== 113 | 114 | -------- 115 | Bulk add 116 | -------- 117 | 118 | .. image:: https://raw.githubusercontent.com/purelabs/django-bulk-admin/master/screenshots/bulk_add_1.png 119 | .. image:: https://raw.githubusercontent.com/purelabs/django-bulk-admin/master/screenshots/bulk_add_2.png 120 | 121 | --------- 122 | Bulk edit 123 | --------- 124 | 125 | .. image:: https://raw.githubusercontent.com/purelabs/django-bulk-admin/master/screenshots/bulk_edit_1.png 126 | 127 | ----------- 128 | Bulk upload 129 | ----------- 130 | 131 | .. image:: https://raw.githubusercontent.com/purelabs/django-bulk-admin/master/screenshots/bulk_upload_1.png 132 | .. image:: https://raw.githubusercontent.com/purelabs/django-bulk-admin/master/screenshots/bulk_upload_2.png 133 | 134 | ----------- 135 | Bulk select 136 | ----------- 137 | 138 | .. image:: https://raw.githubusercontent.com/purelabs/django-bulk-admin/master/screenshots/bulk_select_1.png 139 | .. image:: https://raw.githubusercontent.com/purelabs/django-bulk-admin/master/screenshots/bulk_select_2.png 140 | .. image:: https://raw.githubusercontent.com/purelabs/django-bulk-admin/master/screenshots/bulk_select_3.png 141 | -------------------------------------------------------------------------------- /example_project/tests.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.test import TestCase 4 | from django.contrib.admin.sites import site as admin_site 5 | from django.contrib.auth.models import Permission, User 6 | from django.core.urlresolvers import reverse 7 | from django.utils import six 8 | from io import BytesIO 9 | 10 | from bulk_admin.admin import BulkInlineModelAdmin 11 | from example_project.models import Image, Project 12 | 13 | import sys 14 | 15 | 16 | class BulkTests(TestCase): 17 | 18 | def setUp(self): 19 | self.bulk_url = reverse('admin:{}_{}_bulk'.format(Image._meta.app_label, Image._meta.model_name)) 20 | self.changelist_url = reverse('admin:{}_{}_changelist'.format(Image._meta.app_label, Image._meta.model_name)) 21 | self.add_url = reverse('admin:{}_{}_add'.format(Image._meta.app_label, Image._meta.model_name)) 22 | self.index_url = reverse('admin:index') 23 | 24 | self.add_permission = Permission.objects.get(codename='add_{}'.format(Image._meta.model_name)) 25 | self.change_permission = Permission.objects.get(codename='change_{}'.format(Image._meta.model_name)) 26 | self.delete_permission = Permission.objects.get(codename='delete_{}'.format(Image._meta.model_name)) 27 | 28 | self.user = User.objects.create_user('grill', 'ruben@grill.de', 'grill') 29 | self.user.user_permissions.add(self.add_permission) 30 | self.user.user_permissions.add(self.change_permission) 31 | self.user.user_permissions.add(self.delete_permission) 32 | self.user.is_staff = True 33 | self.user.save() 34 | 35 | self.user_not_staff = User.objects.create_user('not_staff', 'not@staff.de', 'not_staff') 36 | 37 | self.client.login(username='grill', password='grill') 38 | 39 | def bulk_payload(self, objects, **extra): 40 | changed_objects = [obj for obj in objects if 'id' in obj] 41 | new_objects = [obj for obj in objects if 'id' not in obj] 42 | payload = { 43 | 'form-TOTAL_FORMS': len(objects), 44 | 'form-INITIAL_FORMS': len(changed_objects), 45 | } 46 | 47 | for index, obj in enumerate(changed_objects): 48 | for name, value in six.iteritems(obj): 49 | payload['form-{index}-{name}'.format(index=index, name=name)] = value 50 | 51 | for index, obj in enumerate(new_objects, len(changed_objects)): 52 | for name, value in six.iteritems(obj): 53 | payload['form-{index}-{name}'.format(index=index, name=name)] = value 54 | 55 | payload.update(extra) 56 | 57 | return payload 58 | 59 | def bulk_upload_payload(self, field, files, **extra): 60 | payload = { 61 | 'form-TOTAL_FORMS': len(files), 62 | 'form-INITIAL_FORMS': 0, 63 | 'form-{}'.format(field): files, 64 | } 65 | 66 | payload.update(extra) 67 | 68 | return payload 69 | 70 | def assertRedirects(self, response, expected_url): 71 | # Don't fetch redirect response in python 3.2, as sessionid cookie gets lost due to a bug in cookie parsing. 72 | # Happens when messages are used and messages cookie comes before sessionid cookie and contains square brackets. 73 | # See https://bugs.python.org/issue22931 74 | fetch_redirect_response = False if sys.version_info >= (3, 2) and sys.version_info < (3, 3) else True 75 | super(BulkTests, self).assertRedirects(response, expected_url, fetch_redirect_response=fetch_redirect_response) 76 | 77 | def assertImagesEqual(self, qs, images, ordered=True, msg=None): 78 | def transform_to_dict(obj): 79 | return {field.name: getattr(obj, field.name) for field in obj._meta.fields if getattr(obj, field.name)} 80 | 81 | values = [] 82 | 83 | for image in images: 84 | if isinstance(image, Image): 85 | values.append(transform_to_dict(image)) 86 | 87 | else: 88 | image = dict(image) 89 | image.pop('DELETE', None) 90 | try: 91 | image['id'] = getattr(image, 'id', None) or Image.objects.get(title=image['title']).id 92 | except Image.DoesNotExist: 93 | pass 94 | values.append(image) 95 | 96 | return super(BulkTests, self).assertQuerysetEqual(qs, values, transform_to_dict, ordered, msg) 97 | 98 | def getTestQueryset(self): 99 | return Image.objects.exclude(title__startswith='preexisting') 100 | 101 | def getResponseQueryset(self, response): 102 | return response.context['inline_admin_formsets'][0].formset.queryset 103 | 104 | def test_http_get_bulk(self): 105 | response = self.client.get(self.bulk_url) 106 | 107 | self.assertEqual(response.status_code, 200) 108 | 109 | def test_http_get_bulk_with_pks(self): 110 | Image.objects.create(title='preexisting - I might not be included in response queryset!') 111 | 112 | image = Image.objects.create(title='foo') 113 | 114 | response = self.client.get('{}?pks={}'.format(self.bulk_url, image.pk)) 115 | 116 | self.assertEqual(response.status_code, 200) 117 | self.assertImagesEqual(self.getResponseQueryset(response), [image]) 118 | 119 | def test_http_get_bulk_with_pks_without_change_permission(self): 120 | self.user.user_permissions.remove(self.change_permission) 121 | 122 | image = Image.objects.create(title='foo') 123 | 124 | response = self.client.get('{}?pks={}'.format(self.bulk_url, image.pk)) 125 | 126 | self.assertEqual(response.status_code, 200) 127 | self.assertImagesEqual(self.getResponseQueryset(response), []) 128 | 129 | def test_http_get_bulk_not_staff(self): 130 | self.client.login(username='not_staff', password='not_staff') 131 | 132 | response = self.client.get(self.bulk_url) 133 | 134 | self.assertRedirects(response, '/admin/login/?next={}'.format(self.bulk_url)) 135 | 136 | def test_add_image_and_save(self): 137 | images = [{'title': 'foo'}] 138 | payload = self.bulk_payload(images) 139 | response = self.client.post(self.bulk_url, payload) 140 | 141 | self.assertRedirects(response, self.changelist_url) 142 | self.assertImagesEqual(self.getTestQueryset(), images) 143 | 144 | def test_add_image_and_save_without_add_permission(self): 145 | self.user.user_permissions.remove(self.add_permission) 146 | 147 | images = [{'title': 'foo'}] 148 | payload = self.bulk_payload(images) 149 | response = self.client.post(self.bulk_url, payload) 150 | 151 | self.assertEqual(response.status_code, 403) 152 | self.assertImagesEqual(self.getTestQueryset(), []) 153 | 154 | def test_add_image_and_save_without_change_permission(self): 155 | self.user.user_permissions.remove(self.change_permission) 156 | 157 | images = [{'title': 'foo'}] 158 | payload = self.bulk_payload(images) 159 | response = self.client.post(self.bulk_url, payload) 160 | 161 | self.assertRedirects(response, self.index_url) 162 | self.assertImagesEqual(self.getTestQueryset(), images) 163 | 164 | def test_add_image_and_continue(self): 165 | Image.objects.create(title='preexisting - I might not be included in response queryset!') 166 | 167 | images = [{'title': 'foo'}] 168 | payload = self.bulk_payload(images, _continue=1) 169 | response = self.client.post(self.bulk_url, payload) 170 | 171 | self.assertEqual(response.status_code, 200) 172 | self.assertImagesEqual(self.getTestQueryset(), images) 173 | self.assertImagesEqual(self.getResponseQueryset(response), images) 174 | 175 | def test_add_image_and_continue_without_change_permission(self): 176 | Image.objects.create(title='preexisting - I might not be included in response queryset!') 177 | 178 | self.user.user_permissions.remove(self.change_permission) 179 | 180 | images = [{'title': 'foo'}] 181 | payload = self.bulk_payload(images, _continue=1) 182 | response = self.client.post(self.bulk_url, payload) 183 | 184 | self.assertEqual(response.status_code, 200) 185 | self.assertImagesEqual(self.getTestQueryset(), images) 186 | self.assertImagesEqual(self.getResponseQueryset(response), []) 187 | 188 | def test_add_image_and_add_another(self): 189 | images = [{'title': 'foo'}] 190 | payload = self.bulk_payload(images, _addanother=1) 191 | response = self.client.post(self.bulk_url, payload) 192 | 193 | self.assertRedirects(response, self.add_url) 194 | self.assertImagesEqual(self.getTestQueryset(), images) 195 | 196 | def test_change_image_and_save(self): 197 | image = Image.objects.create(title='foo') 198 | images = [{'title': 'bar', 'id': image.id}] 199 | payload = self.bulk_payload(images) 200 | response = self.client.post(self.bulk_url, payload) 201 | 202 | self.assertRedirects(response, self.changelist_url) 203 | self.assertImagesEqual(self.getTestQueryset(), images) 204 | 205 | def test_change_image_and_save_without_change_permission(self): 206 | self.user.user_permissions.remove(self.change_permission) 207 | 208 | image = Image.objects.create(title='foo') 209 | images = [{'title': 'bar', 'id': image.id}] 210 | payload = self.bulk_payload(images) 211 | response = self.client.post(self.bulk_url, payload) 212 | 213 | self.assertEqual(response.status_code, 403) 214 | self.assertImagesEqual(self.getTestQueryset(), [image]) 215 | 216 | def test_change_image_and_save_without_add_permission(self): 217 | self.user.user_permissions.remove(self.add_permission) 218 | 219 | self.test_change_image_and_save() 220 | 221 | def test_change_image_and_continue(self): 222 | Image.objects.create(title='preexisting - I might not be included in response queryset!') 223 | 224 | image = Image.objects.create(title='foo') 225 | images = [{'title': 'bar', 'id': image.id}] 226 | payload = self.bulk_payload(images, _continue=1) 227 | response = self.client.post(self.bulk_url, payload) 228 | 229 | self.assertEqual(response.status_code, 200) 230 | self.assertImagesEqual(self.getTestQueryset(), images) 231 | self.assertImagesEqual(self.getResponseQueryset(response), images) 232 | 233 | def test_change_image_and_add_another(self): 234 | image = Image.objects.create(title='foo') 235 | images = [{'title': 'bar', 'id': image.id}] 236 | payload = self.bulk_payload(images, _addanother=1) 237 | response = self.client.post(self.bulk_url, payload) 238 | 239 | self.assertRedirects(response, self.add_url) 240 | self.assertImagesEqual(self.getTestQueryset(), images) 241 | 242 | def test_delete_image_and_save(self): 243 | image = Image.objects.create(title='foo') 244 | images = [{'title': image.title, 'id': image.id, 'DELETE': True}] 245 | payload = self.bulk_payload(images) 246 | response = self.client.post(self.bulk_url, payload) 247 | 248 | self.assertRedirects(response, self.changelist_url) 249 | self.assertImagesEqual(self.getTestQueryset(), []) 250 | 251 | def test_delete_image_and_save_without_delete_permission(self): 252 | self.user.user_permissions.remove(self.delete_permission) 253 | 254 | image = Image.objects.create(title='foo') 255 | images = [{'title': image.title, 'id': image.id, 'DELETE': True}] 256 | payload = self.bulk_payload(images) 257 | response = self.client.post(self.bulk_url, payload) 258 | 259 | self.assertRedirects(response, self.changelist_url) 260 | self.assertImagesEqual(self.getTestQueryset(), images) 261 | 262 | def test_bulk_upload(self): 263 | with BytesIO(b'data1') as data1, BytesIO(b'data2') as data2: 264 | # Django < 1.8 requires *name* attribute 265 | data1.name = 'data1.txt' 266 | data2.name = 'data2.txt' 267 | 268 | payload = self.bulk_upload_payload('data', [data1, data2]) 269 | response = self.client.post(self.bulk_url, payload) 270 | images = list(Image.objects.all()) 271 | 272 | self.assertEqual(response.status_code, 200) 273 | self.assertEqual(len(images), 2) 274 | 275 | for image, data in zip(images, [data1, data2]): 276 | with image.data as image_data: 277 | self.assertEqual(image_data.read(), data.getvalue()) 278 | 279 | def test_bulk_inline_model_admin_without_model(self): 280 | class ImageInline(BulkInlineModelAdmin): 281 | pass 282 | 283 | ImageInline(Image, admin_site) 284 | 285 | def test_bulk_inline_model_admin_with_same_model(self): 286 | class ImageInline(BulkInlineModelAdmin): 287 | model = Image 288 | 289 | ImageInline(Image, admin_site) 290 | 291 | def test_bulk_inline_model_admin_with_different_model(self): 292 | class ImageInline(BulkInlineModelAdmin): 293 | model = Project 294 | 295 | error = ( 296 | 'ImageInline with model Project may only be used as bulk_inline ' 297 | 'within a ModelAdmin having the same model, ' 298 | 'but was used inside a ModelAdmin with model Image' 299 | ) 300 | 301 | with self.assertRaisesRegexp(Exception, error): 302 | ImageInline(Image, admin_site) 303 | -------------------------------------------------------------------------------- /bulk_admin/admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from collections import OrderedDict 4 | from django import forms 5 | from django.contrib import admin, messages 6 | from django.contrib.admin.exceptions import DisallowedModelAdminToField 7 | from django.contrib.admin.options import IS_POPUP_VAR, InlineModelAdmin, TO_FIELD_VAR, csrf_protect_m 8 | from django.contrib.admin.templatetags.admin_static import static 9 | from django.contrib.admin.templatetags.admin_urls import add_preserved_filters 10 | from django.contrib.admin.utils import NestedObjects, flatten_fieldsets 11 | from django.core.exceptions import PermissionDenied, ValidationError 12 | from django.core.urlresolvers import reverse 13 | from django.db import router, transaction 14 | from django.forms.formsets import DELETION_FIELD_NAME, INITIAL_FORM_COUNT, TOTAL_FORM_COUNT, ManagementForm 15 | from django.forms.models import modelform_defines_fields, modelformset_factory, BaseModelFormSet 16 | from django.forms.utils import ErrorList 17 | from django.http import HttpResponseRedirect 18 | from django.template.response import SimpleTemplateResponse 19 | from django.utils import six 20 | from django.utils.encoding import force_text 21 | from django.utils.text import get_text_list 22 | from django.utils.translation import ugettext as _, ugettext_lazy 23 | from functools import partial, update_wrapper 24 | 25 | import django 26 | import re 27 | import uuid 28 | 29 | 30 | _RE_BULK_FILE = re.compile(r'^([^\\-]+)-([^\\-]+)$') 31 | 32 | 33 | class BulkModelAdmin(admin.ModelAdmin): 34 | 35 | actions = ['bulk_edit_action'] 36 | bulk_generate_unique_values = None 37 | bulk_inline = None 38 | bulk_upload_fields = None 39 | change_list_template = None 40 | add_form_template = None 41 | change_form_template = None 42 | 43 | def __init__(self, *args, **kwargs): 44 | super(BulkModelAdmin, self).__init__(*args, **kwargs) 45 | 46 | opts = self.model._meta 47 | app_label = opts.app_label 48 | 49 | self.change_list_template = self.change_list_template or [ 50 | 'bulk_admin/%s/%s/bulk_change_list.html' % (app_label, opts.model_name), 51 | 'bulk_admin/%s/bulk_change_list.html' % app_label, 52 | 'bulk_admin/bulk_change_list.html' 53 | ] 54 | 55 | self.add_form_template = self.add_form_template or [ 56 | 'bulk_admin/%s/%s/bulk_change_form.html' % (app_label, opts.model_name), 57 | 'bulk_admin/%s/bulk_change_form.html' % app_label, 58 | 'bulk_admin/bulk_change_form.html' 59 | ] 60 | 61 | self.change_form_template = self.change_form_template or [ 62 | 'bulk_admin/%s/%s/bulk_change_form.html' % (app_label, opts.model_name), 63 | 'bulk_admin/%s/bulk_change_form.html' % app_label, 64 | 'bulk_admin/bulk_change_form.html' 65 | ] 66 | 67 | def get_urls(self): 68 | from django.conf.urls import url 69 | 70 | def wrap(view): 71 | def wrapper(*args, **kwargs): 72 | return self.admin_site.admin_view(view)(*args, **kwargs) 73 | return update_wrapper(wrapper, view) 74 | 75 | info = self.model._meta.app_label, self.model._meta.model_name 76 | 77 | urlpatterns = super(BulkModelAdmin, self).get_urls() 78 | urlpatterns.insert(0, url(r'^bulk/$', wrap(self.bulk_view), name='%s_%s_bulk' % info)) 79 | 80 | return urlpatterns 81 | 82 | @csrf_protect_m 83 | @transaction.atomic 84 | def bulk_view(self, request, form_url='', extra_context=None): 85 | to_field = request.POST.get(TO_FIELD_VAR, request.GET.get(TO_FIELD_VAR)) 86 | if to_field and not self.to_field_allowed(request, to_field): 87 | raise DisallowedModelAdminToField("The field %s cannot be referenced." % to_field) 88 | 89 | model = self.model 90 | opts = model._meta 91 | 92 | continue_requested = request.POST.get('_continue', request.GET.get('_continue')) 93 | force_continue = False 94 | inline = self.get_bulk_inline(request) 95 | formset_class = inline.get_formset(request) 96 | formset_params = {} 97 | prefix = formset_class.get_default_prefix() 98 | queryset = inline.get_queryset(request) 99 | 100 | if not self.has_add_permission(request): 101 | formset_class.max_num = 0 102 | 103 | if request.method == 'GET': 104 | if 'pks' in request.GET and self.has_change_permission(request): 105 | pks = [opts.pk.to_python(pk) for pk in request.GET.get('pks').split(',')] 106 | queryset = queryset.filter(pk__in=pks) 107 | else: 108 | queryset = queryset.none() 109 | 110 | elif request.method == 'POST': 111 | management_form = ManagementForm(request.POST, prefix=prefix) 112 | 113 | if not management_form.is_valid(): 114 | raise ValidationError( 115 | _('ManagementForm data is missing or has been tampered with'), 116 | code='missing_management_form', 117 | ) 118 | 119 | if not self.has_add_permission(request) and management_form.cleaned_data[INITIAL_FORM_COUNT] < management_form.cleaned_data[TOTAL_FORM_COUNT]: 120 | raise PermissionDenied 121 | 122 | if not self.has_change_permission(request) and management_form.cleaned_data[INITIAL_FORM_COUNT] > 0: 123 | raise PermissionDenied 124 | 125 | queryset = self.transform_queryset(request, queryset, management_form, prefix) 126 | 127 | post, files, force_continue = self.transform_post_and_files(request, prefix) 128 | formset_params.update({ 129 | 'data': post, 130 | 'files': files, 131 | }) 132 | 133 | formset_params['queryset'] = queryset 134 | 135 | formset = formset_class(**formset_params) 136 | 137 | if request.method == 'POST': 138 | if formset.is_valid(): 139 | self.save_formset(request, form=None, formset=formset, change=False) 140 | 141 | if continue_requested or force_continue: 142 | # The implementation of ModelAdmin redirects to the change view if valid and continue was requested 143 | # The change view then reads the edited model again from database 144 | # In our case, we can't make a redirect as we would loose the information which models should be edited 145 | # Thus, we create a new formset with the edited models and continue as this would have been a usual GET request 146 | 147 | if self.has_change_permission(request): 148 | queryset = _ListQueryset(queryset) 149 | queryset.extend(formset.new_objects) 150 | else: 151 | queryset = _ListQueryset() 152 | 153 | formset_params.update({ 154 | 'data': None, 155 | 'files': None, 156 | 'queryset': queryset, 157 | }) 158 | 159 | formset = formset_class(**formset_params) 160 | 161 | msg = _('The %s were bulk added successfully. You may edit them again below.') % (force_text(opts.verbose_name_plural),) 162 | self.message_user(request, msg, messages.SUCCESS) 163 | 164 | else: 165 | return self.response_bulk(request, formset) 166 | 167 | media = self.media 168 | 169 | inline_formsets = self.get_inline_formsets(request, [formset], [inline], obj=None) 170 | for inline_formset in inline_formsets: 171 | media = media + inline_formset.media 172 | 173 | errors = ErrorList() 174 | 175 | if formset.is_bound: 176 | errors.extend(formset.non_form_errors()) 177 | for formset_errors in formset.errors: 178 | errors.extend(list(six.itervalues(formset_errors))) 179 | 180 | context = dict( 181 | self.admin_site.each_context(request) if django.VERSION >= (1, 8) else self.admin_site.each_context(), 182 | bulk=True, 183 | bulk_formset_prefix=prefix, 184 | bulk_upload_fields=self.get_bulk_upload_fields(request), 185 | title=_('Bulk add %s') % force_text(opts.verbose_name_plural), 186 | is_popup=(IS_POPUP_VAR in request.POST or 187 | IS_POPUP_VAR in request.GET), 188 | to_field=to_field, 189 | media=media, 190 | inline_admin_formsets=inline_formsets, 191 | errors=errors, 192 | preserved_filters=self.get_preserved_filters(request), 193 | ) 194 | 195 | context.update(extra_context or {}) 196 | 197 | return self.render_change_form(request, context, add=True, change=False, obj=None, form_url=form_url) 198 | 199 | def response_bulk(self, request, formset): 200 | model = self.model 201 | opts = model._meta 202 | preserved_filters = self.get_preserved_filters(request) 203 | msg_dict = { 204 | 'name': force_text(opts.verbose_name), 205 | 'name_plural': force_text(opts.verbose_name_plural), 206 | } 207 | 208 | if IS_POPUP_VAR in request.POST: 209 | return self.response_bulk_popup(request, list(formset.queryset) + formset.new_objects) 210 | 211 | elif '_addanother' in request.POST: 212 | msg = _('The %(name_plural)s were bulk added successfully. You may add another %(name)s below.') % msg_dict 213 | self.message_user(request, msg, messages.SUCCESS) 214 | redirect_url = reverse('admin:%s_%s_add' % (opts.app_label, opts.model_name), current_app=self.admin_site.name) 215 | redirect_url = add_preserved_filters({'preserved_filters': preserved_filters, 'opts': opts}, redirect_url) 216 | return HttpResponseRedirect(redirect_url) 217 | 218 | else: 219 | msg = _('The %(name_plural)s were bulk added successfully.') % msg_dict 220 | self.message_user(request, msg, messages.SUCCESS) 221 | 222 | return self.response_post_save_add(request, obj=None) 223 | 224 | def response_bulk_popup(self, request, objects): 225 | model = self.model 226 | opts = model._meta 227 | 228 | to_field = request.POST.get(TO_FIELD_VAR) 229 | if to_field: 230 | attr = str(to_field) 231 | else: 232 | attr = opts.pk.attname 233 | values = [obj.serializable_value(attr) for obj in objects] 234 | media = forms.Media(js=[static('bulk_admin/js/bulk-related.js')]) 235 | return SimpleTemplateResponse('bulk_admin/bulk_popup_response.html', { 236 | 'values': values, 237 | 'objects': objects, 238 | 'media': media, 239 | }) 240 | 241 | def transform_queryset(self, request, queryset, management_form, prefix): 242 | pk_list = [] 243 | pk_name = self.model._meta.pk.name 244 | pk_field = self.model._meta.pk 245 | to_python = pk_field.to_python 246 | 247 | for index in range(management_form.cleaned_data[INITIAL_FORM_COUNT]): 248 | pk_key = '{}-{}-{}'.format(prefix, index, pk_name) 249 | pk = request.POST[pk_key] 250 | pk = to_python(pk) 251 | pk_list.append(pk) 252 | 253 | return queryset.filter(pk__in=pk_list) 254 | 255 | def transform_post_and_files(self, request, prefix): 256 | post = request.POST.copy() 257 | files = request.FILES 258 | force_continue = False 259 | 260 | for field_name_prefixed, field_files in list(files.lists()): 261 | match = _RE_BULK_FILE.match(field_name_prefixed) 262 | 263 | if match and match.group(1) == prefix: 264 | field_name = match.group(2) 265 | 266 | for index, field_file in enumerate(field_files): 267 | files['{}-{}-{}'.format(prefix, index, field_name)] = field_file 268 | 269 | form_data_for_file = self.generate_data_for_file(request, field_name, field_file, index) 270 | 271 | if form_data_for_file: 272 | force_continue = True 273 | post.update({ 274 | '{}-{}-{}'.format(prefix, index, name): value 275 | for name, value 276 | in six.iteritems(form_data_for_file) 277 | }) 278 | 279 | return post, files, force_continue 280 | 281 | def generate_data_for_file(self, request, field_name, field_file, index): 282 | return {field: uuid.uuid4() for field in self.get_bulk_generate_unique_values() or []} 283 | 284 | def get_bulk_generate_unique_values(self): 285 | if self.bulk_generate_unique_values is not None: 286 | return self.bulk_generate_unique_values 287 | 288 | fields = self.model._meta.get_fields() if django.VERSION >= (1, 8) else self.model._meta.fields 289 | 290 | return list(field.name for field in fields if not getattr(field, 'blank', True)) 291 | 292 | def get_actions(self, request): 293 | if IS_POPUP_VAR in request.GET: 294 | return OrderedDict(select_related_action=self.get_action('select_related_action')) 295 | return super(BulkModelAdmin, self).get_actions(request) 296 | 297 | def get_bulk_inline(self, request): 298 | bulk_inline = self.bulk_inline or TabularBulkInlineModelAdmin 299 | return bulk_inline(self.model, self.admin_site) 300 | 301 | def get_bulk_upload_fields(self, request): 302 | model = self.model 303 | opts = model._meta 304 | 305 | if self.bulk_upload_fields is not None: 306 | return [opts.get_field(field) for field in self.bulk_upload_fields] 307 | 308 | fields = opts.get_fields() if django.VERSION >= (1, 8) else opts.fields 309 | 310 | return [field for field in fields if hasattr(field, 'upload_to')] 311 | 312 | @property 313 | def media(self): 314 | media = super(BulkModelAdmin, self).media 315 | media.add_js([static('bulk_admin/js/bulk.js')]) 316 | 317 | return media 318 | 319 | def select_related_action(self, request, queryset): 320 | return self.response_bulk_popup(request, queryset) 321 | 322 | select_related_action.short_description = ugettext_lazy('Select') 323 | 324 | def bulk_edit_action(self, request, queryset): 325 | model = self.model 326 | opts = model._meta 327 | 328 | selected = request.POST.getlist(admin.ACTION_CHECKBOX_NAME) 329 | redirect_url = reverse('admin:%s_%s_bulk' % (opts.app_label, opts.model_name), current_app=self.admin_site.name) 330 | 331 | return HttpResponseRedirect('{}?pks={}'.format(redirect_url, ','.join(selected))) 332 | 333 | bulk_edit_action.short_description = ugettext_lazy('Bulk edit') 334 | 335 | 336 | class BulkInlineModelAdmin(InlineModelAdmin): 337 | 338 | formset = BaseModelFormSet 339 | 340 | def __init__(self, parent_model, admin_site): 341 | self.model = self.model if self.model is not None else parent_model 342 | 343 | if self.model != parent_model: 344 | raise Exception( 345 | '{} with model {} may only be used as bulk_inline ' 346 | 'within a ModelAdmin having the same model, ' 347 | 'but was used inside a ModelAdmin with model {}' 348 | .format(self.__class__.__name__, self.model.__name__, parent_model.__name__) 349 | ) 350 | 351 | super(BulkInlineModelAdmin, self).__init__(parent_model=None, admin_site=admin_site) 352 | 353 | def get_formset(self, request, obj=None, **kwargs): 354 | if 'fields' in kwargs: 355 | fields = kwargs.pop('fields') 356 | else: 357 | fields = flatten_fieldsets(self.get_fieldsets(request, obj)) 358 | if self.exclude is None: 359 | exclude = [] 360 | else: 361 | exclude = list(self.exclude) 362 | exclude.extend(self.get_readonly_fields(request, obj)) 363 | if self.exclude is None and hasattr(self.form, '_meta') and self.form._meta.exclude: 364 | # Take the custom ModelForm's Meta.exclude into account only if the 365 | # InlineModelAdmin doesn't define its own. 366 | exclude.extend(self.form._meta.exclude) 367 | # If exclude is an empty list we use None, since that's the actual 368 | # default. 369 | exclude = exclude or None 370 | can_delete = self.can_delete and self.has_delete_permission(request, obj) 371 | defaults = { 372 | "form": self.form, 373 | "formset": self.formset, 374 | "fields": fields, 375 | "exclude": exclude, 376 | "formfield_callback": partial(self.formfield_for_dbfield, request=request), 377 | "extra": self.get_extra(request, obj, **kwargs), 378 | "min_num": self.get_min_num(request, obj, **kwargs), 379 | "max_num": self.get_max_num(request, obj, **kwargs), 380 | "can_delete": can_delete, 381 | } 382 | 383 | defaults.update(kwargs) 384 | base_model_form = defaults['form'] 385 | 386 | class DeleteProtectedModelForm(base_model_form): 387 | 388 | def hand_clean_DELETE(self): 389 | """ 390 | We don't validate the 'DELETE' field itself because on 391 | templates it's not rendered using the field information, but 392 | just using a generic "deletion_field" of the InlineModelAdmin. 393 | """ 394 | if self.cleaned_data.get(DELETION_FIELD_NAME, False): 395 | using = router.db_for_write(self._meta.model) 396 | collector = NestedObjects(using=using) 397 | if self.instance.pk is None: 398 | return 399 | collector.collect([self.instance]) 400 | if collector.protected: 401 | objs = [] 402 | for p in collector.protected: 403 | objs.append( 404 | # Translators: Model verbose name and instance representation, 405 | # suitable to be an item in a list. 406 | _('%(class_name)s %(instance)s') % { 407 | 'class_name': p._meta.verbose_name, 408 | 'instance': p} 409 | ) 410 | params = {'class_name': self._meta.model._meta.verbose_name, 411 | 'instance': self.instance, 412 | 'related_objects': get_text_list(objs, _('and'))} 413 | msg = _("Deleting %(class_name)s %(instance)s would require " 414 | "deleting the following protected related objects: " 415 | "%(related_objects)s") 416 | raise ValidationError(msg, code='deleting_protected', params=params) 417 | 418 | def is_valid(self): 419 | result = super(DeleteProtectedModelForm, self).is_valid() 420 | self.hand_clean_DELETE() 421 | return result 422 | 423 | defaults['form'] = DeleteProtectedModelForm 424 | 425 | if defaults['fields'] is None and not modelform_defines_fields(defaults['form']): 426 | defaults['fields'] = forms.ALL_FIELDS 427 | 428 | return modelformset_factory(self.model, **defaults) 429 | 430 | 431 | class StackedBulkInlineModelAdmin(BulkInlineModelAdmin): 432 | template = 'admin/edit_inline/stacked.html' 433 | 434 | 435 | class TabularBulkInlineModelAdmin(BulkInlineModelAdmin): 436 | template = 'admin/edit_inline/tabular.html' 437 | 438 | 439 | class _ListQueryset(list): 440 | ordered = True 441 | --------------------------------------------------------------------------------