├── .coveragerc ├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── admin_enhancer ├── __init__.py ├── admin.py ├── locale │ └── fr │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── models.py ├── static │ └── admin_enhancer │ │ ├── css │ │ └── related-widget-wrapper.css │ │ └── js │ │ └── related-widget-wrapper.js ├── templates │ └── admin_enhancer │ │ ├── dismiss-change-related-popup.html │ │ ├── dismiss-delete-related-popup.html │ │ └── related-widget-wrapper.html └── widgets.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── admin.py ├── models.py ├── settings.py ├── tests.py └── urls.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = admin_enhancer 3 | branch = True 4 | omit = admin_enhancer/*tests* 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | syntax:glob 2 | *~ 3 | *.db 4 | *.pyc 5 | *.orig 6 | .pydevproject 7 | .settings/* 8 | .DS_Store 9 | dist/* 10 | django_admin_enhancer.egg-info/* 11 | .coverage 12 | .tox 13 | build/* -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - 2.6 5 | - 2.7 6 | - 3.2 7 | - 3.3 8 | - 3.4 9 | 10 | env: 11 | - DJANGO=1.4 12 | - DJANGO=1.5 13 | - DJANGO=1.6 14 | - DJANGO=1.7 15 | 16 | matrix: 17 | exclude: 18 | - python: 2.6 19 | env: DJANGO=1.7 20 | - python: 3.2 21 | env: DJANGO=1.4 22 | - python: 3.3 23 | env: DJANGO=1.4 24 | - python: 3.4 25 | env: DJANGO=1.4 26 | - python: 3.4 27 | env: DJANGO=1.5 28 | - python: 3.4 29 | env: DJANGO=1.6 30 | allow_failures: 31 | - python: 3.2 32 | - python: 3.3 33 | - python: 3.4 34 | 35 | install: 36 | - pip install tox coveralls 37 | 38 | before_script: 39 | - "export DISPLAY=:99.0" 40 | - "sh -e /etc/init.d/xvfb start" 41 | - sleep 3 # give xvfb some time to start 42 | 43 | script: 44 | - tox -e `python -c 'import sys,os;print("py%d%d-"%sys.version_info[0:2]+os.environ["DJANGO"])'` 45 | 46 | after_success: 47 | - coverage report -m 48 | - coveralls 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Simon Charette 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | recursive-include admin_enhancer/locale * 4 | recursive-include admin_enhancer/static * 5 | recursive-include admin_enhancer/templates * -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-admin-enhancer 2 | ===================== 3 | 4 | .. image:: https://travis-ci.org/charettes/django-admin-enhancer.svg?branch=master 5 | :target: https://travis-ci.org/charettes/django-admin-enhancer 6 | :alt: Build Status 7 | 8 | .. image:: https://coveralls.io/repos/charettes/django-admin-enhancer/badge.svg?branch=master 9 | :target: https://coveralls.io/r/charettes/django-admin-enhancer?branch=master 10 | :alt: Coverage Status 11 | 12 | Overview 13 | -------- 14 | 15 | A simple django app that provided change and deletion links to FK fields 16 | in the admin before tickets 17 | `#13163 `__ and 18 | `#13165 `__ were fixed. 19 | 20 | Note that this apps works with 1.4 <= Django < 1.8 since both ticket have been solved and merged into Django 1.8. 21 | 22 | Display 23 | ------- 24 | 25 | .. figure:: https://dl.dropbox.com/u/2759157/selected.png 26 | :alt: Selected 27 | 28 | selected 29 | .. figure:: https://dl.dropbox.com/u/2759157/empty.png 30 | :alt: Empty 31 | 32 | Usage 33 | ----- 34 | 35 | The recommended way to install ``django-admin-enhancer`` is via 36 | `pip `__: 37 | 38 | .. code:: sh 39 | 40 | pip install django-admin-enhancer 41 | 42 | Add ``'admin_enhancer'`` to your ``INSTALLED_APPS`` to avoid getting 43 | ``TemplateDoesNotExist`` errors. 44 | 45 | Make sure to mix ``EnhancedModelAdminMixin`` when dealing with 46 | ``django.contrib.admin.ModelAdmin`` subclasses and 47 | ``EnhancedAdminMixin`` when dealing with 48 | ``django.contrib.admin.InlineModelAdmin`` at both ends of the 49 | relationship. The mixins are located at ``admin_enhancer.admin``. 50 | 51 | If edition and deletion controls appears but the popup is not closed nor 52 | is the select box updated your ``ModelAdmin`` subclass referenced by the 53 | field in question is probably not mixed with 54 | ``EnhancedModelAdminMixin``. 55 | 56 | For some examples take a look 57 | `here `__. 58 | -------------------------------------------------------------------------------- /admin_enhancer/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = (1, 0, 0) 2 | -------------------------------------------------------------------------------- /admin_enhancer/admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.contrib import admin 4 | from django.http import HttpResponseRedirect 5 | from django.shortcuts import render_to_response 6 | 7 | from .widgets import RelatedFieldWidgetWrapper 8 | 9 | 10 | class EnhancedAdminMixin(object): 11 | enhance_exclude = () 12 | related_widget_wrapper = RelatedFieldWidgetWrapper 13 | 14 | def formfield_for_dbfield(self, db_field, **kwargs): 15 | formfield = super(EnhancedAdminMixin, self).formfield_for_dbfield(db_field, **kwargs) 16 | if (formfield and db_field.name not in self.enhance_exclude and 17 | isinstance(formfield.widget, admin.widgets.RelatedFieldWidgetWrapper)): 18 | request = kwargs.pop('request', None) 19 | related_modeladmin = self.admin_site._registry.get(db_field.rel.to) 20 | if related_modeladmin: 21 | can_change_related = related_modeladmin.has_change_permission(request) 22 | can_delete_related = related_modeladmin.has_delete_permission(request) 23 | widget = self.related_widget_wrapper.wrap(formfield.widget, 24 | can_change_related, 25 | can_delete_related) 26 | formfield.widget = widget 27 | return formfield 28 | 29 | def delete_view(self, request, object_id, extra_context=None): 30 | """Sets is_popup context variable to hide admin header.""" 31 | if not extra_context: 32 | extra_context = {} 33 | extra_context['is_popup'] = request.REQUEST.get('_popup', 0) 34 | return super(EnhancedAdminMixin, self).delete_view(request, object_id, extra_context) 35 | 36 | 37 | class EnhancedModelAdminMixin(EnhancedAdminMixin): 38 | def response_change(self, request, obj): 39 | if '_popup' in request.REQUEST: 40 | return render_to_response( 41 | 'admin_enhancer/dismiss-change-related-popup.html', {'obj': obj} 42 | ) 43 | else: 44 | return super(EnhancedModelAdminMixin, self).response_change(request, obj) 45 | 46 | def delete_view(self, request, object_id, extra_context=None): 47 | delete_view_response = super(EnhancedModelAdminMixin, self).delete_view(request, object_id, extra_context) 48 | if (request.POST and '_popup' in request.REQUEST and 49 | isinstance(delete_view_response, HttpResponseRedirect)): 50 | return render_to_response( 51 | 'admin_enhancer/dismiss-delete-related-popup.html', {'object_id': object_id} 52 | ) 53 | else: 54 | return delete_view_response 55 | -------------------------------------------------------------------------------- /admin_enhancer/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charettes/django-admin-enhancer/ed4eb944cd1afafcad4e42f41cfc3de1d8bac4d1/admin_enhancer/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /admin_enhancer/locale/fr/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 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2012-05-06 18:28-0400\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: Simon Charette \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: fr-CA\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n > 1)\n" 20 | 21 | #: widgets.py:45 22 | msgid "Change related model" 23 | msgstr "Modifier" 24 | 25 | #: widgets.py:48 26 | msgid "Add another" 27 | msgstr "Ajouter" 28 | 29 | #: widgets.py:54 30 | msgid "Delete related model" 31 | msgstr "Supprimer" 32 | -------------------------------------------------------------------------------- /admin_enhancer/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charettes/django-admin-enhancer/ed4eb944cd1afafcad4e42f41cfc3de1d8bac4d1/admin_enhancer/models.py -------------------------------------------------------------------------------- /admin_enhancer/static/admin_enhancer/css/related-widget-wrapper.css: -------------------------------------------------------------------------------- 1 | .related-widget-wrapper-link { 2 | opacity: 0.3; 3 | } 4 | 5 | .related-widget-wrapper-link:link { 6 | opacity: 1; 7 | } 8 | -------------------------------------------------------------------------------- /admin_enhancer/static/admin_enhancer/js/related-widget-wrapper.js: -------------------------------------------------------------------------------- 1 | django.jQuery(document).ready(function($){ 2 | 3 | window.dismissChangeRelatedPopup = function(win, objId, newRepr) { 4 | objId = html_unescape(objId); 5 | newRepr = html_unescape(newRepr); 6 | var id = windowname_to_id(win.name).replace(/^edit_/, ''), 7 | selects = $(interpolate('#%s, #%s_from, #%s_to', [id, id, id])); 8 | selects.find('option').each(function(){ 9 | if (this.value == objId) this.innerHTML = newRepr; 10 | }); 11 | win.close(); 12 | }; 13 | 14 | if (!dismissAddAnotherPopup.original) { 15 | var originalDismissAddAnotherPopup = dismissAddAnotherPopup; 16 | dismissAddAnotherPopup = function(win, newId, newRepr) { 17 | originalDismissAddAnotherPopup(win, newId, newRepr); 18 | newId = html_unescape(newId); 19 | newRepr = html_unescape(newRepr); 20 | $('#' + windowname_to_id(win.name)).trigger('change'); 21 | }; 22 | dismissAddAnotherPopup.original = originalDismissAddAnotherPopup; 23 | } 24 | 25 | window.dismissDeleteRelatedPopup = function(win, objId) { 26 | objId = html_unescape(objId); 27 | var id = windowname_to_id(win.name).replace(/^delete_/, ''), 28 | selects = $(interpolate('#%s, #%s_from, #%s_to', [id, id, id])); 29 | selects.find('option').each(function(){ 30 | if (this.value == objId) $(this).remove(); 31 | }).trigger('change'); 32 | win.close(); 33 | }; 34 | 35 | var relatedWidgetCSSSelector = '.related-widget-wrapper-change-link, .related-widget-wrapper-delete-link', 36 | hrefTemplateAttr = 'data-href-template'; 37 | 38 | $('#container').delegate('.related-widget-wrapper', 'change', function(event){ 39 | var siblings = $(this).nextAll(relatedWidgetCSSSelector), 40 | value = event.target.value; 41 | if (!siblings.length) return; 42 | if (value) { 43 | siblings.each(function(){ 44 | var elm = $(this); 45 | elm.attr('href', elm.attr(hrefTemplateAttr).replace('__fk__', value)); 46 | }); 47 | } else siblings.removeAttr('href'); 48 | }); 49 | 50 | $('#container').delegate('.related-widget-wrapper-link', 'click', function(event){ 51 | if (this.href) { 52 | return showAddAnotherPopup(this); 53 | } else return false; 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /admin_enhancer/templates/admin_enhancer/dismiss-change-related-popup.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | -------------------------------------------------------------------------------- /admin_enhancer/templates/admin_enhancer/dismiss-delete-related-popup.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | -------------------------------------------------------------------------------- /admin_enhancer/templates/admin_enhancer/related-widget-wrapper.html: -------------------------------------------------------------------------------- 1 | {% load static from staticfiles %} 2 | {{ widget }} 3 | {% if can_change_related %} 4 | 5 | {{ change_help_text }} 6 | 7 | {% endif %} 8 | {% if can_add_related %} 9 | 10 | {{ add_help_text }} 11 | 12 | {% endif %} 13 | {% if can_delete_related %} 14 | 15 | {{ delete_help_text }} 16 | 17 | {% endif %} 18 | -------------------------------------------------------------------------------- /admin_enhancer/widgets.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.contrib.admin.widgets import RelatedFieldWidgetWrapper 4 | from django.core.urlresolvers import reverse 5 | from django.db.models import CASCADE 6 | from django.template.loader import render_to_string 7 | from django.utils.safestring import mark_safe 8 | from django.utils.translation import ugettext_lazy as _ 9 | 10 | 11 | class RelatedFieldWidgetWrapper(RelatedFieldWidgetWrapper): 12 | class Media: 13 | css = { 14 | 'screen': ('admin_enhancer/css/related-widget-wrapper.css',) 15 | } 16 | js = ('admin_enhancer/js/related-widget-wrapper.js',) 17 | 18 | def __init__(self, widget, rel, *args, **kwargs): 19 | multiple = getattr(widget, 'allow_multiple_selected', False) 20 | cascade = getattr(rel, 'on_delete', None) is CASCADE 21 | can_change_related = kwargs.pop('can_change_related', None) 22 | can_delete_related = kwargs.pop('can_delete_related', None) 23 | super(RelatedFieldWidgetWrapper, self).__init__(widget, rel, *args, **kwargs) 24 | self.can_change_related = not multiple and can_change_related 25 | self.can_delete_related = not multiple and not cascade and can_delete_related 26 | 27 | @classmethod 28 | def wrap(cls, wrapper, can_change_related, can_delete_related): 29 | return cls( 30 | wrapper.widget, 31 | wrapper.rel, 32 | wrapper.admin_site, 33 | can_add_related=wrapper.can_add_related, 34 | can_change_related=can_change_related, 35 | can_delete_related=can_delete_related, 36 | ) 37 | 38 | def get_related_url(self, rel_to, info, action, args=None): 39 | return reverse( 40 | "admin:%s_%s_%s" % (info + (action,)), current_app=self.admin_site.name, args=args 41 | ) 42 | 43 | def render(self, name, value, attrs=None, *args, **kwargs): 44 | if attrs is None: 45 | attrs = {} 46 | rel_to = self.rel.to 47 | info = (rel_to._meta.app_label, rel_to._meta.object_name.lower()) 48 | self.widget.choices = self.choices 49 | attrs['class'] = ' '.join((attrs.get('class', ''), 'related-widget-wrapper')) 50 | context = { 51 | 'widget': self.widget.render(name, value, attrs=attrs, *args, **kwargs), 52 | 'name': name, 53 | 'can_change_related': self.can_change_related, 54 | 'can_add_related': self.can_add_related, 55 | 'can_delete_related': self.can_delete_related, 56 | } 57 | 58 | if self.can_change_related: 59 | if value: 60 | context['change_url'] = self.get_related_url(rel_to, info, 'change', [value]) 61 | template = self.get_related_url(rel_to, info, 'change', ['__fk__']) 62 | context.update( 63 | change_url_template=template, 64 | change_help_text=_('Change related model') 65 | ) 66 | 67 | if self.can_add_related: 68 | context.update( 69 | add_url=self.get_related_url(rel_to, info, 'add'), 70 | add_help_text=_('Add another') 71 | ) 72 | 73 | if self.can_delete_related: 74 | if value: 75 | context['delete_url'] = self.get_related_url(rel_to, info, 'delete', [value]) 76 | template = self.get_related_url(rel_to, info, 'delete', ['__fk__']) 77 | context.update( 78 | delete_url_template=template, 79 | delete_help_text=_('Delete related model') 80 | ) 81 | 82 | return mark_safe(render_to_string('admin_enhancer/related-widget-wrapper.html', context)) 83 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 119 3 | 4 | [metadata] 5 | license-file = LICENSE 6 | 7 | [wheel] 8 | universal = 1 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | from __future__ import unicode_literals 3 | 4 | from setuptools import find_packages, setup 5 | 6 | from admin_enhancer import __version__ 7 | 8 | 9 | with open('README.rst') as file_: 10 | long_description = file_.read() 11 | 12 | setup( 13 | name='django-admin-enhancer', 14 | version='.'.join(str(v) for v in __version__), 15 | description='A simple django app that provides change and deletion links to FK fields in the admin.', 16 | long_description=long_description, 17 | url='https://github.com/charettes/django-admin-enhancer', 18 | author='Simon Charette', 19 | author_email='charette.s+admin-enhancer@gmail.com', 20 | license='MIT License', 21 | classifiers=[ 22 | 'Development Status :: 5 - Production/Stable', 23 | 'Environment :: Web Environment', 24 | 'Framework :: Django', 25 | 'Intended Audience :: Developers', 26 | 'License :: OSI Approved :: MIT License', 27 | 'Operating System :: OS Independent', 28 | 'Programming Language :: Python', 29 | 'Programming Language :: Python :: 2', 30 | 'Programming Language :: Python :: 2.6', 31 | 'Programming Language :: Python :: 2.7', 32 | 'Programming Language :: Python :: 3', 33 | 'Programming Language :: Python :: 3.2', 34 | 'Programming Language :: Python :: 3.3', 35 | 'Programming Language :: Python :: 3.4', 36 | 'Topic :: Internet :: WWW/HTTP', 37 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 38 | 'Topic :: Internet :: WWW/HTTP :: WSGI', 39 | 'Topic :: Software Development :: Libraries :: Application Frameworks', 40 | 'Topic :: Software Development :: Libraries :: Python Modules', 41 | ], 42 | include_package_data=True, 43 | keywords=['django admin foreign'], 44 | packages=find_packages(exclude=['tests', 'tests.*']), 45 | install_requires=['django>=1.4,<1.8'], 46 | ) 47 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charettes/django-admin-enhancer/ed4eb944cd1afafcad4e42f41cfc3de1d8bac4d1/tests/__init__.py -------------------------------------------------------------------------------- /tests/admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.contrib.admin import AdminSite, ModelAdmin, TabularInline 4 | 5 | from admin_enhancer import admin as enhanced_admin 6 | 7 | from .models import Author, Book, Character, Theme 8 | 9 | 10 | site = AdminSite() 11 | 12 | 13 | class EnhancedModelAdmin(enhanced_admin.EnhancedModelAdminMixin, ModelAdmin): 14 | pass 15 | 16 | 17 | class CharacterInline(enhanced_admin.EnhancedAdminMixin, TabularInline): 18 | model = Character 19 | 20 | 21 | class BookAdmin(EnhancedModelAdmin): 22 | inlines = (CharacterInline,) 23 | filter_horizontal = ('themes',) 24 | 25 | 26 | site.register(Author, EnhancedModelAdmin) 27 | site.register(Book, BookAdmin) 28 | site.register(Theme, EnhancedModelAdmin) 29 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.db import models 4 | 5 | 6 | class Author(models.Model): 7 | name = models.CharField(max_length=100) 8 | 9 | def __str__(self): 10 | return self.name 11 | 12 | def __unicode__(self): 13 | return self.name 14 | 15 | 16 | class Collection(models.Model): 17 | name = models.CharField(max_length=100) 18 | 19 | def __str__(self): 20 | return self.name 21 | 22 | def __unicode__(self): 23 | return self.name 24 | 25 | 26 | class Book(models.Model): 27 | author = models.ForeignKey(Author, null=True, on_delete=models.SET_NULL) 28 | collection = models.ForeignKey(Collection, null=True, blank=True) 29 | themes = models.ManyToManyField('Theme') 30 | 31 | 32 | class Theme(models.Model): 33 | name = models.CharField(max_length=100) 34 | 35 | def __str__(self): 36 | return self.name 37 | 38 | def __unicode__(self): 39 | return self.name 40 | 41 | 42 | class Character(models.Model): 43 | name = models.CharField(max_length=100) 44 | book = models.ForeignKey(Book) 45 | main_theme = models.ForeignKey(Theme) 46 | 47 | def __str__(self): 48 | return self.name 49 | 50 | def __unicode__(self): 51 | return self.name 52 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.conf.global_settings import TEST_RUNNER 4 | 5 | 6 | DEBUG = True 7 | 8 | DATABASES = { 9 | 'default': { 10 | 'ENGINE': 'django.db.backends.sqlite3', 11 | }, 12 | } 13 | 14 | SITE_ID = 1 15 | 16 | ROOT_URLCONF = 'tests.urls' 17 | 18 | INSTALLED_APPS = [ 19 | 'django.contrib.auth', 20 | 'django.contrib.contenttypes', 21 | 'django.contrib.sessions', 22 | 'django.contrib.sites', 23 | 'django.contrib.messages', 24 | 'django.contrib.staticfiles', 25 | 'django.contrib.admin', 26 | 'admin_enhancer', 27 | 'tests', 28 | ] 29 | 30 | STATIC_URL = '/static/' 31 | 32 | STATICFILES_FINDERS = [ 33 | 'django.contrib.staticfiles.finders.FileSystemFinder', 34 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 35 | ] 36 | 37 | MIDDLEWARE_CLASSES = [ 38 | 'django.middleware.common.CommonMiddleware', 39 | 'django.contrib.sessions.middleware.SessionMiddleware', 40 | 'django.middleware.csrf.CsrfViewMiddleware', 41 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 42 | 'django.contrib.messages.middleware.MessageMiddleware', 43 | ] 44 | 45 | if not TEST_RUNNER.endswith('DiscoverRunner'): 46 | TEST_RUNNER = str('discover_runner.DiscoverRunner') 47 | 48 | SECRET_KEY = 'not-anymore' 49 | 50 | TEMPLATE_LOADERS = [ 51 | 'django.template.loaders.filesystem.Loader', 52 | 'django.template.loaders.app_directories.Loader', 53 | ] 54 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from contextlib import contextmanager 4 | import time 5 | 6 | from django import forms 7 | from django.conf import settings 8 | from django.contrib.admin import site 9 | from django.contrib.admin.tests import AdminSeleniumWebDriverTestCase 10 | from django.contrib.auth.models import User 11 | from django.core.urlresolvers import reverse 12 | from django.test import SimpleTestCase 13 | 14 | from admin_enhancer import widgets 15 | 16 | from .models import Book 17 | 18 | 19 | class InteractionTest(AdminSeleniumWebDriverTestCase): 20 | available_apps = settings.INSTALLED_APPS 21 | 22 | def setUp(self): 23 | super(InteractionTest, self).setUp() 24 | User.objects.create_superuser('super', '', 'secret') 25 | 26 | def wait_for_popup(self, name): 27 | def popup_is_loaded(driver): 28 | return driver.current_window_handle == name 29 | self.wait_until(popup_is_loaded) 30 | 31 | @contextmanager 32 | def handle_popup(self, trigger): 33 | initial_window_handle = self.selenium.current_window_handle 34 | window_handles = set(self.selenium.window_handles) 35 | try: 36 | trigger() 37 | self.wait_until(lambda driver: set(driver.window_handles) != window_handles) 38 | new_window_handle = (set(self.selenium.window_handles) - window_handles).pop() 39 | self.selenium.switch_to.window(new_window_handle) 40 | yield new_window_handle 41 | finally: 42 | time.sleep(1) 43 | self.selenium.switch_to.window(initial_window_handle) 44 | 45 | def test_widget_interactions(self): 46 | self.admin_login('super', 'secret') 47 | driver = self.selenium 48 | driver.set_page_load_timeout(10) 49 | driver.get("%s%s" % (self.live_server_url, reverse('admin:tests_book_add'))) 50 | 51 | author_select = driver.find_element_by_id('id_author') 52 | edit_author_btn = driver.find_element_by_id('edit_id_author') 53 | add_author_btn = driver.find_element_by_id('add_id_author') 54 | delete_author_btn = driver.find_element_by_id('delete_id_author') 55 | 56 | self.assertIsNone(edit_author_btn.get_attribute('href')) 57 | self.assertIsNone(delete_author_btn.get_attribute('href')) 58 | 59 | def author_options(): 60 | author_options = author_select.find_elements_by_tag_name('option') 61 | options_label = [] 62 | selected_option_label = None 63 | for option in author_options: 64 | label = option.get_attribute('innerHTML') 65 | options_label.append(label) 66 | if option.get_attribute('selected'): 67 | selected_option_label = label 68 | return selected_option_label, options_label 69 | 70 | def interact(button, name): 71 | with self.handle_popup(button.click): 72 | driver.implicitly_wait(1) 73 | driver.find_element_by_id('id_name').clear() 74 | driver.find_element_by_id('id_name').send_keys(name) 75 | driver.find_element_by_name('_save').click() 76 | selected_option_label, options_label = author_options() 77 | self.assertEqual(['---------', name], options_label) 78 | self.assertEqual(name, selected_option_label) 79 | 80 | interact(add_author_btn, 'David Abraham') 81 | 82 | self.assertIsNotNone(edit_author_btn.get_attribute('href')) 83 | self.assertIsNotNone(delete_author_btn.get_attribute('href')) 84 | 85 | interact(edit_author_btn, 'David Abram') 86 | 87 | with self.handle_popup(delete_author_btn.click): 88 | driver.find_element_by_css_selector('input[type="submit"]').click() 89 | 90 | selected_option_label, options_label = author_options() 91 | self.assertEqual(['---------'], options_label) 92 | self.assertEqual('---------', selected_option_label) 93 | 94 | self.assertIsNone(edit_author_btn.get_attribute('href')) 95 | self.assertIsNone(delete_author_btn.get_attribute('href')) 96 | 97 | 98 | class RelatedFieldWidgetWrapperTests(SimpleTestCase): 99 | def test_select_multiple_widget_cant_change_delete_related(self): 100 | rel = Book._meta.get_field('themes').rel 101 | widget = forms.SelectMultiple() 102 | wrapper = widgets.RelatedFieldWidgetWrapper( 103 | widget, rel, site, 104 | can_add_related=True, 105 | can_change_related=True, 106 | can_delete_related=True, 107 | ) 108 | self.assertTrue(wrapper.can_add_related) 109 | self.assertFalse(wrapper.can_change_related) 110 | self.assertFalse(wrapper.can_delete_related) 111 | 112 | def test_on_delete_cascade_rel_cant_delete_related(self): 113 | rel = Book._meta.get_field('collection').rel 114 | widget = forms.Select() 115 | wrapper = widgets.RelatedFieldWidgetWrapper( 116 | widget, rel, site, 117 | can_add_related=True, 118 | can_change_related=True, 119 | can_delete_related=True, 120 | ) 121 | self.assertTrue(wrapper.can_add_related) 122 | self.assertTrue(wrapper.can_change_related) 123 | self.assertFalse(wrapper.can_delete_related) 124 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.conf.urls import include, url 4 | 5 | from .admin import site 6 | 7 | 8 | urlpatterns = [ 9 | url(r'^admin/', include(site.urls)), 10 | ] 11 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | args_are_paths = false 3 | envlist = 4 | {py26}-{1.4,1.5,1.6}, 5 | {py27}-{1.4,1.5,1.6,1.7}, 6 | {py32,py33}-{1.6,1.7}, 7 | py34-{1.7} 8 | 9 | [testenv] 10 | basepython = 11 | py26: python2.6 12 | py27: python2.7 13 | py32: python3.2 14 | py33: python3.3 15 | py34: python3.4 16 | usedevelop = true 17 | setenv = 18 | DJANGO_SELENIUM_TESTS=1 19 | commands = 20 | python -R -Wonce {envbindir}/coverage run {envbindir}/django-admin.py test tests -v2 --settings=tests.settings {posargs} 21 | coverage report 22 | deps = 23 | coverage 24 | selenium 25 | 1.4: Django>=1.4,<1.5 26 | 1.4: django-discover-runner 27 | 1.5: Django>=1.5,<1.6 28 | 1.5: django-discover-runner 29 | 1.6: Django>=1.6,<1.7 30 | 1.7: Django>=1.7,<1.8 31 | --------------------------------------------------------------------------------