diff --git a/.gitignore b/.gitignore index be68f57..06ec6d9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ atlassian-* *.sublime-* .ropeproject .codeintel +__pycache__ diff --git a/python3_sample_project/app/__init__.py b/python3_sample_project/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python3_sample_project/app/admin.py b/python3_sample_project/app/admin.py new file mode 100644 index 0000000..25783b3 --- /dev/null +++ b/python3_sample_project/app/admin.py @@ -0,0 +1,67 @@ +from django.contrib import admin + +from adminsortable.admin import (SortableAdmin, SortableTabularInline, + SortableStackedInline, SortableGenericStackedInline) +from adminsortable.utils import get_is_sortable +from app.models import (Category, Widget, Project, Credit, Note, GenericNote, + Component, Person) + + +admin.site.register(Category, SortableAdmin) + + +class ComponentInline(SortableStackedInline): + model = Component + + def queryset(self, request): + qs = super(ComponentInline, self).queryset( + request).exclude(title__icontains='2') + if get_is_sortable(qs): + self.model.is_sortable = True + else: + self.model.is_sortable = False + return qs + + +class WidgetAdmin(SortableAdmin): + def queryset(self, request): + """ + A simple example demonstrating that adminsortable works even in + situations where you need to filter the queryset in admin. Here, + we are just filtering out `widget` instances with an pk higher + than 3 + """ + qs = super(WidgetAdmin, self).queryset(request) + return qs.filter(id__lte=3) + + inlines = [ComponentInline] + +admin.site.register(Widget, WidgetAdmin) + + +class CreditInline(SortableTabularInline): + model = Credit + extra = 1 + + +class NoteInline(SortableStackedInline): + model = Note + extra = 0 + + +class GenericNoteInline(SortableGenericStackedInline): + model = GenericNote + extra = 0 + + +class ProjectAdmin(SortableAdmin): + inlines = [CreditInline, NoteInline, GenericNoteInline] + list_display = ['__str__', 'category'] + +admin.site.register(Project, ProjectAdmin) + + +class PersonAdmin(SortableAdmin): + list_display = ['__str__', 'is_board_member'] + +admin.site.register(Person, PersonAdmin) diff --git a/python3_sample_project/app/fixtures/data.json b/python3_sample_project/app/fixtures/data.json new file mode 100644 index 0000000..25f6d7d --- /dev/null +++ b/python3_sample_project/app/fixtures/data.json @@ -0,0 +1,52 @@ +[ +{ + "fields": { + "is_board_member": true, + "order": 1, + "first_name": "Brandon", + "last_name": "Taylor" + }, + "pk": 1, + "model": "app.person" +}, +{ + "fields": { + "is_board_member": true, + "order": 2, + "first_name": "Kerri", + "last_name": "Taylor" + }, + "pk": 2, + "model": "app.person" +}, +{ + "fields": { + "is_board_member": false, + "order": 3, + "first_name": "Sarah", + "last_name": "Taylor" + }, + "pk": 3, + "model": "app.person" +}, +{ + "fields": { + "is_board_member": false, + "order": 4, + "first_name": "Renna", + "last_name": "Taylor" + }, + "pk": 4, + "model": "app.person" +}, +{ + "fields": { + "is_board_member": false, + "order": 5, + "first_name": "Jake", + "last_name": "Taylor" + }, + "pk": 5, + "model": "app.person" +} +] diff --git a/python3_sample_project/app/migrations/0001_initial.py b/python3_sample_project/app/migrations/0001_initial.py new file mode 100644 index 0000000..ab0527a --- /dev/null +++ b/python3_sample_project/app/migrations/0001_initial.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import adminsortable.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.AutoField(auto_created=True, serialize=False, primary_key=True, verbose_name='ID')), + ('order', models.PositiveIntegerField(editable=False, default=1, db_index=True)), + ('title', models.CharField(max_length=50)), + ], + options={ + 'ordering': ['order'], + 'verbose_name_plural': 'Categories', + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='Component', + fields=[ + ('id', models.AutoField(auto_created=True, serialize=False, primary_key=True, verbose_name='ID')), + ('order', models.PositiveIntegerField(editable=False, default=1, db_index=True)), + ('title', models.CharField(max_length=50)), + ], + options={ + 'ordering': ['order'], + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='Credit', + fields=[ + ('id', models.AutoField(auto_created=True, serialize=False, primary_key=True, verbose_name='ID')), + ('order', models.PositiveIntegerField(editable=False, default=1, db_index=True)), + ('first_name', models.CharField(max_length=30)), + ('last_name', models.CharField(max_length=30)), + ], + options={ + 'ordering': ['order'], + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='GenericNote', + fields=[ + ('id', models.AutoField(auto_created=True, serialize=False, primary_key=True, verbose_name='ID')), + ('order', models.PositiveIntegerField(editable=False, default=1, db_index=True)), + ('title', models.CharField(max_length=50)), + ('object_id', models.PositiveIntegerField(verbose_name='Content id')), + ('content_type', models.ForeignKey(related_name='generic_notes', verbose_name='Content type', to='contenttypes.ContentType')), + ], + options={ + 'ordering': ['order'], + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='Note', + fields=[ + ('id', models.AutoField(auto_created=True, serialize=False, primary_key=True, verbose_name='ID')), + ('order', models.PositiveIntegerField(editable=False, default=1, db_index=True)), + ('text', models.CharField(max_length=100)), + ], + options={ + 'ordering': ['order'], + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='Person', + fields=[ + ('id', models.AutoField(auto_created=True, serialize=False, primary_key=True, verbose_name='ID')), + ('order', models.PositiveIntegerField(editable=False, default=1, db_index=True)), + ('first_name', models.CharField(max_length=50)), + ('last_name', models.CharField(max_length=50)), + ('is_board_member', models.BooleanField(verbose_name='Board Member', default=False)), + ], + options={ + 'ordering': ['order'], + 'verbose_name_plural': 'People', + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.AutoField(auto_created=True, serialize=False, primary_key=True, verbose_name='ID')), + ('order', models.PositiveIntegerField(editable=False, default=1, db_index=True)), + ('title', models.CharField(max_length=50)), + ('description', models.TextField()), + ('category', adminsortable.fields.SortableForeignKey(to='app.Category')), + ], + options={ + 'ordering': ['order'], + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='Widget', + fields=[ + ('id', models.AutoField(auto_created=True, serialize=False, primary_key=True, verbose_name='ID')), + ('order', models.PositiveIntegerField(editable=False, default=1, db_index=True)), + ('title', models.CharField(max_length=50)), + ], + options={ + 'ordering': ['order'], + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.AddField( + model_name='note', + name='project', + field=models.ForeignKey(to='app.Project'), + preserve_default=True, + ), + migrations.AddField( + model_name='credit', + name='project', + field=models.ForeignKey(to='app.Project'), + preserve_default=True, + ), + migrations.AddField( + model_name='component', + name='widget', + field=adminsortable.fields.SortableForeignKey(to='app.Widget'), + preserve_default=True, + ), + ] diff --git a/python3_sample_project/app/migrations/__init__.py b/python3_sample_project/app/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python3_sample_project/app/models.py b/python3_sample_project/app/models.py new file mode 100644 index 0000000..9fe5a0b --- /dev/null +++ b/python3_sample_project/app/models.py @@ -0,0 +1,121 @@ +from django.contrib.contenttypes import generic +from django.contrib.contenttypes.models import ContentType +from django.db import models + +from adminsortable.fields import SortableForeignKey +from adminsortable.models import Sortable + + +class SimpleModel(models.Model): + class Meta: + abstract = True + + title = models.CharField(max_length=50) + + def __unicode__(self): + return self.title + + +# A model that is sortable +class Category(SimpleModel, Sortable): + class Meta(Sortable.Meta): + """ + Classes that inherit from Sortable must define an inner + Meta class that inherits from Sortable.Meta or ordering + won't work as expected + """ + verbose_name_plural = 'Categories' + + def __str__(self): + return self.title + + +# A model with an override of its queryset for admin +class Widget(SimpleModel, Sortable): + class Meta(Sortable.Meta): + pass + + def __str__(self): + return self.title + + +# A model that is sortable relative to a foreign key that is also sortable +# uses SortableForeignKey field. Works with versions 1.3+ +class Project(SimpleModel, Sortable): + class Meta(Sortable.Meta): + pass + + category = SortableForeignKey(Category) + description = models.TextField() + + +# Registered as a tabular inline on `Project` +class Credit(Sortable): + class Meta(Sortable.Meta): + pass + + project = models.ForeignKey(Project) + first_name = models.CharField(max_length=30) + last_name = models.CharField(max_length=30) + + def __str__(self): + return '{0} {1}'.format(self.first_name, self.last_name) + + +# Registered as a stacked inline on `Project` +class Note(Sortable): + class Meta(Sortable.Meta): + pass + + project = models.ForeignKey(Project) + text = models.CharField(max_length=100) + + def __str__(self): + return self.text + + +# A generic bound model +class GenericNote(SimpleModel, Sortable): + content_type = models.ForeignKey(ContentType, + verbose_name=u"Content type", related_name="generic_notes") + object_id = models.PositiveIntegerField(u"Content id") + content_object = generic.GenericForeignKey(ct_field='content_type', + fk_field='object_id') + + class Meta(Sortable.Meta): + pass + + def __str__(self): + return u'{0}: {1}'.format(self.title, self.content_object) + + +# An model registered as an inline that has a custom queryset +class Component(SimpleModel, Sortable): + class Meta(Sortable.Meta): + pass + + widget = SortableForeignKey(Widget) + + def __str__(self): + return self.title + + +class Person(Sortable): + class Meta(Sortable.Meta): + verbose_name_plural = 'People' + + first_name = models.CharField(max_length=50) + last_name = models.CharField(max_length=50) + is_board_member = models.BooleanField('Board Member', default=False) + + # Sorting Filters allow you to set up sub-sets of objects that need + # to have independent sorting. They are listed in order, from left + # to right in the sorting change list. You can use any standard + # Django ORM filter method. + sorting_filters = ( + ('Board Members', {'is_board_member': True}), + ('Non-Board Members', {'is_board_member': False}), + ) + + def __str__(self): + return '{0} {1}'.format(self.first_name, self.last_name) diff --git a/python3_sample_project/app/tests.py b/python3_sample_project/app/tests.py new file mode 100644 index 0000000..66e1a4b --- /dev/null +++ b/python3_sample_project/app/tests.py @@ -0,0 +1,158 @@ +import http.client +import json + +from django.contrib.auth.models import User +from django.test import TestCase +from django.test.client import Client, RequestFactory + +from adminsortable.utils import get_is_sortable +from app.models import Category, Person + + +class SortableTestCase(TestCase): + fixtures = ['data.json'] + + def setUp(self): + self.client = Client() + self.factory = RequestFactory() + self.user_raw_password = 'admin' + self.user = User.objects.create_user('admin', 'admin@admin.com', + self.user_raw_password) + self.user.is_staff = True + self.user.is_superuser = True + self.user.save() + + people = Person.objects.all() + self.first_person = people[0] + self.second_person = people[1] + + def create_category(self, title='Category 1'): + category = Category.objects.create(title=title) + return category + + def test_new_user_is_authenticated(self): + self.assertEqual(self.user.is_authenticated(), True, + 'User is not authenticated') + + def test_new_user_is_staff(self): + self.assertEqual(self.user.is_staff, True, 'User is not staff') + + def test_is_not_sortable(self): + """ + A model should only become sortable if it has more than + record to sort. + """ + self.create_category() + self.assertFalse(get_is_sortable(Category.objects.all()), + 'Category only has one record. It should not be sortable.') + + def test_is_sortable(self): + self.create_category() + self.create_category(title='Category 2') + self.assertTrue(get_is_sortable(Category.objects.all()), + 'Category has more than one record. It should be sortable.') + + def test_save_order_incremented(self): + category1 = self.create_category() + self.assertEqual(category1.order, 1, 'Category 1 order should be 1.') + + category2 = self.create_category(title='Category 2') + self.assertEqual(category2.order, 2, 'Category 2 order should be 2.') + + def test_adminsortable_change_list_view(self): + self.client.login(username=self.user.username, + password=self.user_raw_password) + response = self.client.get('/admin/app/category/sort/') + self.assertEquals(response.status_code, http.client.OK, + 'Unable to reach sort view.') + + def make_test_categories(self): + category1 = self.create_category() + category2 = self.create_category(title='Category 2') + category3 = self.create_category(title='Category 3') + return category1, category2, category3 + + def get_sorting_url(self): + return '/admin/app/category/sorting/do-sorting/{0}/'.format( + Category.model_type_id()) + + def get_category_indexes(self, *categories): + return {'indexes': ','.join([str(c.id) for c in categories])} + + def test_adminsortable_changelist_templates(self): + logged_in = self.client.login(username=self.user.username, + password=self.user_raw_password) + self.assertTrue(logged_in, 'User is not logged in') + + response = self.client.get('/admin/app/category/sort/') + self.assertEqual(response.status_code, http.client.OK, + 'Admin sort request failed.') + + #assert adminsortable change list templates are used + template_names = [t.name for t in response.templates] + self.assertTrue('adminsortable/change_list.html' in template_names, + 'adminsortable/change_list.html was not rendered') + + def test_adminsortable_change_list_sorting_fails_if_not_ajax(self): + logged_in = self.client.login(username=self.user.username, + password=self.user_raw_password) + self.assertTrue(logged_in, 'User is not logged in') + + category1, category2, category3 = self.make_test_categories() + #make a normal POST + response = self.client.post(self.get_sorting_url(), + data=self.get_category_indexes(category1, category2, category3)) + content = json.loads(response.content.decode(encoding='UTF-8'), + 'latin-1') + self.assertFalse(content.get('objects_sorted'), + 'Objects should not have been sorted. An ajax post is required.') + + def test_adminsortable_change_list_sorting_successful(self): + logged_in = self.client.login(username=self.user.username, + password=self.user_raw_password) + self.assertTrue(logged_in, 'User is not logged in') + + #make categories + category1, category2, category3 = self.make_test_categories() + + #make an Ajax POST + response = self.client.post(self.get_sorting_url(), + data=self.get_category_indexes(category3, category2, category1), + HTTP_X_REQUESTED_WITH='XMLHttpRequest') + content = json.loads(response.content.decode(encoding='UTF-8'), + 'latin-1') + self.assertTrue(content.get('objects_sorted'), + 'Objects should have been sorted.') + + #assert order is correct + categories = Category.objects.all() + cat1 = categories[0] + cat2 = categories[1] + cat3 = categories[2] + + self.assertEqual(cat1.order, 1, + 'First category returned should have order == 1') + self.assertEqual(cat1.pk, 3, + 'Category ID 3 should have been first in queryset') + + self.assertEqual(cat2.order, 2, + 'Second category returned should have order == 2') + self.assertEqual(cat2.pk, 2, + 'Category ID 2 should have been second in queryset') + + self.assertEqual(cat3.order, 3, + 'Third category returned should have order == 3') + self.assertEqual(cat3.pk, 1, + 'Category ID 1 should have been third in queryset') + + def test_get_next(self): + result = self.first_person.get_next() + + self.assertEqual(self.second_person, result, 'Next person should ' + 'be "{}"'.format(self.second_person)) + + def test_get_previous(self): + result = self.second_person.get_previous() + + self.assertEqual(self.first_person, result, 'Previous person should ' + 'be "{}"'.format(self.first_person)) diff --git a/python3_sample_project/database/python3-test-project.sqlite3 b/python3_sample_project/database/python3-test-project.sqlite3 new file mode 100644 index 0000000..76b853c Binary files /dev/null and b/python3_sample_project/database/python3-test-project.sqlite3 differ diff --git a/python3_sample_project/manage.py b/python3_sample_project/manage.py new file mode 100755 index 0000000..08821aa --- /dev/null +++ b/python3_sample_project/manage.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +import os +import sys + +# Adds the adminsortable package from the cloned repository instead of +# site_packages +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", + "python3_sample_project.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/python3_sample_project/python3_sample_project/__init__.py b/python3_sample_project/python3_sample_project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python3_sample_project/python3_sample_project/settings.py b/python3_sample_project/python3_sample_project/settings.py new file mode 100644 index 0000000..5d729fe --- /dev/null +++ b/python3_sample_project/python3_sample_project/settings.py @@ -0,0 +1,99 @@ +""" +Django settings for python3_sample_project project. + +For more information on this file, see +https://docs.djangoproject.com/en/1.7/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.7/ref/settings/ +""" + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +from .utils import map_path + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'k^uy@(9tieoj3d%o=09ph$b&gu+5q@9h$f(6l7@h2ak*0@y9%w' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +TEMPLATE_DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = ( + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + 'adminsortable', + 'app', +) + +MIDDLEWARE_CLASSES = ( + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +) + +ROOT_URLCONF = 'python3_sample_project.urls' + +WSGI_APPLICATION = 'python3_sample_project.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/1.7/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': map_path('database/python3-test-project.sqlite3'), + }, + 'test': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': map_path('database/test-python3-test-project.sqlite3'), + } +} + +# Internationalization +# https://docs.djangoproject.com/en/1.7/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.7/howto/static-files/ + +STATIC_URL = '/static/' + +STATICFILES_FINDERS = ( + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', +) + +TEMPLATE_LOADERS = ( + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', +) diff --git a/python3_sample_project/python3_sample_project/urls.py b/python3_sample_project/python3_sample_project/urls.py new file mode 100644 index 0000000..c4b2ed2 --- /dev/null +++ b/python3_sample_project/python3_sample_project/urls.py @@ -0,0 +1,10 @@ +from django.conf.urls import patterns, include, url +from django.contrib import admin + +urlpatterns = patterns('', + # Examples: + # url(r'^$', 'python3_sample_project.views.home', name='home'), + # url(r'^blog/', include('blog.urls')), + + url(r'^admin/', include(admin.site.urls)), +) diff --git a/python3_sample_project/python3_sample_project/utils.py b/python3_sample_project/python3_sample_project/utils.py new file mode 100644 index 0000000..4178ad9 --- /dev/null +++ b/python3_sample_project/python3_sample_project/utils.py @@ -0,0 +1,6 @@ +import os + + +def map_path(directory_name): + return os.path.join(os.path.dirname(__file__), + '../' + directory_name).replace('\\', '/') diff --git a/python3_sample_project/python3_sample_project/wsgi.py b/python3_sample_project/python3_sample_project/wsgi.py new file mode 100644 index 0000000..1256fa7 --- /dev/null +++ b/python3_sample_project/python3_sample_project/wsgi.py @@ -0,0 +1,14 @@ +""" +WSGI config for python3_sample_project project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.7/howto/deployment/wsgi/ +""" + +import os +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "python3_sample_project.settings") + +from django.core.wsgi import get_wsgi_application +application = get_wsgi_application() diff --git a/sample_project/app/admin.py b/sample_project/app/admin.py index bf2a5fb..304be18 100644 --- a/sample_project/app/admin.py +++ b/sample_project/app/admin.py @@ -41,6 +41,7 @@ admin.site.register(Widget, WidgetAdmin) class CreditInline(SortableTabularInline): model = Credit + extra = 1 class NoteInline(SortableStackedInline): diff --git a/sample_project/database/test_project.sqlite b/sample_project/database/test_project.sqlite index 98505cf..5dd00e7 100644 Binary files a/sample_project/database/test_project.sqlite and b/sample_project/database/test_project.sqlite differ diff --git a/sample_project/requirements.txt b/sample_project/requirements.txt index 0a26803..d3e4ba5 100644 --- a/sample_project/requirements.txt +++ b/sample_project/requirements.txt @@ -1,2 +1 @@ -django==1.6.6 -south==1.0 +django