diff --git a/README b/README old mode 100755 new mode 100644 index 1f6f71a..03d9a2a --- a/README +++ b/README @@ -45,26 +45,23 @@ have an inner Meta class that inherits from ``Sortable.Meta`` return self.title -For models that you want sortable relative to a ``ForeignKey`` field, you need to -specify a property: ``sortable_by`` that is equal to the class defined as your ForeignKey field. -If you're upgrading from a version < 1.2, you do not need to redefine sortable_by. -1.2 is backwards compatible to 1.0. +It is also possible to order objects relative to another object that is a ForeignKey: - #admin.py + from adminsortable.fields import SortableForeignKey + + #models.py class Category(models.Model): title = models.CharField(max_length=50) ... - class MySortableClass(Sortable): + class Project(Sortable): class Meta(Sortable.Meta) - category = models.ForeignKey(Category) + category = SortableForeignKey(Category) title = models.CharField(max_length=50) def __unicode__(self): return self.title - - sortable_by = Category Sortable has one field: `order` and adds a default ordering value set to `order`. @@ -132,13 +129,10 @@ Status admin-sortable is currently used in production. -What's new in 1.2 +What's new in 1.3 ============= -- Refactored ``sortable_by`` to be a property rather than a classmethod, which is much less work to implement. -- Fixed an issue with ordering which could result in sortable change list view objects not being grouped properly. -- Refactored the ORM calls to determine if an object is sortable, and what the next order should be, to return -scalar values and to not hydrate any objects whatsoever. This potentially decreases memory usage by exponential -factors. +- Refactored ``sortable_by`` to subclass ForeignKey rather than a property or classmethod. + No more extra property hackishness. Features ============= @@ -156,7 +150,6 @@ to the foreign key object if it also inherits from Sortable. Future ------ - Support for foreign keys that are self referential -- Support for ForeignKeys that have not been previously defined - More unit tests Requirements diff --git a/adminsortable/__init__.py b/adminsortable/__init__.py index 3cb15d8..7c4488d 100755 --- a/adminsortable/__init__.py +++ b/adminsortable/__init__.py @@ -1,4 +1,4 @@ -VERSION = (1, 2, 0, "f", 0) # following PEP 386 +VERSION = (1, 3, "f", 0) # following PEP 386 DEV_N = None @@ -7,7 +7,7 @@ def get_version(): if VERSION[2]: version = "%s.%s" % (version, VERSION[2]) if VERSION[3] != "f": - version = "%s%s%s" % (version, VERSION[3], VERSION[4]) + version = "%s%s" % (version, VERSION[3]) if DEV_N: version = "%s.dev%s" % (version, DEV_N) return version diff --git a/adminsortable/admin.py b/adminsortable/admin.py old mode 100755 new mode 100644 index 8097647..459042a --- a/adminsortable/admin.py +++ b/adminsortable/admin.py @@ -9,6 +9,7 @@ from django.shortcuts import render from django.template.defaultfilters import capfirst from django.views.decorators.csrf import csrf_exempt +from adminsortable.fields import SortableForeignKey from adminsortable.models import Sortable STATIC_URL = settings.STATIC_URL @@ -20,8 +21,17 @@ class SortableAdmin(ModelAdmin): class Meta: abstract = True + def _get_sortable_foreign_key(self): + sortable_foreign_key = None + for field in self.model._meta.fields: + if isinstance(field, SortableForeignKey): + sortable_foreign_key = field + break + return sortable_foreign_key + def __init__(self, *args, **kwargs): super(SortableAdmin, self).__init__(*args, **kwargs) + self.has_sortable_tabular_inlines = False self.has_sortable_stacked_inlines = False for klass in self.inlines: @@ -55,11 +65,16 @@ class SortableAdmin(ModelAdmin): #Determine if we need to regroup objects relative to a foreign key specified on the # model class that is extending Sortable. - sortable_by = getattr(self.model, 'sortable_by', None) - if sortable_by: + #Legacy support for 'sortable_by' defined as a model property + sortable_by_property = getattr(self.model, 'sortable_by', None) + + #`sortable_by` defined as a SortableForeignKey + sortable_by_fk = self._get_sortable_foreign_key() + + if sortable_by_property: #backwards compatibility for < 1.1.1, where sortable_by was a classmethod instead of a property try: - sortable_by_class, sortable_by_expression = sortable_by() + sortable_by_class, sortable_by_expression = sortable_by_property() except TypeError, ValueError: sortable_by_class = self.model.sortable_by sortable_by_expression = sortable_by_class.__name__.lower() @@ -67,15 +82,24 @@ class SortableAdmin(ModelAdmin): sortable_by_class_display_name = sortable_by_class._meta.verbose_name_plural sortable_by_class_is_sortable = sortable_by_class.is_sortable() + elif sortable_by_fk: + #get sortable by properties from the SortableForeignKey field - supported in 1.3+ + sortable_by_class_display_name = sortable_by_fk.rel.to._meta.verbose_name_plural + sortable_by_class = sortable_by_fk.rel.to + sortable_by_expression = sortable_by_fk.name.lower() + sortable_by_class_is_sortable = sortable_by_class.is_sortable() + + else: + #model is not sortable by another model + sortable_by_class = sortable_by_expression = sortable_by_class_display_name =\ + sortable_by_class_is_sortable = None + + if sortable_by_property or sortable_by_fk: # Order the objects by the property they are sortable by, then by the order, otherwise the regroup # template tag will not show the objects correctly as # shown in https://docs.djangoproject.com/en/1.3/ref/templates/builtins/#regroup objects = objects.order_by(sortable_by_expression, 'order') - else: - sortable_by_class = sortable_by_expression = sortable_by_class_display_name =\ - sortable_by_class_is_sortable = None - try: verbose_name_plural = opts.verbose_name_plural.__unicode__() except AttributeError: diff --git a/adminsortable/fields.py b/adminsortable/fields.py new file mode 100644 index 0000000..e93f2bc --- /dev/null +++ b/adminsortable/fields.py @@ -0,0 +1,18 @@ +from django.db.models.fields.related import ForeignKey + + +class SortableForeignKey(ForeignKey): + """ + Field simply acts as a flag to determine the class to sort by. + This field replaces previous functionality where `sortable_by` was definied as a model property + that specified another model class. + """ + + def south_field_triple(self): + try: + from south.modelsinspector import introspector + cls_name = '%s.%s' % (self.__class__.__module__ , self.__class__.__name__) + args, kwargs = introspector(self) + return cls_name, args, kwargs + except ImportError: + pass diff --git a/adminsortable/models.py b/adminsortable/models.py old mode 100755 new mode 100644 index af299de..8a94b3d --- a/adminsortable/models.py +++ b/adminsortable/models.py @@ -1,6 +1,16 @@ from django.contrib.contenttypes.models import ContentType from django.db import models +from adminsortable.fields import SortableForeignKey + + +class MultipleSortableForeignKeyException(Exception): + def __init__(self, value): + self.value = value + + def __str__(self): + return repr(self.value) + class Sortable(models.Model): """ @@ -18,7 +28,10 @@ class Sortable(models.Model): Override `sortable_by` method to make your model be sortable by a foreign key field. Set `sortable_by` to the class specified in the foreign key relationship. """ + order = models.PositiveIntegerField(editable=False, default=1, db_index=True) + + #legacy support sortable_by = None class Meta: @@ -37,6 +50,17 @@ class Sortable(models.Model): def model_type_id(cls): return ContentType.objects.get_for_model(cls).id + def __init__(self, *args, **kwargs): + super(Sortable, self).__init__(*args, **kwargs) + + #Validate that model only contains at most one SortableForeignKey + sortable_foreign_keys = [] + for field in self._meta.fields: + if isinstance(field, SortableForeignKey): + sortable_foreign_keys.append(field) + if len(sortable_foreign_keys) > 1: + raise MultipleSortableForeignKeyException(u'%s may only have one SortableForeignKey' % self) + def save(self, *args, **kwargs): if not self.id: try: diff --git a/adminsortable/templates/adminsortable/change_list.html b/adminsortable/templates/adminsortable/change_list.html old mode 100755 new mode 100644 diff --git a/adminsortable/templates/adminsortable/shared/javascript_includes.html b/adminsortable/templates/adminsortable/shared/javascript_includes.html old mode 100755 new mode 100644 diff --git a/adminsortable/templates/adminsortable/shared/nested_objects.html b/adminsortable/templates/adminsortable/shared/nested_objects.html old mode 100755 new mode 100644 diff --git a/adminsortable/templatetags/django_template_additions.py b/adminsortable/templatetags/django_template_additions.py old mode 100755 new mode 100644 diff --git a/sample_project/adminsortable.sqlite b/sample_project/adminsortable.sqlite old mode 100755 new mode 100644 index 7df8bd7..87a2b3a Binary files a/sample_project/adminsortable.sqlite and b/sample_project/adminsortable.sqlite differ diff --git a/sample_project/app/admin.py b/sample_project/app/admin.py old mode 100755 new mode 100644 index f365aab..a1eb445 --- a/sample_project/app/admin.py +++ b/sample_project/app/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from adminsortable.admin import SortableAdmin, SortableTabularInline, SortableStackedInline -from app.models import Category, Project, Credit, Note, Sample +from app.models import Category, Project, Credit, Note admin.site.register(Category, SortableAdmin) @@ -20,4 +20,3 @@ class ProjectAdmin(SortableAdmin): list_display = ['__unicode__', 'category'] admin.site.register(Project, ProjectAdmin) -admin.site.register(Sample, SortableAdmin) diff --git a/sample_project/app/fixtures/initial_data.json b/sample_project/app/fixtures/initial_data.json new file mode 100644 index 0000000..03bb2e0 --- /dev/null +++ b/sample_project/app/fixtures/initial_data.json @@ -0,0 +1,56 @@ +[ + { + "pk": 1, + "model": "app.category", + "fields": { + "order": 1, + "title": "Test 1" + } + }, + { + "pk": 2, + "model": "app.category", + "fields": { + "order": 2, + "title": "Test 2" + } + }, + { + "pk": 3, + "model": "app.category", + "fields": { + "order": 3, + "title": "Test 3" + } + }, + { + "pk": 1, + "model": "app.project", + "fields": { + "category": 1, + "description": "Test", + "order": 1, + "title": "Test Project 1" + } + }, + { + "pk": 2, + "model": "app.project", + "fields": { + "category": 1, + "description": "Test", + "order": 2, + "title": "Test Project 2" + } + }, + { + "pk": 3, + "model": "app.project", + "fields": { + "category": 2, + "description": "Test", + "order": 3, + "title": "Test Project 3" + } + } +] diff --git a/sample_project/app/migrations/0001_initial.py b/sample_project/app/migrations/0001_initial.py deleted file mode 100644 index 099672b..0000000 --- a/sample_project/app/migrations/0001_initial.py +++ /dev/null @@ -1,77 +0,0 @@ -# encoding: utf-8 -import datetime -from south.db import db -from south.v2 import SchemaMigration -from django.db import models - -class Migration(SchemaMigration): - - def forwards(self, orm): - - # Adding model 'Category' - db.create_table('app_category', ( - ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('order', self.gf('django.db.models.fields.PositiveIntegerField')(default=1, db_index=True)), - ('title', self.gf('django.db.models.fields.CharField')(max_length=50)), - )) - db.send_create_signal('app', ['Category']) - - # Adding model 'Project' - db.create_table('app_project', ( - ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('order', self.gf('django.db.models.fields.PositiveIntegerField')(default=1, db_index=True)), - ('title', self.gf('django.db.models.fields.CharField')(max_length=50)), - ('category', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['app.Category'])), - ('description', self.gf('django.db.models.fields.TextField')()), - )) - db.send_create_signal('app', ['Project']) - - # Adding model 'Credit' - db.create_table('app_credit', ( - ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('order', self.gf('django.db.models.fields.PositiveIntegerField')(default=1, db_index=True)), - ('project', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['app.Project'])), - ('first_name', self.gf('django.db.models.fields.CharField')(max_length=30)), - ('last_name', self.gf('django.db.models.fields.CharField')(max_length=30)), - )) - db.send_create_signal('app', ['Credit']) - - - def backwards(self, orm): - - # Deleting model 'Category' - db.delete_table('app_category') - - # Deleting model 'Project' - db.delete_table('app_project') - - # Deleting model 'Credit' - db.delete_table('app_credit') - - - models = { - 'app.category': { - 'Meta': {'ordering': "['order', 'id']", 'object_name': 'Category'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'order': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1', 'db_index': 'True'}), - 'title': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - 'app.credit': { - 'Meta': {'ordering': "['order', 'id']", 'object_name': 'Credit'}, - 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30'}), - 'order': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1', 'db_index': 'True'}), - 'project': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['app.Project']"}) - }, - 'app.project': { - 'Meta': {'ordering': "['order', 'id']", 'object_name': 'Project'}, - 'category': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['app.Category']"}), - 'description': ('django.db.models.fields.TextField', [], {}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'order': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1', 'db_index': 'True'}), - 'title': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - } - } - - complete_apps = ['app'] diff --git a/sample_project/app/migrations/0002_add_note.py b/sample_project/app/migrations/0002_add_note.py deleted file mode 100644 index 8952798..0000000 --- a/sample_project/app/migrations/0002_add_note.py +++ /dev/null @@ -1,59 +0,0 @@ -# encoding: utf-8 -import datetime -from south.db import db -from south.v2 import SchemaMigration -from django.db import models - -class Migration(SchemaMigration): - - def forwards(self, orm): - - # Adding model 'Note' - db.create_table('app_note', ( - ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('order', self.gf('django.db.models.fields.PositiveIntegerField')(default=1, db_index=True)), - ('project', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['app.Project'])), - ('text', self.gf('django.db.models.fields.CharField')(max_length=100)), - )) - db.send_create_signal('app', ['Note']) - - - def backwards(self, orm): - - # Deleting model 'Note' - db.delete_table('app_note') - - - models = { - 'app.category': { - 'Meta': {'ordering': "['order', 'id']", 'object_name': 'Category'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'order': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1', 'db_index': 'True'}), - 'title': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - 'app.credit': { - 'Meta': {'ordering': "['order', 'id']", 'object_name': 'Credit'}, - 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30'}), - 'order': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1', 'db_index': 'True'}), - 'project': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['app.Project']"}) - }, - 'app.note': { - 'Meta': {'ordering': "['order', 'id']", 'object_name': 'Note'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'order': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1', 'db_index': 'True'}), - 'project': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['app.Project']"}), - 'text': ('django.db.models.fields.CharField', [], {'max_length': '100'}) - }, - 'app.project': { - 'Meta': {'ordering': "['order', 'id']", 'object_name': 'Project'}, - 'category': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['app.Category']"}), - 'description': ('django.db.models.fields.TextField', [], {}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'order': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1', 'db_index': 'True'}), - 'title': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - } - } - - complete_apps = ['app'] diff --git a/sample_project/app/migrations/0003_add_sample.py b/sample_project/app/migrations/0003_add_sample.py deleted file mode 100755 index 474c29e..0000000 --- a/sample_project/app/migrations/0003_add_sample.py +++ /dev/null @@ -1,68 +0,0 @@ -# encoding: utf-8 -import datetime -from south.db import db -from south.v2 import SchemaMigration -from django.db import models - -class Migration(SchemaMigration): - - def forwards(self, orm): - - # Adding model 'Sample' - db.create_table('app_sample', ( - ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('order', self.gf('django.db.models.fields.PositiveIntegerField')(default=1, db_index=True)), - ('title', self.gf('django.db.models.fields.CharField')(max_length=50)), - ('category', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['app.Category'])), - ('description', self.gf('django.db.models.fields.TextField')()), - )) - db.send_create_signal('app', ['Sample']) - - - def backwards(self, orm): - - # Deleting model 'Sample' - db.delete_table('app_sample') - - - models = { - 'app.category': { - 'Meta': {'ordering': "['order']", 'object_name': 'Category'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'order': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1', 'db_index': 'True'}), - 'title': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - 'app.credit': { - 'Meta': {'ordering': "['order']", 'object_name': 'Credit'}, - 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30'}), - 'order': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1', 'db_index': 'True'}), - 'project': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['app.Project']"}) - }, - 'app.note': { - 'Meta': {'ordering': "['order']", 'object_name': 'Note'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'order': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1', 'db_index': 'True'}), - 'project': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['app.Project']"}), - 'text': ('django.db.models.fields.CharField', [], {'max_length': '100'}) - }, - 'app.project': { - 'Meta': {'ordering': "['order']", 'object_name': 'Project'}, - 'category': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['app.Category']"}), - 'description': ('django.db.models.fields.TextField', [], {}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'order': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1', 'db_index': 'True'}), - 'title': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - 'app.sample': { - 'Meta': {'ordering': "['order']", 'object_name': 'Sample'}, - 'category': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['app.Category']"}), - 'description': ('django.db.models.fields.TextField', [], {}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'order': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1', 'db_index': 'True'}), - 'title': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - } - } - - complete_apps = ['app'] diff --git a/sample_project/app/migrations/__init__.py b/sample_project/app/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/sample_project/app/models.py b/sample_project/app/models.py old mode 100755 new mode 100644 index 9b58536..489f257 --- a/sample_project/app/models.py +++ b/sample_project/app/models.py @@ -1,5 +1,6 @@ from django.db import models +from adminsortable.fields import SortableForeignKey from adminsortable.models import Sortable @@ -25,34 +26,16 @@ class Category(SimpleModel, Sortable): #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 - #deprecated: shown for backward compatibility only. Reference class "Sample" for proper - # designation of `sortable_by` as a property - @classmethod - def sortable_by(cls): - return Category, 'category' - - category = models.ForeignKey(Category) + category = SortableForeignKey(Category) description = models.TextField() -#a model that is sortable relative to a foreign key that is also sortable -class Sample(SimpleModel, Sortable): - class Meta(Sortable.Meta): - ordering = Sortable.Meta.ordering + ['category'] - - category = models.ForeignKey(Category) - description = models.TextField() - - #field to define which foreign key the model is sortable by. - #works with versions > 1.1.1 - sortable_by = Category - - -#registered as a tabular inline on project +#registered as a tabular inline on `Project` class Credit(Sortable): class Meta(Sortable.Meta): pass @@ -65,7 +48,7 @@ class Credit(Sortable): return '%s %s' % (self.first_name, self.last_name) -#registered as a stacked inline on project +#registered as a stacked inline on `Project` class Note(Sortable): class Meta(Sortable.Meta): pass diff --git a/sample_project/app/tests.py b/sample_project/app/tests.py old mode 100755 new mode 100644 index a086099..c209125 --- a/sample_project/app/tests.py +++ b/sample_project/app/tests.py @@ -7,8 +7,14 @@ from django.db import models from django.test import TestCase from django.test.client import Client, RequestFactory -from models import Sortable -from app.models import Category +from adminsortable.fields import SortableForeignKey +from adminsortable.models import Sortable, MultipleSortableForeignKeyException +from app.models import Category, Credit, Note + + +class BadSortableModel(models.Model): + note = SortableForeignKey(Note) + credit = SortableForeignKey(Credit) class TestSortableModel(Sortable): @@ -19,7 +25,6 @@ class TestSortableModel(Sortable): class SortableTestCase(TestCase): - def setUp(self): self.client = Client() self.factory = RequestFactory() diff --git a/sample_project/settings.py b/sample_project/settings.py old mode 100755 new mode 100644