├── .gitignore ├── .travis.yml ├── CHANGES.md ├── LICENSE.txt ├── MANIFEST ├── Makefile ├── README.md ├── requirements.txt ├── setup.cfg ├── setup.py ├── singlemodeladmin └── __init__.py └── test ├── app ├── __init__.py ├── admin.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py └── tests.py ├── manage.py └── project ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.sqlite3 3 | *.egg-info/ 4 | dist/ 5 | env/ 6 | build/ 7 | 8 | \.idea/ 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | matrix: 4 | include: 5 | # Python 2.6 is supported by Django < 1.7 6 | - python: 2.6 7 | env: DJANGO_VERSION=1.4.22 8 | - python: 2.6 9 | env: DJANGO_VERSION=1.5.12 10 | - python: 2.6 11 | env: DJANGO_VERSION=1.6.11 12 | # Python 2.7 is supported by Django < 2.0 13 | - python: 2.7 14 | env: DJANGO_VERSION=1.4.22 15 | - python: 2.7 16 | env: DJANGO_VERSION=1.5.12 17 | - python: 2.7 18 | env: DJANGO_VERSION=1.6.11 19 | - python: 2.7 20 | env: DJANGO_VERSION=1.7.11 21 | - python: 2.7 22 | env: DJANGO_VERSION=1.8.19 23 | - python: 2.7 24 | env: DJANGO_VERSION=1.9.13 25 | - python: 2.7 26 | env: DJANGO_VERSION=1.10.8 27 | - python: 2.7 28 | env: DJANGO_VERSION=1.11.15 29 | # Python 3.3 is supported by Django >= 1.5 and Django < 1.9 30 | - python: 3.3 31 | env: DJANGO_VERSION=1.5.12 32 | - python: 3.3 33 | env: DJANGO_VERSION=1.6.11 34 | - python: 3.3 35 | env: DJANGO_VERSION=1.7.11 36 | - python: 3.3 37 | env: DJANGO_VERSION=1.8.19 38 | # Python 3.4 is supported by Django >= 1.5 and Django < 1.11 39 | - python: 3.4 40 | env: DJANGO_VERSION=1.5.12 41 | - python: 3.4 42 | env: DJANGO_VERSION=1.6.11 43 | - python: 3.4 44 | env: DJANGO_VERSION=1.7.11 45 | - python: 3.4 46 | env: DJANGO_VERSION=1.8.19 47 | - python: 3.4 48 | env: DJANGO_VERSION=1.9.13 49 | - python: 3.4 50 | env: DJANGO_VERSION=1.10.8 51 | # Python 3.5 is supported by Django >= 1.8 52 | - python: 3.5 53 | env: DJANGO_VERSION=1.8.19 54 | - python: 3.5 55 | env: DJANGO_VERSION=1.9.13 56 | - python: 3.5 57 | env: DJANGO_VERSION=1.10.8 58 | - python: 3.5 59 | env: DJANGO_VERSION=1.11.15 60 | - python: 3.5 61 | env: DJANGO_VERSION=2.0.8 62 | - python: 3.5 63 | env: DJANGO_VERSION=2.1 64 | # Python 3.6 is supported by Django >= 1.11 65 | - python: 3.6 66 | env: DJANGO_VERSION=1.11.15 67 | - python: 3.6 68 | env: DJANGO_VERSION=2.0.8 69 | - python: 3.6 70 | env: DJANGO_VERSION=2.1 71 | # Python 3.7 is supported by Django >= 2.0 72 | # The dist and sudo are needed to run 3.7 73 | # on Travis until 3.7 is officially supported. 74 | - python: 3.7 75 | env: DJANGO_VERSION=2.0.8 76 | dist: xenial 77 | sudo: true 78 | - python: 3.7 79 | env: DJANGO_VERSION=2.1 80 | dist: xenial 81 | sudo: true 82 | 83 | install: 84 | - pip install --pre -q Django==$DJANGO_VERSION 85 | - pip install -r requirements.txt 86 | - pip install . 87 | 88 | script: 89 | - make lint 90 | - make test 91 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | Version 0.9 5 | ----------- 6 | 7 | * Add classifiers 8 | * Add support for Django 2.0 9 | * Drop support for Django 2.0b1 10 | * Add support for Python 3.7 11 | 12 | 13 | Version 0.8 14 | ----------- 15 | 16 | * Add support for Django 2.0b1 17 | 18 | Version 0.7 19 | ----------- 20 | 21 | * Add support for Django 1.11. 22 | * Add support for Python 3.6. 23 | 24 | Version 0.6 25 | ----------- 26 | 27 | * Add support for Django 1.10. 28 | * Drop support for Python 3.2. 29 | 30 | Version 0.5 31 | ----------- 32 | 33 | * Add support for Python 3.4 and 3.5. 34 | * Add support for Django 1.9. 35 | 36 | Version 0.4 37 | ----------- 38 | 39 | * Ensure support for Python 2.6, 2.7, 3.2, and 3.3. 40 | 41 | Version 0.3 42 | ----------- 43 | 44 | * Add support for Django 1.8. 45 | 46 | Version 0.2 47 | ----------- 48 | 49 | * Add support for Django 1.6. 50 | * Hide the "Add" button in the UI if a model exists. 51 | 52 | Version 0.1 53 | ----------- 54 | 55 | * Initial release. 56 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2011-2018 Alexander Meng 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST: -------------------------------------------------------------------------------- 1 | # file GENERATED by distutils, do NOT edit 2 | setup.cfg 3 | setup.py 4 | singlemodeladmin/__init__.py 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | 3 | install: 4 | pip install . 5 | pip install -r requirements.txt 6 | 7 | test: 8 | cd test && python manage.py test app 9 | 10 | lint: 11 | flake8 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Django Single ModelAdmin 2 | ======================== 3 | 4 | [![PyPI Version](https://img.shields.io/pypi/v/singlemodeladmin.svg)][pypi] 5 | [![Build Status](http://img.shields.io/travis/AMeng/django-single-model-admin.svg)][travis] 6 | 7 | [travis]: http://travis-ci.org/AMeng/django-single-model-admin 8 | [pypi]: https://pypi.python.org/pypi/singlemodeladmin 9 | 10 | A subclass of Django's `ModelAdmin` for use with models that are only meant to 11 | have one record. This is useful for things like site-wide settings. 12 | 13 | Usage: 14 | ------ 15 | 16 | Register a model with `SingleModelAdmin`: 17 | 18 | ```python 19 | from django.contrib import admin 20 | from singlemodeladmin import SingleModelAdmin 21 | from my_app.models import MyModel 22 | 23 | admin.site.register(MyModel, SingleModelAdmin) 24 | ``` 25 | You can also subclass `SingleModelAdmin` instead of Django's `ModelAdmin`, and 26 | it will work as expected: 27 | 28 | ```python 29 | from django.contrib import admin 30 | from singlemodeladmin import SingleModelAdmin 31 | from my_app.models import MyModel 32 | 33 | class MyModelAdmin(SingleModelAdmin): 34 | list_display = ['my_field'] 35 | 36 | admin.site.register(MyModel, MyModelAdmin) 37 | ``` 38 | 39 | Installation: 40 | ------------- 41 | ``` 42 | pip install singlemodeladmin 43 | ``` 44 | 45 | Behavior: 46 | --------- 47 | 48 | * If there is only one object, the changelist will redirect to that object. 49 | * If there are no objects, the changelist will redirect to the add form. 50 | * If there are multiple objects, the changelist is displayed with a warning 51 | * Attempting to add a new record when there is already one will result in a 52 | warning and a redirect away from the add form. 53 | 54 | Supported Django Versions: 55 | -------------------------- 56 | * 1.4 57 | * 1.5 58 | * 1.6 59 | * 1.7 60 | * 1.8 61 | * 1.9 62 | * 1.10 63 | * 1.11 64 | * 2.0 65 | * 2.1 66 | 67 | Supported Python Versions: 68 | -------------------------- 69 | * 2.6 70 | * 2.7 71 | * 3.3 72 | * 3.4 73 | * 3.5 74 | * 3.6 75 | * 3.7 76 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=1.4 2 | flake8<=2.6.2 # Last version with Python 2.6 support. 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [flake8] 5 | exclude = env,build 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools # noqa: F401 2 | from distutils.core import setup 3 | 4 | setup( 5 | name='singlemodeladmin', 6 | packages=['singlemodeladmin'], 7 | version='0.9', 8 | description='ModelAdmin for models that are meant to have one record', 9 | author='Alexander Meng', 10 | author_email='alexbmeng@gmail.com', 11 | url='https://github.com/AMeng/django-single-model-admin', 12 | keywords=['django', 'model', 'admin'], 13 | install_requires=[ 14 | 'Django>=1.4' 15 | ], 16 | classifiers=[ 17 | "Development Status :: 5 - Production/Stable", 18 | "Framework :: Django", 19 | "Framework :: Django :: 1.4", 20 | "Framework :: Django :: 1.5", 21 | "Framework :: Django :: 1.6", 22 | "Framework :: Django :: 1.7", 23 | "Framework :: Django :: 1.8", 24 | "Framework :: Django :: 1.9", 25 | "Framework :: Django :: 1.10", 26 | "Framework :: Django :: 1.11", 27 | "Framework :: Django :: 2.0", 28 | "Framework :: Django :: 2.1", 29 | "Intended Audience :: Developers", 30 | "License :: OSI Approved :: MIT License", 31 | "Natural Language :: English", 32 | "Operating System :: OS Independent", 33 | "Programming Language :: Python", 34 | "Programming Language :: Python :: 2", 35 | "Programming Language :: Python :: 2.6", 36 | "Programming Language :: Python :: 2.7", 37 | "Programming Language :: Python :: 3", 38 | "Programming Language :: Python :: 3.5", 39 | "Programming Language :: Python :: 3.6", 40 | "Programming Language :: Python :: 3.7", 41 | "Topic :: Utilities" 42 | ] 43 | ) 44 | -------------------------------------------------------------------------------- /singlemodeladmin/__init__.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin, messages 2 | from django.core.exceptions import MultipleObjectsReturned 3 | from django.shortcuts import redirect 4 | try: 5 | from django.core.urlresolvers import reverse 6 | except ImportError: 7 | from django.urls import reverse 8 | 9 | 10 | class SingleModelAdmin(admin.ModelAdmin): 11 | 12 | """ 13 | Django ModelAdmin for models that are only meant to have one record. 14 | This is useful for a site-wide settings model, among other things. 15 | 16 | If there is only one object, the changelist will redirect to that object. 17 | If there are no objects, the changelist will redirect to the add form. 18 | If there are multiple objects, the changelist is displayed with a warning. 19 | 20 | Attempting to add a new record when there is already one will result in a 21 | warning and a redirect away from the add form. 22 | """ 23 | 24 | def _get_model_name(self): 25 | try: 26 | return self.model._meta.model_name 27 | except AttributeError: 28 | return self.model._meta.module_name 29 | 30 | def changelist_view(self, request, extra_context=None): 31 | app_and_model = '{0}_{1}'.format(self.model._meta.app_label, 32 | self._get_model_name()) 33 | try: 34 | instance = self.model.objects.get() 35 | except self.model.DoesNotExist: 36 | return redirect(reverse('admin:{0}_add'.format(app_and_model))) 37 | except MultipleObjectsReturned: 38 | warning = ('There are multiple instances of {0}. There should only' 39 | ' be one.').format(self._get_model_name()) 40 | messages.warning(request, warning, fail_silently=True) 41 | return super(SingleModelAdmin, self).changelist_view( 42 | request, extra_context=extra_context) 43 | else: 44 | return redirect(reverse('admin:{0}_change'.format(app_and_model), 45 | args=[instance.pk])) 46 | 47 | def add_view(self, request, form_url='', extra_context=None): 48 | if self.model.objects.count(): 49 | warning = ('Do not add additional instances of {0}. Only one is' 50 | ' needed.').format(self._get_model_name()) 51 | messages.warning(request, warning, fail_silently=True) 52 | return redirect(reverse('admin:{0}_{1}_changelist'.format( 53 | self.model._meta.app_label, 54 | self._get_model_name()))) 55 | return super(SingleModelAdmin, self).add_view( 56 | request, 57 | form_url=form_url, 58 | extra_context=extra_context) 59 | 60 | def has_add_permission(self, request): 61 | try: 62 | self.model.objects.get() 63 | except self.model.DoesNotExist: 64 | return super(SingleModelAdmin, self).has_add_permission(request) 65 | except MultipleObjectsReturned: 66 | pass 67 | return False 68 | -------------------------------------------------------------------------------- /test/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AMeng/django-single-model-admin/85c7dcbedcf9264bfdfab24fdb27c96afbf6a7ee/test/app/__init__.py -------------------------------------------------------------------------------- /test/app/admin.py: -------------------------------------------------------------------------------- 1 | from singlemodeladmin import SingleModelAdmin 2 | from django.contrib import admin 3 | from app.models import TestModel 4 | 5 | 6 | admin.site.register(TestModel, SingleModelAdmin) 7 | -------------------------------------------------------------------------------- /test/app/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from django.db import models, migrations 3 | 4 | 5 | class Migration(migrations.Migration): 6 | operations = [ 7 | migrations.CreateModel( 8 | name='TestModel', 9 | fields=[ 10 | ('id', models.AutoField(verbose_name='ID', 11 | serialize=False, 12 | auto_created=True, 13 | primary_key=True)), 14 | ('field', models.CharField(max_length=25)) 15 | ], 16 | bases=(models.Model,) 17 | ) 18 | ] 19 | -------------------------------------------------------------------------------- /test/app/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AMeng/django-single-model-admin/85c7dcbedcf9264bfdfab24fdb27c96afbf6a7ee/test/app/migrations/__init__.py -------------------------------------------------------------------------------- /test/app/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class TestModel(models.Model): 5 | field = models.CharField(max_length=25) 6 | -------------------------------------------------------------------------------- /test/app/tests.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.test import Client, TestCase 3 | try: 4 | from django.core.urlresolvers import reverse 5 | except ImportError: 6 | from django.urls import reverse 7 | 8 | from app.models import TestModel 9 | 10 | 11 | class AbstractTestCase(TestCase): 12 | def setUp(self): 13 | User.objects.create_superuser('user', 'email@test.com', 'password') 14 | self.client = Client() 15 | self.client.login(username='user', password='password') 16 | 17 | def assertMessage(self, response, message): 18 | messages = [m.message for m in response.context['messages']] 19 | error_text = 'Message "{0}" not found. Messages: {1}'.format(message, 20 | messages) 21 | self.assertTrue(message in messages, error_text) 22 | 23 | 24 | class NoObjectsTestCase(AbstractTestCase): 25 | 26 | def test_can_add_new_model(self): 27 | response = self.client.get(reverse('admin:app_testmodel_add'), 28 | follow=True) 29 | self.assertEqual(response.status_code, 200) 30 | 31 | def test_changelist_redirects_to_add(self): 32 | response = self.client.get(reverse('admin:app_testmodel_changelist'), 33 | follow=True) 34 | self.assertRedirects(response, reverse('admin:app_testmodel_add')) 35 | 36 | def test_renders_add_button(self): 37 | response = self.client.get(reverse('admin:app_list', args=['app']), 38 | follow=True) 39 | self.assertContains( 40 | response, 41 | 'Add', 42 | html=True) 43 | 44 | 45 | class SingleObjectTestCase(AbstractTestCase): 46 | 47 | def setUp(self): 48 | super(SingleObjectTestCase, self).setUp() 49 | TestModel.objects.create(field='value') 50 | 51 | def test_cannot_add_new_model(self): 52 | response = self.client.get(reverse('admin:app_testmodel_add'), 53 | follow=True) 54 | self.assertRedirects(response, 55 | reverse('admin:app_testmodel_change', args=[1])) 56 | self.assertMessage( 57 | response, 58 | 'Do not add additional instances of testmodel. Only one is needed.' 59 | ) 60 | 61 | def test_cannot_see_changelist(self): 62 | response = self.client.get(reverse('admin:app_testmodel_changelist'), 63 | follow=True) 64 | self.assertRedirects(response, 65 | reverse('admin:app_testmodel_change', args=[1])) 66 | 67 | def test_does_not_render_add_button(self): 68 | response = self.client.get(reverse('admin:app_list', args=['app']), 69 | follow=True) 70 | self.assertNotContains( 71 | response, 72 | 'Add', 73 | html=True) 74 | 75 | 76 | class MultipleObjectsTestCase(AbstractTestCase): 77 | 78 | def setUp(self): 79 | super(MultipleObjectsTestCase, self).setUp() 80 | TestModel.objects.create(field='value1') 81 | TestModel.objects.create(field='value2') 82 | TestModel.objects.create(field='value3') 83 | 84 | def test_cannot_add_new_model(self): 85 | response = self.client.get(reverse('admin:app_testmodel_add'), 86 | follow=True) 87 | self.assertRedirects(response, 88 | reverse('admin:app_testmodel_changelist')) 89 | self.assertMessage( 90 | response, 91 | 'Do not add additional instances of testmodel. Only one is needed.' 92 | ) 93 | 94 | def test_can_see_changelist(self): 95 | response = self.client.get(reverse('admin:app_testmodel_changelist'), 96 | follow=True) 97 | self.assertEqual(response.status_code, 200) 98 | self.assertMessage( 99 | response, 100 | ('There are multiple instances of testmodel. There should only be ' 101 | 'one.')) 102 | 103 | def test_does_not_render_add_button(self): 104 | response = self.client.get(reverse('admin:app_list', args=['app']), 105 | follow=True) 106 | self.assertNotContains( 107 | response, 108 | 'Add', 109 | html=True) 110 | -------------------------------------------------------------------------------- /test/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", "project.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /test/project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AMeng/django-single-model-admin/85c7dcbedcf9264bfdfab24fdb27c96afbf6a7ee/test/project/__init__.py -------------------------------------------------------------------------------- /test/project/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 3 | SECRET_KEY = '$jq0^7*hj$7_!2$$bmm^ay@2g(8p4=9!n+ysmxajuhnj*z%ds6' 4 | DEBUG = True 5 | ALLOWED_HOSTS = [] 6 | 7 | INSTALLED_APPS = ( 8 | 'django.contrib.admin', 9 | 'django.contrib.auth', 10 | 'django.contrib.contenttypes', 11 | 'django.contrib.sessions', 12 | 'django.contrib.messages', 13 | 'django.contrib.staticfiles', 14 | 'app' 15 | ) 16 | 17 | MIDDLEWARE_CLASSES = ( 18 | 'django.contrib.sessions.middleware.SessionMiddleware', 19 | 'django.middleware.common.CommonMiddleware', 20 | 'django.middleware.csrf.CsrfViewMiddleware', 21 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 22 | 'django.contrib.messages.middleware.MessageMiddleware', 23 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 24 | ) 25 | 26 | MIDDLEWARE = MIDDLEWARE_CLASSES # Renamed variable in Django 1.10 27 | 28 | ROOT_URLCONF = 'project.urls' 29 | WSGI_APPLICATION = 'project.wsgi.application' 30 | 31 | TEMPLATES = [ 32 | { 33 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 34 | 'DIRS': [], 35 | 'APP_DIRS': True, 36 | 'OPTIONS': { 37 | 'context_processors': [ 38 | 'django.template.context_processors.debug', 39 | 'django.template.context_processors.request', 40 | 'django.contrib.auth.context_processors.auth', 41 | 'django.contrib.messages.context_processors.messages', 42 | ], 43 | }, 44 | }, 45 | ] 46 | 47 | DATABASES = { 48 | 'default': { 49 | 'ENGINE': 'django.db.backends.sqlite3', 50 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 51 | } 52 | } 53 | LANGUAGE_CODE = 'en-us' 54 | TIME_ZONE = 'UTC' 55 | USE_I18N = True 56 | USE_L10N = True 57 | USE_TZ = True 58 | STATIC_URL = '/static/' 59 | -------------------------------------------------------------------------------- /test/project/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include, url 2 | from django.contrib import admin 3 | 4 | admin.autodiscover() 5 | 6 | try: 7 | from django.conf.urls import patterns 8 | except ImportError: 9 | urlpatterns = [url(r'^admin/', admin.site.urls)] 10 | else: 11 | urlpatterns = patterns('', url(r'^admin/', include(admin.site.urls))) 12 | -------------------------------------------------------------------------------- /test/project/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | from django.core.wsgi import get_wsgi_application 3 | 4 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") 5 | application = get_wsgi_application() 6 | --------------------------------------------------------------------------------