├── examples ├── __init__.py ├── lists │ ├── __init__.py │ ├── models.py │ └── tests.py ├── nodes │ ├── __init__.py │ ├── models.py │ └── tests.py ├── photos │ ├── __init__.py │ ├── forms.py │ ├── models.py │ └── tests.py ├── school │ ├── __init__.py │ ├── models.py │ └── tests.py ├── store │ ├── __init__.py │ ├── models.py │ └── tests.py ├── todo │ ├── __init__.py │ ├── models.py │ └── tests.py ├── migration │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0003_migrationtest_position.py │ │ ├── 0002_insert_test_data.py │ │ └── 0001_initial.py │ ├── models.py │ └── tests.py ├── restaurants │ ├── __init__.py │ ├── models.py │ └── tests.py ├── settings_sqlite.py ├── ci_settings_sqlite.py ├── ci_settings_mysql.py ├── ci_settings_postgres.py ├── settings_mysql.py ├── settings_postgres.py └── settings.py ├── requirements.txt ├── MANIFEST.in ├── .gitignore ├── positions ├── __init__.py ├── managers.py └── fields.py ├── manage.py ├── AUTHORS ├── setup.py ├── LICENSE ├── .travis.yml └── README.rst /examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/lists/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/nodes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/photos/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/school/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/store/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/todo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/migration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/restaurants/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/migration/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==1.7.4 2 | psycopg2==2.6 3 | -------------------------------------------------------------------------------- /examples/settings_sqlite.py: -------------------------------------------------------------------------------- 1 | from .settings import * 2 | -------------------------------------------------------------------------------- /examples/ci_settings_sqlite.py: -------------------------------------------------------------------------------- 1 | from .settings_sqlite import * 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include positions/examples/nodes/doctests * 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | *.swp 4 | *.un~ 5 | /build 6 | /dist 7 | -------------------------------------------------------------------------------- /positions/__init__.py: -------------------------------------------------------------------------------- 1 | from positions.fields import PositionField 2 | from positions.managers import PositionManager 3 | -------------------------------------------------------------------------------- /examples/ci_settings_mysql.py: -------------------------------------------------------------------------------- 1 | from .settings_mysql import * 2 | 3 | DATABASES['default'].update({'USER': 'root', 'PASSWORD': ''}) 4 | -------------------------------------------------------------------------------- /examples/ci_settings_postgres.py: -------------------------------------------------------------------------------- 1 | from .settings_postgres import * 2 | 3 | DATABASES['default'].update({'USER': 'postgres', 'PASSWORD': ''}) 4 | -------------------------------------------------------------------------------- /examples/photos/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from examples.photos.models import Photo 4 | 5 | 6 | class PhotoForm(forms.ModelForm): 7 | class Meta: 8 | model = Photo 9 | fields = ['name',] 10 | -------------------------------------------------------------------------------- /examples/settings_mysql.py: -------------------------------------------------------------------------------- 1 | from .settings import * 2 | 3 | DATABASES = { 4 | 'default': { 5 | 'ENGINE': 'django.db.backends.mysql', 6 | 'NAME': 'django_positions', 7 | 'USER': 'django_positions', 8 | 'PASSWORD': 'django_positions', 9 | } 10 | } 11 | 12 | LOGGING['handlers']['debug_log_file']['formatter'] = 'simple' 13 | -------------------------------------------------------------------------------- /examples/settings_postgres.py: -------------------------------------------------------------------------------- 1 | from .settings import * 2 | 3 | DATABASES = { 4 | 'default': { 5 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 6 | 'NAME': 'django_positions', 7 | 'USER': 'django_positions', 8 | 'PASSWORD': 'django_positions', 9 | 'HOST': '127.0.0.1', 10 | 'PORT': '5432', 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/nodes/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from positions.fields import PositionField 3 | 4 | class Node(models.Model): 5 | parent = models.ForeignKey('self', related_name='children', blank=True, null=True, on_delete=models.CASCADE) 6 | name = models.CharField(max_length=50) 7 | position = PositionField(collection='parent') 8 | 9 | def __unicode__(self): 10 | return self.name 11 | -------------------------------------------------------------------------------- /examples/migration/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from positions.fields import PositionField 4 | 5 | 6 | class MigrationTest(models.Model): 7 | name = models.CharField(max_length=80) 8 | age = models.IntegerField(null=True, blank=True) 9 | favorite_color = models.CharField(max_length=255, null=True, blank=True) 10 | position = PositionField(collection=('name', 'age')) 11 | 12 | def __unicode__(self): 13 | return self.name -------------------------------------------------------------------------------- /examples/restaurants/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from positions import PositionField 4 | 5 | 6 | class Menu(models.Model): 7 | name = models.CharField(max_length=100) 8 | 9 | 10 | class Item(models.Model): 11 | menu = models.ForeignKey(Menu, on_delete=models.CASCADE) 12 | position = PositionField(collection='menu') 13 | 14 | 15 | class Food(Item): 16 | name = models.CharField(max_length=100) 17 | 18 | 19 | class Drink(Item): 20 | name = models.CharField(max_length=100) 21 | -------------------------------------------------------------------------------- /examples/photos/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from positions import PositionField 4 | 5 | 6 | class Album(models.Model): 7 | name = models.CharField(max_length=50) 8 | 9 | def __unicode__(self): 10 | return self.name 11 | 12 | 13 | class Photo(models.Model): 14 | album = models.ForeignKey(Album, related_name='photos', on_delete=models.CASCADE) 15 | name = models.CharField(max_length=50) 16 | position = PositionField(collection='album', default=0) 17 | 18 | def __unicode__(self): 19 | return self.name 20 | -------------------------------------------------------------------------------- /examples/todo/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | import positions 4 | 5 | 6 | class Item(models.Model): 7 | description = models.CharField(max_length=50) 8 | 9 | # I'm calling the PositionField "index" to make sure any internal code that 10 | # relies on a PositionField being called "position" will break. 11 | # https://github.com/jpwatts/django-positions/pull/12 12 | index = positions.PositionField() 13 | 14 | objects = positions.PositionManager('index') 15 | 16 | def __unicode__(self): 17 | return self.description 18 | -------------------------------------------------------------------------------- /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", "examples.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /examples/lists/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from positions.fields import PositionField 4 | 5 | 6 | class List(models.Model): 7 | name = models.CharField(max_length=50) 8 | 9 | def __unicode__(self): 10 | return self.name 11 | 12 | 13 | class Item(models.Model): 14 | list = models.ForeignKey('list', related_name='items', db_index=True, on_delete=models.CASCADE) 15 | name = models.CharField(max_length=50) 16 | position = PositionField(collection='list') 17 | updated = models.DateTimeField(auto_now=True) 18 | 19 | def __unicode__(self): 20 | return self.name 21 | -------------------------------------------------------------------------------- /examples/migration/migrations/0003_migrationtest_position.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import positions.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('migration', '0002_insert_test_data'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='migrationtest', 17 | name='position', 18 | field=positions.fields.PositionField(default=-1), 19 | preserve_default=True, 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Joel Watts 2 | Eivind Uggedal 3 | Jacob Smullyan 4 | Maciek Szczesniak 5 | Fabio Corneti 6 | Jannis Leidel 7 | Jakub Paczkowski 8 | Victor Safronovich 9 | Daniel Shapiro <@danxshap> 10 | Rich Atkinson 11 | Gordon Cassie 12 | Mathias Kahl 13 | Ezra Buehler <@easybe> 14 | Amin Dandache 15 | Simon Litchfield 16 | Areth Foster-Webster 17 | -------------------------------------------------------------------------------- /examples/school/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from positions import PositionField 4 | 5 | 6 | class SubUnit(models.Model): 7 | name = models.CharField(max_length=100) 8 | 9 | 10 | class Task(models.Model): 11 | """ 12 | Base class for lessons/exercises - ordered items within a sub-unit 13 | """ 14 | sub_unit = models.ForeignKey(SubUnit, on_delete=models.CASCADE) 15 | title = models.CharField(max_length=100) 16 | position = PositionField(collection='sub_unit', parent_link='task_ptr') 17 | 18 | 19 | class Lesson(Task): 20 | text = models.CharField(max_length=100) 21 | 22 | 23 | class Exercise(Task): 24 | description = models.CharField(max_length=100) 25 | -------------------------------------------------------------------------------- /examples/migration/migrations/0002_insert_test_data.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import positions.fields 6 | 7 | 8 | def add_test_data(apps, schema_editor): 9 | # We can't import the Person model directly as it may be a newer 10 | # version than this migration expects. We use the historical version. 11 | MigrationTest = apps.get_model("migration", "MigrationTest") 12 | test_record = MigrationTest.objects.create(name='Test Name', age=99, favorite_color='Red') 13 | 14 | class Migration(migrations.Migration): 15 | 16 | dependencies = [ 17 | ('migration', '0001_initial'), 18 | ] 19 | 20 | operations = [ 21 | migrations.RunPython(add_test_data), 22 | ] 23 | 24 | 25 | -------------------------------------------------------------------------------- /examples/migration/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='MigrationTest', 15 | fields=[ 16 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 17 | ('name', models.CharField(max_length=80)), 18 | ('age', models.IntegerField(null=True, blank=True)), 19 | ('favorite_color', models.CharField(max_length=255, null=True, blank=True)), 20 | ], 21 | options={ 22 | }, 23 | bases=(models.Model,), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='django-positions', 5 | version='0.6.0', 6 | description='A Django field for custom model ordering', 7 | author='Joel Watts', 8 | author_email='joel@joelwatts.com', 9 | url='http://github.com/jpwatts/django-positions', 10 | packages=find_packages(), 11 | classifiers=[ 12 | 'Development Status :: 3 - Alpha', 13 | 'Environment :: Web Environment', 14 | 'Intended Audience :: Developers', 15 | 'License :: OSI Approved :: BSD License', 16 | 'Operating System :: OS Independent', 17 | 'Programming Language :: Python', 18 | 'Framework :: Django', 19 | ], 20 | include_package_data=True, 21 | zip_safe=False, 22 | install_requires=[ 23 | 'Django', 24 | 'setuptools' 25 | ], 26 | ) 27 | -------------------------------------------------------------------------------- /examples/migration/tests.py: -------------------------------------------------------------------------------- 1 | from unittest import skipIf 2 | from .models import MigrationTest 3 | from django.test import TestCase 4 | import django 5 | 6 | 7 | class MigrationTestCase(TestCase): 8 | @skipIf(django.VERSION < (1,7), 'Skipping migration test because Django < 1.7') 9 | def test_migration(self): 10 | # The data migration should have inserted the following record. This test just verifies that the data is there. 11 | # Ideally this test would run the migrations but setting up a data migration is faster for now. 12 | test = MigrationTest.objects.create(name="Some Person", age=37, favorite_color='Blue') 13 | result = list(MigrationTest.objects.order_by('position').values_list('name', 'age', 'favorite_color', 'position')) 14 | expected_result = [(u'Test Name', 99, u'Red', -1), (u'Some Person', 37, u'Blue', 0)] 15 | self.assertEqual(result, expected_result) 16 | -------------------------------------------------------------------------------- /examples/store/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from positions.fields import PositionField 4 | 5 | 6 | class Product(models.Model): 7 | name = models.CharField(max_length=50) 8 | 9 | 10 | def __unicode__(self): 11 | return self.name 12 | 13 | 14 | class Category(models.Model): 15 | name = models.CharField(max_length=50) 16 | products = models.ManyToManyField(Product, through='ProductCategory', related_name='categories') 17 | 18 | def __unicode__(self): 19 | return self.name 20 | 21 | 22 | class ProductCategory(models.Model): 23 | product = models.ForeignKey(Product, on_delete=models.CASCADE) 24 | category = models.ForeignKey(Category, on_delete=models.CASCADE) 25 | position = PositionField(collection='category') 26 | 27 | class Meta(object): 28 | unique_together = ('product', 'category') 29 | 30 | def __unicode__(self): 31 | return u"%s in %s" % (self.product, self.category) 32 | -------------------------------------------------------------------------------- /examples/restaurants/tests.py: -------------------------------------------------------------------------------- 1 | import doctest 2 | import unittest 3 | 4 | from django.db import models 5 | 6 | from examples.restaurants.models import Menu, Food, Drink 7 | 8 | from django.test import TestCase 9 | 10 | class GenericTestCase(TestCase): 11 | def setUp(self): 12 | pass 13 | 14 | def tearDown(self): 15 | pass 16 | 17 | # @unittest.skip("Some reason. If you are reading this in a test run someone did not fill this in.") 18 | def test_doctests_standin(self): 19 | # This code just contains the old doctests for this module. They should be most likely split out into their own 20 | # tests at some point. 21 | self.romanos = Menu.objects.create(name="Romano's Pizza") 22 | 23 | self.pizza = Food.objects.create(menu=self.romanos, name="Pepperoni") 24 | result = self.pizza.position 25 | expected_result = 0 26 | self.assertEqual(result, expected_result) 27 | 28 | self.wine = Drink.objects.create(menu=self.romanos, name="Merlot") 29 | result = self.wine.position 30 | expected_result = 0 31 | self.assertEqual(result, expected_result) 32 | 33 | self.spaghetti = Food.objects.create(menu=self.romanos, name="Spaghetti & Meatballs") 34 | result = self.spaghetti.position 35 | expected_result = 1 36 | self.assertEqual(result, expected_result) 37 | 38 | self.soda = Drink.objects.create(menu=self.romanos, name="Coca-Cola") 39 | result = self.soda.position 40 | expected_result = 1 41 | self.assertEqual(result, expected_result) 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008, Watts Lab, Inc. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the Watts Lab, Inc. nor the names of its contributors 15 | may be used to endorse or promote products derived from this software 16 | without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /examples/school/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | import doctest 3 | import unittest 4 | 5 | from django.db import models 6 | 7 | from examples.school.models import SubUnit, Lesson, Exercise 8 | 9 | 10 | class SchoolsTestCase(TestCase): 11 | def setUp(self): 12 | self.american_revolution = SubUnit.objects.create(name="American Revolution") 13 | self.no_taxation = Lesson.objects.create(sub_unit=self.american_revolution, title="No Taxation without Representation", text="...") 14 | self.assertEqual(self.no_taxation.position, 0) 15 | 16 | self.research_paper = Exercise.objects.create(sub_unit=self.american_revolution, title="Paper", description="Two pages, double spaced") 17 | self.assertEqual(self.research_paper.position, 1) 18 | 19 | self.tea_party = Lesson.objects.create(sub_unit=self.american_revolution, title="Boston Tea Party", text="...") 20 | self.assertEqual(self.tea_party.position, 2) 21 | 22 | self.quiz = Exercise.objects.create(sub_unit=self.american_revolution, title="Pop Quiz", description="...") 23 | self.assertEqual(self.quiz.position, 3) 24 | 25 | def tearDown(self): 26 | SubUnit.objects.all().delete() 27 | 28 | @unittest.skip("This should not fail! Skipping during test development.") 29 | def test_explicit_position(self): 30 | # create a task with an explicit position 31 | self.intro_lesson = Lesson.objects.create(sub_unit=self.american_revolution, title="The Intro", text="...", position=0) 32 | actual_order = list(self.american_revolution.task_set.values_list('title', 'position')) 33 | expected_order = [ 34 | (u'The Intro', 0), 35 | (u'No Taxation without Representation', 1), 36 | (u'Paper', 2), 37 | (u'Boston Tea Party', 3), 38 | (u'Pop Quiz', 4) 39 | ] 40 | self.assertEqual(actual_order, expected_order) -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | addons: 4 | postgresql: "9.3" 5 | 6 | matrix: 7 | include: 8 | - python: 2.7 9 | env: DJANGO_VERSION=1.11.12 DB=postgres 10 | - python: 2.7 11 | env: DJANGO_VERSION=1.11.12 DB=sqlite 12 | - python: 2.7 13 | env: DJANGO_VERSION=1.11.12 DB=mysql 14 | 15 | - python: 2.7 16 | env: DJANGO_VERSION=1.7.11 DB=postgres 17 | - python: 2.7 18 | env: DJANGO_VERSION=1.7.11 DB=sqlite 19 | - python: 2.7 20 | env: DJANGO_VERSION=1.7.11 DB=mysql 21 | 22 | - python: 2.7 23 | env: DJANGO_VERSION=1.6.11 DB=postgres 24 | - python: 2.7 25 | env: DJANGO_VERSION=1.6.11 DB=sqlite 26 | - python: 2.7 27 | env: DJANGO_VERSION=1.6.11 DB=mysql 28 | 29 | - python: 3.5 30 | env: DJANGO_VERSION=2.0.4 DB=postgres 31 | - python: 3.5 32 | env: DJANGO_VERSION=2.0.4 DB=mysql 33 | - python: 3.5 34 | env: DJANGO_VERSION=2.0.4 DB=sqlite 35 | 36 | - python: 3.5 37 | env: DJANGO_VERSION=1.11.12 DB=postgres 38 | - python: 3.5 39 | env: DJANGO_VERSION=1.11.12 DB=sqlite 40 | - python: 3.5 41 | env: DJANGO_VERSION=1.11.12 DB=mysql 42 | 43 | - python: 3.6 44 | env: DJANGO_VERSION=2.0.4 DB=postgres 45 | - python: 3.6 46 | env: DJANGO_VERSION=2.0.4 DB=mysql 47 | - python: 3.6 48 | env: DJANGO_VERSION=2.0.4 DB=sqlite 49 | 50 | - python: 3.6 51 | env: DJANGO_VERSION=1.11.12 DB=postgres 52 | - python: 3.6 53 | env: DJANGO_VERSION=1.11.12 DB=sqlite 54 | - python: 3.6 55 | env: DJANGO_VERSION=1.11.12 DB=mysql 56 | 57 | # command to install dependencies 58 | install: 59 | - "pip install -q Django==$DJANGO_VERSION" 60 | - "if [[ $DB == mysql ]]; then pip install -q mysqlclient; fi" 61 | - "if [[ $DB == postgres ]]; then pip install -q psycopg2; fi" 62 | 63 | before_script: 64 | - psql -c 'create database django_positions;' -U postgres 65 | - mysql -e 'create database django_positions;' 66 | 67 | # command to run tests 68 | script: python manage.py test --settings examples.ci_settings_$DB examples 69 | -------------------------------------------------------------------------------- /positions/managers.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Manager 2 | from django.db.models.query import QuerySet 3 | from django.db.models.signals import post_save 4 | 5 | from positions.fields import PositionField 6 | 7 | import logging 8 | logger = logging.getLogger(__name__) 9 | 10 | class PositionQuerySet(QuerySet): 11 | def __init__(self, model=None, query=None, using=None, position_field_name='position', hints=None): 12 | super(PositionQuerySet, self).__init__(model, query, using) 13 | self.position_field_name = position_field_name 14 | 15 | def _clone(self, *args, **kwargs): 16 | queryset = super(PositionQuerySet, self)._clone(*args, **kwargs) 17 | queryset.position_field_name = self.position_field_name 18 | return queryset 19 | 20 | def reposition(self, save=True): 21 | try: 22 | position_field = self.model._meta.get_field_by_name(self.position_field_name)[0] 23 | except AttributeError: 24 | # Handle Django 1.10+ which removes get_field_by_name 25 | position_field = self.model._meta.get_field(self.position_field_name) 26 | post_save.disconnect(position_field.update_on_save, sender=self.model) 27 | position = 0 28 | for obj in self.iterator(): 29 | setattr(obj, self.position_field_name, position) 30 | if save: 31 | obj.save() 32 | position += 1 33 | post_save.connect(position_field.update_on_save, sender=self.model) 34 | return self 35 | 36 | 37 | class PositionManager(Manager): 38 | def __init__(self, position_field_name='position'): 39 | super(PositionManager, self).__init__() 40 | self.position_field_name = position_field_name 41 | 42 | def get_queryset(self): 43 | return PositionQuerySet(self.model, position_field_name=self.position_field_name) 44 | 45 | def get_query_set(self): 46 | return self.get_queryset(self.model, position_field_name=self.position_field_name) 47 | 48 | def reposition(self): 49 | return self.get_queryset().reposition() 50 | -------------------------------------------------------------------------------- /examples/photos/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | import doctest 3 | import unittest 4 | import pprint 5 | from examples.photos.forms import PhotoForm 6 | from examples.photos.models import Album, Photo 7 | 8 | class PhotosTestCase(TestCase): 9 | def setUp(self): 10 | self.album = Album.objects.create(name="Vacation") 11 | self.bahamas = self.album.photos.create(name="Bahamas") 12 | self.bahamas_id = self.bahamas.id 13 | self.assertEqual(self.bahamas.position, 0) 14 | 15 | self.jamaica = self.album.photos.create(name="Jamaica") 16 | self.jamaica_id = self.jamaica.id 17 | self.assertEqual(self.jamaica.position, 0) 18 | 19 | self.grand_cayman = self.album.photos.create(name="Grand Cayman") 20 | self.grand_cayman_id = self.grand_cayman.id 21 | self.assertEqual(self.grand_cayman.position, 0) 22 | 23 | self.cozumel = self.album.photos.create(name="Cozumel") 24 | self.cozumel_id = self.cozumel.id 25 | self.assertEqual(self.cozumel.position, 0) 26 | 27 | def refresh(self): 28 | self.bahamas = Photo.objects.get(id=self.bahamas_id) 29 | self.jamaica = Photo.objects.get(id=self.jamaica_id) 30 | self.grand_cayman = Photo.objects.get(id=self.grand_cayman_id) 31 | self.cozumel = Photo.objects.get(id=self.cozumel_id) 32 | 33 | def tearDown(self): 34 | Album.objects.all().delete() 35 | 36 | def test_reordered_positions(self): 37 | ordered_by_position = list(self.album.photos.order_by('position').values_list('name', 'position')) 38 | expected_order = [(u'Cozumel', 0), (u'Grand Cayman', 1), (u'Jamaica', 2), (u'Bahamas', 3)] 39 | self.assertEqual( 40 | ordered_by_position, 41 | expected_order 42 | ) 43 | 44 | def test_renamed_positions(self): 45 | self.refresh() 46 | new_name = 'Cozumel, Mexico' 47 | self.cozumel.name = new_name 48 | self.cozumel.save(update_fields=['name']) 49 | self.refresh() 50 | self.assertEqual(self.cozumel.name, new_name) 51 | self.assertEqual(self.cozumel.position, 0) 52 | 53 | self.jamaica.name = "Ocho Rios, Jamaica" 54 | self.jamaica.save(update_fields=['name', 'position']) 55 | self.refresh() 56 | self.assertEqual(self.jamaica.position, 2) 57 | 58 | self.jamaica.position = -1 59 | self.jamaica.save(update_fields=['name', 'position']) 60 | self.refresh() 61 | self.assertEqual(self.jamaica.position, 3) 62 | 63 | def test_form_renamed_position(self): 64 | self.refresh() 65 | grand_cayman_form = PhotoForm(dict(name="Georgetown, Grand Cayman"), instance=self.grand_cayman) 66 | grand_cayman_form.save() 67 | self.refresh() 68 | self.assertEqual(self.grand_cayman.position, 1) 69 | -------------------------------------------------------------------------------- /examples/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for local project. 3 | 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/1.7/topics/settings/ 6 | 7 | For the full list of settings and their values, see 8 | https://docs.djangoproject.com/en/1.7/ref/settings/ 9 | """ 10 | 11 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 12 | import os 13 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 14 | 15 | 16 | # Quick-start development settings - unsuitable for production 17 | # See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/ 18 | 19 | # SECURITY WARNING: keep the secret key used in production secret! 20 | SECRET_KEY = 'wg2_iar76ne7o@uf7op87=4tqtnwch_civ79b1j%)=#i0q1*8p' 21 | 22 | # SECURITY WARNING: don't run with debug turned on in production! 23 | DEBUG = True 24 | 25 | TEMPLATE_DEBUG = True 26 | 27 | ALLOWED_HOSTS = [] 28 | 29 | 30 | # Application definition 31 | 32 | INSTALLED_APPS = ( 33 | 'django.contrib.admin', 34 | 'django.contrib.auth', 35 | 'django.contrib.contenttypes', 36 | 'django.contrib.sessions', 37 | 'django.contrib.messages', 38 | 'django.contrib.staticfiles', 39 | 'examples.todo', 40 | 'examples.lists', 41 | 'examples.nodes', 42 | 'examples.photos', 43 | 'examples.restaurants', 44 | 'examples.school', 45 | 'examples.store', 46 | 'examples.migration' 47 | ) 48 | 49 | MIDDLEWARE_CLASSES = ( 50 | 'django.contrib.sessions.middleware.SessionMiddleware', 51 | 'django.middleware.common.CommonMiddleware', 52 | 'django.middleware.csrf.CsrfViewMiddleware', 53 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 54 | 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 55 | 'django.contrib.messages.middleware.MessageMiddleware', 56 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 57 | ) 58 | 59 | 60 | # Database 61 | # https://docs.djangoproject.com/en/1.7/ref/settings/#databases 62 | 63 | DATABASES = { 64 | 'default': { 65 | 'ENGINE': 'django.db.backends.sqlite3', 66 | 'NAME': 'mydb', 67 | } 68 | } 69 | 70 | # Internationalization 71 | # https://docs.djangoproject.com/en/1.7/topics/i18n/ 72 | 73 | LANGUAGE_CODE = 'en-us' 74 | 75 | TIME_ZONE = 'UTC' 76 | 77 | USE_I18N = True 78 | 79 | USE_L10N = True 80 | 81 | USE_TZ = True 82 | 83 | 84 | # Static files (CSS, JavaScript, Images) 85 | # https://docs.djangoproject.com/en/1.7/howto/static-files/ 86 | 87 | STATIC_URL = '/static/' 88 | LOGGING = { 89 | 'version': 1, 90 | 'disable_existing_loggers': False, 91 | 'formatters': { 92 | 'verbose': { 93 | 'format': '%(levelname)s %(asctime)s %(module)s %(funcName)s %(process)d %(thread)d %(message)s' 94 | }, 95 | 'simple': { 96 | 'format': '%(levelname)s %(message)s' 97 | }, 98 | }, 99 | 'filters': { 100 | 'require_debug_false': { 101 | '()': 'django.utils.log.RequireDebugFalse' 102 | }, 103 | 'require_debug_true': { 104 | '()': 'django.utils.log.RequireDebugTrue' 105 | } 106 | }, 107 | 'handlers': { 108 | 'console':{ 109 | 'level': 'DEBUG', 110 | 'class': 'logging.StreamHandler', 111 | 'formatter': 'simple' 112 | }, 113 | 'debug_log_file':{ 114 | 'level': 'DEBUG', 115 | 'filters': ['require_debug_true'], 116 | 'class': 'logging.handlers.RotatingFileHandler', 117 | 'filename': 'debug.log', 118 | 'maxBytes': 1024*1024*5, 119 | 'formatter': 'verbose' 120 | }, 121 | }, 122 | 'loggers': { 123 | 'django.request': { 124 | 'handlers': ['debug_log_file'], 125 | 'level': 'INFO', 126 | 'propagate': True, 127 | }, 128 | 'django.db.backends': { 129 | 'handlers': ['debug_log_file'], 130 | 'level': 'DEBUG', 131 | 'propagate': False, 132 | }, 133 | '': { 134 | 'handlers': ['debug_log_file', 'console'], 135 | 'level': 'DEBUG', 136 | 'propagate': True, 137 | }, 138 | }, 139 | } -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | Django Positions 3 | ================ 4 | 5 | 6 | .. image:: https://travis-ci.org/jpwatts/django-positions.svg?branch=master 7 | :target: https://travis-ci.org/jpwatts/django-positions 8 | 9 | 10 | This module provides ``PositionField``, a model field for `Django`_ that allows 11 | instances of a model to be sorted by a user-specified position. Conceptually, 12 | the field works like a list index: when the position of one item is changed, the 13 | positions of other items in the collection are updated in response. 14 | 15 | 16 | Usage 17 | ----- 18 | 19 | Add a ``PositionField`` to your model; that's just about it. 20 | 21 | If you want to work with all instances of the model as a single collection, 22 | there's nothing else required. To create collections based on one or more 23 | fields on the model, specify the field names using the ``collection`` argument. 24 | 25 | The apps in ``positions.examples`` demonstrate the ``PositionField`` API. 26 | 27 | 28 | Indices 29 | ~~~~~~~ 30 | 31 | In general, the value assigned to a ``PositionField`` will be handled like a 32 | list index, to include negative values. Setting the position to ``-2`` will 33 | cause the item to be moved to the second position from the end of collection -- 34 | unless, of course, the collection has fewer than two elements. 35 | 36 | Behavior varies from standard list indices when values greater than or less than 37 | the maximum or minimum positions are used. In those cases, the value is handled 38 | as being the same as the maximum or minimum position, respectively. ``None`` is 39 | also a special case that will cause an item to be moved to the last position in 40 | its collection. 41 | 42 | Bulk updates 43 | ~~~~~~~~~~~~ 44 | 45 | The `PositionManager` custom manager uses `PositionQuerySet` to provide a 46 | `reposition` method that will update the position of all objects in the 47 | queryset to match the current ordering. If `reposition` is called on the 48 | manager itself, all objects will be repositioned according to the default 49 | model ordering. 50 | 51 | Be aware that, unlike repositioning objects one at a time using list indices, 52 | the `reposition` method will call the `save` method of every model instance 53 | in the queryset. 54 | 55 | Many-to-many 56 | ~~~~~~~~~~~~ 57 | 58 | Specifying a ``ManyToManyField`` as a ``collection`` won't work; use an 59 | intermediate model with a ``PositionField`` instead:: 60 | 61 | class Product(models.Model): 62 | name = models.CharField(max_length=50) 63 | 64 | class Category(models.Model): 65 | name = models.CharField(max_length=50) 66 | products = models.ManyToManyField(Product, through='ProductCategory', related_name='categories') 67 | 68 | class ProductCategory(models.Model): 69 | product = models.ForeignKey(Product) 70 | category = models.ForeignKey(Category) 71 | position = PositionField(collection='category') 72 | 73 | class Meta(object): 74 | unique_together = ('product', 'category') 75 | 76 | 77 | Multi-table model inheritance 78 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 79 | 80 | By default, if a parent model has a position field that declares a collection, 81 | child model instances are ordered independently. This behavior can be changed 82 | by specifying a `parent_link` argument identifying the name of the one-to-one 83 | field linking the child model to the parent. If `parent_link` is set, all subclass 84 | instances will be part of a single sequence in each collection. 85 | 86 | 87 | Limitations 88 | ----------- 89 | 90 | * Unique constraints can't be applied to ``PositionField`` because they break 91 | the ability to update other items in a collection all at once. This one was 92 | a bit painful, because setting the constraint is probably the right thing to 93 | do from a database consistency perspective, but the overhead in additional 94 | queries was too much to bear. 95 | 96 | * After a position has been updated, other members of the collection are updated 97 | using a single SQL ``UPDATE`` statement, this means the ``save`` method of the 98 | other instances won't be called. As a partial work-around to this issue, 99 | any ``DateTimeField`` with ``auto_now=True`` will be assigned the current time. 100 | 101 | 102 | .. _`Django`: http://www.djangoproject.com/ 103 | -------------------------------------------------------------------------------- /examples/store/tests.py: -------------------------------------------------------------------------------- 1 | import doctest 2 | import unittest 3 | 4 | from django.db import models 5 | 6 | from positions import PositionField 7 | from examples.store.models import Product, Category, ProductCategory 8 | 9 | from django.test import TestCase 10 | 11 | class StoreTestCase(TestCase): 12 | def setUp(self): 13 | pass 14 | 15 | def tearDown(self): 16 | Category.objects.all().delete() 17 | Product.objects.all().delete() 18 | ProductCategory.objects.all().delete() 19 | 20 | # @unittest.skip("Some reason. If you are reading this in a test run someone did not fill this in.") 21 | def test_doctests_standin(self): 22 | # This code just contains the old doctests for this module. They should be most likely split out into their own 23 | # tests at some point. 24 | self.clothes = Category.objects.create(name="Clothes") 25 | self.sporting_goods = Category.objects.create(name="Sporting Goods") 26 | 27 | self.bat = Product.objects.create(name="Bat") 28 | self.bat_in_sporting_goods = ProductCategory.objects.create(product=self.bat, category=self.sporting_goods) 29 | 30 | self.cap = Product.objects.create(name="Cap") 31 | self.cap_in_sporting_goods = ProductCategory.objects.create(product=self.cap, category=self.sporting_goods) 32 | self.cap_in_clothes = ProductCategory.objects.create(product=self.cap, category=self.clothes) 33 | 34 | self.glove = Product.objects.create(name="Glove") 35 | self.glove_in_sporting_goods = ProductCategory.objects.create(product=self.glove, category=self.sporting_goods) 36 | 37 | self.tshirt = Product.objects.create(name="T-shirt") 38 | self.tshirt_in_clothes = ProductCategory.objects.create(product=self.tshirt, category=self.clothes) 39 | 40 | self.jeans = Product.objects.create(name="Jeans") 41 | self.jeans_in_clothes = ProductCategory.objects.create(product=self.jeans, category=self.clothes) 42 | 43 | self.jersey = Product.objects.create(name="Jersey") 44 | self.jersey_in_sporting_goods = ProductCategory.objects.create(product=self.jersey, category=self.sporting_goods) 45 | self.jersey_in_clothes = ProductCategory.objects.create(product=self.jersey, category=self.clothes) 46 | 47 | self.ball = Product.objects.create(name="Ball") 48 | self.ball_in_sporting_goods = ProductCategory.objects.create(product=self.ball, category=self.sporting_goods) 49 | 50 | actual_order = list(ProductCategory.objects.filter(category=self.clothes).values_list('product__name', 'position').order_by('position')) 51 | expected_order = [(u'Cap', 0), (u'T-shirt', 1), (u'Jeans', 2), (u'Jersey', 3)] 52 | self.assertEqual(actual_order, expected_order) 53 | 54 | actual_order = list(ProductCategory.objects.filter(category=self.sporting_goods).values_list('product__name', 'position').order_by('position')) 55 | expected_order = [(u'Bat', 0), (u'Cap', 1), (u'Glove', 2), (u'Jersey', 3), (u'Ball', 4)] 56 | self.assertEqual(actual_order, expected_order) 57 | 58 | # Moving cap in sporting goods shouldn't effect its position in clothes. 59 | 60 | self.cap_in_sporting_goods.position = -1 61 | self.cap_in_sporting_goods.save() 62 | 63 | actual_order = list(ProductCategory.objects.filter(category=self.clothes).values_list('product__name', 'position').order_by('position')) 64 | expected_order = [(u'Cap', 0), (u'T-shirt', 1), (u'Jeans', 2), (u'Jersey', 3)] 65 | self.assertEqual(actual_order, expected_order) 66 | 67 | actual_order = list(ProductCategory.objects.filter(category=self.sporting_goods).values_list('product__name', 'position').order_by('position')) 68 | expected_order = [(u'Bat', 0), (u'Glove', 1), (u'Jersey', 2), (u'Ball', 3), (u'Cap', 4)] 69 | self.assertEqual(actual_order, expected_order) 70 | 71 | # Deleting an object should reorder both collections. 72 | self.cap.delete() 73 | 74 | actual_order = list(ProductCategory.objects.filter(category=self.clothes).values_list('product__name', 'position').order_by('position')) 75 | expected_order = [(u'T-shirt', 0), (u'Jeans', 1), (u'Jersey', 2)] 76 | self.assertEqual(actual_order, expected_order) 77 | 78 | actual_order = list(ProductCategory.objects.filter(category=self.sporting_goods).values_list('product__name', 'position').order_by('position')) 79 | expected_order = [(u'Bat', 0), (u'Glove', 1), (u'Jersey', 2), (u'Ball', 3)] 80 | self.assertEqual(actual_order, expected_order) 81 | -------------------------------------------------------------------------------- /examples/todo/tests.py: -------------------------------------------------------------------------------- 1 | import doctest 2 | import unittest 3 | 4 | from examples.todo.models import Item 5 | 6 | from django.test import TestCase 7 | 8 | class GenericTestCase(TestCase): 9 | def setUp(self): 10 | pass 11 | 12 | def tearDown(self): 13 | pass 14 | 15 | # @unittest.skip("Some reason. If you are reading this in a test run someone did not fill this in.") 16 | def test_doctests_standin(self): 17 | # This code just contains the old doctests for this module. They should be most likely split out into their own 18 | # tests at some point. 19 | result = Item.objects.position_field_name 20 | expected_result = 'index' 21 | self.assertEqual(result, expected_result) 22 | 23 | result = Item.objects.all().position_field_name 24 | expected_result = 'index' 25 | self.assertEqual(result, expected_result) 26 | 27 | result = Item.objects.create(description="Add a `reposition` method").description 28 | expected_result = 'Add a `reposition` method' 29 | self.assertEqual(result, expected_result) 30 | 31 | result = Item.objects.create(description="Write some tests").description 32 | expected_result = 'Write some tests' 33 | self.assertEqual(result, expected_result) 34 | 35 | result = Item.objects.create(description="Push to GitHub").description 36 | expected_result = 'Push to GitHub' 37 | self.assertEqual(result, expected_result) 38 | 39 | result = list(Item.objects.order_by('index').values_list('description')) 40 | expected_result = [(u'Add a `reposition` method',), (u'Write some tests',), (u'Push to GitHub',)] 41 | self.assertEqual(result, expected_result) 42 | 43 | self.alphabetized = Item.objects.order_by('description') 44 | result = list(self.alphabetized.values_list('description')) 45 | expected_result = [(u'Add a `reposition` method',), (u'Push to GitHub',), (u'Write some tests',)] 46 | self.assertEqual(result, expected_result) 47 | 48 | result = self.alphabetized.position_field_name 49 | expected_result = 'index' 50 | self.assertEqual(result, expected_result) 51 | 52 | self.repositioned = self.alphabetized.reposition(save=False) 53 | result = list(self.repositioned.values_list('description')) 54 | expected_result = [(u'Add a `reposition` method',), (u'Push to GitHub',), (u'Write some tests',)] 55 | self.assertEqual(result, expected_result) 56 | 57 | # Make sure the position wasn't saved 58 | result = list(Item.objects.order_by('index').values_list('description')) 59 | expected_result = [(u'Add a `reposition` method',), (u'Write some tests',), (u'Push to GitHub',)] 60 | self.assertEqual(result, expected_result) 61 | 62 | self.repositioned = self.alphabetized.reposition() 63 | result = list(self.repositioned.values_list('description')) 64 | expected_result = [(u'Add a `reposition` method',), (u'Push to GitHub',), (u'Write some tests',)] 65 | self.assertEqual(result, expected_result) 66 | 67 | result = list(Item.objects.order_by('index').values_list('description')) 68 | expected_result = [(u'Add a `reposition` method',), (u'Push to GitHub',), (u'Write some tests',)] 69 | self.assertEqual(result, expected_result) 70 | 71 | self.item = Item.objects.order_by('index')[0] 72 | result = self.item.description 73 | expected_result = 'Add a `reposition` method' 74 | self.assertEqual(result, expected_result) 75 | 76 | result = self.item.index 77 | expected_result = 0 78 | 79 | self.item.index = -1 80 | self.item.save() 81 | 82 | # Make sure the signals are still connected 83 | result = list(Item.objects.order_by('index').values_list('description')) 84 | expected_result = [(u'Push to GitHub',), (u'Write some tests',), (u'Add a `reposition` method',)] 85 | self.assertEqual(result, expected_result) 86 | 87 | result = [i.index for i in Item.objects.order_by('index')] 88 | expected_result = [0, 1, 2] 89 | self.assertEqual(result, expected_result) 90 | 91 | 92 | # Add an item at position zero 93 | # http://github.com/jpwatts/django-positions/issues/#issue/7 94 | 95 | self.item0 = Item(description="Fix Issue #7") 96 | self.item0.index = 0 97 | self.item0.save() 98 | 99 | result = list(Item.objects.values_list('description', 'index').order_by('index')) 100 | expected_result = [(u'Fix Issue #7', 0), (u'Push to GitHub', 1), (u'Write some tests', 2), (u'Add a `reposition` method', 3)] 101 | self.assertEqual(result, expected_result) 102 | -------------------------------------------------------------------------------- /examples/lists/tests.py: -------------------------------------------------------------------------------- 1 | import time 2 | import doctest 3 | import unittest 4 | 5 | from examples.lists.models import List, Item 6 | 7 | from django.test import TestCase 8 | 9 | class GenericTestCase(TestCase): 10 | def setUp(self): 11 | pass 12 | 13 | def tearDown(self): 14 | pass 15 | 16 | # @unittest.skip("Some reason. If you are reading this in a test run someone did not fill this in.") 17 | def test_doctests_standin(self): 18 | # This code just contains the old doctests for this module. They should be most likely split out into their own 19 | # tests at some point. 20 | self.l = List.objects.create(name='To Do') 21 | 22 | # create a couple items using the default position 23 | result = self.l.items.create(name='Write Tests').name 24 | expected_result = 'Write Tests' 25 | self.assertEqual(result, expected_result) 26 | 27 | result = list(self.l.items.values_list('name', 'position')) 28 | expected_result = [(u'Write Tests', 0)] 29 | self.assertEqual(result, expected_result) 30 | 31 | result = self.l.items.create(name='Exercise').name 32 | expected_result = 'Exercise' 33 | self.assertEqual(result, expected_result) 34 | 35 | result = list(self.l.items.values_list('name', 'position').order_by('position')) 36 | expected_result = [(u'Write Tests', 0), (u'Exercise', 1)] 37 | self.assertEqual(result, expected_result) 38 | 39 | # create an item with an explicit position 40 | result = self.l.items.create(name='Learn to spell Exercise', position=0).name 41 | expected_result = 'Learn to spell Exercise' 42 | self.assertEqual(result, expected_result) 43 | 44 | result = list(self.l.items.values_list('name', 'position').order_by('position')) 45 | expected_result = [(u'Learn to spell Exercise', 0), (u'Write Tests', 1), (u'Exercise', 2)] 46 | self.assertEqual(result, expected_result) 47 | 48 | # save an item without changing it's position 49 | self.exercise = self.l.items.order_by('-position')[0] 50 | self.exercise.name = 'Exercise' 51 | self.exercise.save() 52 | 53 | result = list(self.l.items.values_list('name', 'position').order_by('position')) 54 | expected_result = [(u'Learn to spell Exercise', 0), (u'Write Tests', 1), (u'Exercise', 2)] 55 | self.assertEqual(result, expected_result) 56 | 57 | # delete an item 58 | self.learn_to_spell = self.l.items.order_by('position')[0] 59 | self.learn_to_spell.delete() 60 | 61 | result = list(self.l.items.values_list('name', 'position').order_by('position')) 62 | expected_result = [(u'Write Tests', 0), (u'Exercise', 1)] 63 | self.assertEqual(result, expected_result) 64 | 65 | # create a couple more items 66 | result = self.l.items.create(name='Drink less Coke').name 67 | expected_result = 'Drink less Coke' 68 | self.assertEqual(result, expected_result) 69 | 70 | result = self.l.items.create(name='Go to Bed').name 71 | expected_result = 'Go to Bed' 72 | self.assertEqual(result, expected_result) 73 | 74 | result = list(self.l.items.values_list('name', 'position').order_by('position')) 75 | expected_result = [(u'Write Tests', 0), (u'Exercise', 1), (u'Drink less Coke', 2), (u'Go to Bed', 3)] 76 | self.assertEqual(result, expected_result) 77 | 78 | # move item to end using None 79 | self.write_tests = self.l.items.order_by('position')[0] 80 | self.write_tests.position = None 81 | self.write_tests.save() 82 | result = list(self.l.items.values_list('name', 'position').order_by('position')) 83 | expected_result = [(u'Exercise', 0), (u'Drink less Coke', 1), (u'Go to Bed', 2), (u'Write Tests', 3)] 84 | self.assertEqual(result, expected_result) 85 | 86 | # move item using negative index 87 | self.write_tests.position = -3 88 | self.write_tests.save() 89 | result = list(self.l.items.values_list('name', 'position').order_by('position')) 90 | expected_result = [(u'Exercise', 0), (u'Write Tests', 1), (u'Drink less Coke', 2), (u'Go to Bed', 3)] 91 | self.assertEqual(result, expected_result) 92 | 93 | # move item to position 94 | self.write_tests.position = 2 95 | self.write_tests.save() 96 | 97 | result = list(self.l.items.values_list('name', 'position').order_by('position')) 98 | expected_result = [(u'Exercise', 0), (u'Drink less Coke', 1), (u'Write Tests', 2), (u'Go to Bed', 3)] 99 | self.assertEqual(result, expected_result) 100 | 101 | # move item to beginning 102 | self.sleep = self.l.items.order_by('-position')[0] 103 | self.sleep.position = 0 104 | self.sleep.save() 105 | 106 | result = list(self.l.items.values_list('name', 'position').order_by('position')) 107 | expected_result = [(u'Go to Bed', 0), (u'Exercise', 1), (u'Drink less Coke', 2), (u'Write Tests', 3)] 108 | self.assertEqual(result, expected_result) 109 | 110 | # check auto_now updates 111 | time.sleep(1) # sleep to guarantee updated time increases 112 | sleep_updated, exercise_updated, eat_better_updated, write_tests_updated = [i.updated for i in self.l.items.order_by('position')] 113 | self.eat_better = self.l.items.order_by('-position')[1] 114 | self.eat_better.position = 1 115 | self.eat_better.save() 116 | self.todo_list = list(self.l.items.order_by('position')) 117 | 118 | self.assertEqual(sleep_updated, self.todo_list[0].updated) 119 | 120 | self.assertLessEqual(eat_better_updated, self.todo_list[1].updated) 121 | 122 | self.assertLessEqual(exercise_updated, self.todo_list[2].updated) 123 | 124 | # create an item using negative index 125 | # http://github.com/jpwatts/django-positions/issues/#issue/5 126 | result = list(self.l.items.values_list('name', 'position').order_by('position')) 127 | expected_result = [(u'Go to Bed', 0), (u'Drink less Coke', 1), (u'Exercise', 2), (u'Write Tests', 3)] 128 | self.assertEqual(result, expected_result) 129 | 130 | self.fix_issue_5 = Item(list=self.l, name="Fix Issue #5") 131 | result = self.fix_issue_5.position 132 | expected_result = -1 133 | self.assertEqual(result, expected_result) 134 | 135 | self.fix_issue_5.position = -2 136 | result = self.fix_issue_5.position 137 | expected_result = -2 138 | self.assertEqual(result, expected_result) 139 | 140 | self.fix_issue_5.save() 141 | result = self.fix_issue_5.position 142 | expected_result = 3 143 | self.assertEqual(result, expected_result) 144 | 145 | result = list(self.l.items.values_list('name', 'position').order_by('position')) 146 | expected_result = [(u'Go to Bed', 0), (u'Drink less Coke', 1), (u'Exercise', 2), (u'Fix Issue #5', 3), (u'Write Tests', 4)] 147 | self.assertEqual(result, expected_result) 148 | 149 | # Try again, now that the model has been saved. 150 | self.fix_issue_5.position = -2 151 | self.fix_issue_5.save() 152 | result = self.fix_issue_5.position 153 | expected_result = 3 154 | self.assertEqual(result, expected_result) 155 | 156 | result = list(self.l.items.values_list('name', 'position').order_by('position')) 157 | expected_result = [(u'Go to Bed', 0), (u'Drink less Coke', 1), (u'Exercise', 2), (u'Fix Issue #5', 3), (u'Write Tests', 4)] 158 | self.assertEqual(result, expected_result) 159 | 160 | # create an item using with a position of zero 161 | # http://github.com/jpwatts/django-positions/issues#issue/7 162 | self.item0 = self.l.items.create(name="Fix Issue #7", position=0) 163 | result = self.item0.position 164 | expected_result = 0 165 | self.assertEqual(result, expected_result) 166 | 167 | result = list(self.l.items.values_list('name', 'position').order_by('position')) 168 | expected_result = [(u'Fix Issue #7', 0), (u'Go to Bed', 1), (u'Drink less Coke', 2), (u'Exercise', 3), (u'Fix Issue #5', 4), (u'Write Tests', 5)] 169 | self.assertEqual(result, expected_result) 170 | -------------------------------------------------------------------------------- /examples/nodes/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from examples.nodes.models import Node 3 | import doctest 4 | import os 5 | import unittest 6 | 7 | 8 | class NodesTestCase(TestCase): 9 | def setUp(self): 10 | """ 11 | Creates a simple tree:: 12 | 13 | parent1 14 | child2 15 | child1 16 | child3 17 | parent2 18 | child4 19 | child5 20 | child6 21 | """ 22 | self.parent1 = Node.objects.create(name='Parent 1') 23 | self.parent2 = Node.objects.create(name='Parent 2') 24 | self.child1 = self.parent1.children.create(name='Child 1') 25 | self.child2 = self.parent1.children.create(name='Child 2') 26 | self.child3 = self.parent1.children.create(name='Child 3') 27 | self.child2.position = 0 28 | self.child2.save() 29 | self.child1 = Node.objects.get(pk=self.child1.pk) 30 | self.child2 = Node.objects.get(pk=self.child2.pk) 31 | self.child3 = Node.objects.get(pk=self.child3.pk) 32 | 33 | self.child4 = self.parent2.children.create(name='Child 4') 34 | self.child5 = self.parent2.children.create(name='Child 5') 35 | self.child6 = self.parent2.children.create(name='Child 6') 36 | 37 | def tearDown(self): 38 | Node.objects.all().delete() 39 | 40 | def test_structure(self): 41 | """ 42 | Tests the tree structure 43 | """ 44 | result = list(Node.objects.order_by('parent__position', 'position').values_list('name', 'position')) 45 | expected_result = [(u'Parent 1', 0), (u'Parent 2', 1), (u'Child 2', 0), (u'Child 1', 1), (u'Child 3', 2), (u'Child 4', 0), (u'Child 5', 1), (u'Child 6', 2)] 46 | self.assertEqual(sorted(result), sorted(expected_result)) 47 | 48 | def test_collection_field_change_sibling_position(self): 49 | """ 50 | Set child6 as the first sibling in its branch. 51 | """ 52 | self.child6.position = 0 53 | self.child6.save() 54 | 55 | result = list(Node.objects.order_by('parent__position', 'position').values_list('name', 'position')) 56 | expected_result = [(u'Parent 1', 0), (u'Parent 2', 1), (u'Child 2', 0), (u'Child 1', 1), (u'Child 3', 2), (u'Child 6', 0), (u'Child 4', 1), (u'Child 5', 2)] 57 | self.assertEqual(sorted(result), sorted(expected_result)) 58 | 59 | def test_collection_field_change_first_child(self): 60 | """ 61 | Move child2 to make it the first child of parent2 62 | """ 63 | self.child2.position = 0 64 | self.child2.parent = Node.objects.get(pk=self.parent2.pk) 65 | self.child2.save() 66 | 67 | result = list(Node.objects.order_by('parent__position', 'position').values_list('name', 'position')) 68 | expected_result = [(u'Parent 1', 0), (u'Parent 2', 1), (u'Child 1', 0), (u'Child 3', 1), (u'Child 2', 0), (u'Child 4', 1), (u'Child 5', 2), (u'Child 6', 3)] 69 | self.assertEqual(sorted(result), sorted(expected_result)) 70 | 71 | def test_collection_field_change_last_child(self): 72 | """ 73 | Move child2 to make it the last child of parent2 74 | """ 75 | 76 | self.child2.position = -1 77 | self.child2.parent = Node.objects.get(pk=self.parent2.pk) 78 | self.child2.save() 79 | 80 | result = list(Node.objects.order_by('parent__position', 'position').values_list('name', 'position')) 81 | expected_result = [(u'Parent 1', 0), (u'Parent 2', 1), (u'Child 1', 0), (u'Child 3', 1), (u'Child 4', 0), (u'Child 5', 1), (u'Child 6', 2), (u'Child 2', 3)] 82 | self.assertEqual(sorted(result), sorted(expected_result)) 83 | 84 | def test_collection_field_change_sibling_1(self): 85 | """ 86 | Move child2 to make it the next sibling of child4 87 | """ 88 | 89 | self.child2.position = 1 90 | self.child2.parent = Node.objects.get(pk=self.parent2.pk) 91 | self.child2.save() 92 | 93 | result = list(Node.objects.order_by('parent__position', 'position').values_list('name', 'position')) 94 | expected_result = [(u'Parent 1', 0), (u'Parent 2', 1), (u'Child 1', 0), (u'Child 3', 1), (u'Child 4', 0), (u'Child 2', 1), (u'Child 5', 2), (u'Child 6', 3)] 95 | self.assertEqual(sorted(result), sorted(expected_result)) 96 | 97 | def test_collection_field_change_sibling_2(self): 98 | """ 99 | Move child2 to make it the next sibling of child5 100 | """ 101 | 102 | self.child2.position = 2 103 | self.child2.parent = Node.objects.get(pk=self.parent2.pk) 104 | self.child2.save() 105 | 106 | result = list(Node.objects.order_by('parent__position', 'position').values_list('name', 'position')) 107 | expected_result = [(u'Parent 1', 0), (u'Parent 2', 1), (u'Child 1', 0), (u'Child 3', 1), (u'Child 4', 0), (u'Child 5', 1), (u'Child 2', 2), (u'Child 6', 3)] 108 | self.assertEqual(sorted(result), sorted(expected_result)) 109 | 110 | def test_collection_field_change_sibling_3(self): 111 | """ 112 | Move child2 to make it the next sibling of child6 (last child) 113 | """ 114 | 115 | self.child2.position = 3 116 | self.child2.parent = Node.objects.get(pk=self.parent2.pk) 117 | self.child2.save() 118 | 119 | result = list(Node.objects.order_by('parent__position', 'position').values_list('name', 'position')) 120 | expected_result = [(u'Parent 1', 0), (u'Parent 2', 1), (u'Child 1', 0), (u'Child 3', 1), (u'Child 4', 0), (u'Child 5', 1), (u'Child 6', 2), (u'Child 2', 3)] 121 | self.assertEqual(sorted(result), sorted(expected_result)) 122 | 123 | def test_deletion_1(self): 124 | """ 125 | Delete child2 126 | """ 127 | self.child2.delete() 128 | result = list(Node.objects.order_by('parent__position', 'position').values_list('name', 'position')) 129 | expected_result = [(u'Parent 1', 0), (u'Parent 2', 1), (u'Child 1', 0), (u'Child 3', 1), (u'Child 4', 0), (u'Child 5', 1), (u'Child 6', 2)] 130 | self.assertEqual(sorted(result), sorted(expected_result)) 131 | 132 | def test_deletion_2(self): 133 | """ 134 | Delete child3 135 | """ 136 | self.child3.delete() 137 | result = list(Node.objects.order_by('parent__position', 'position').values_list('name', 'position')) 138 | expected_result = [(u'Parent 1', 0), (u'Parent 2', 1), (u'Child 2', 0), (u'Child 1', 1), (u'Child 4', 0), (u'Child 5', 1), (u'Child 6', 2)] 139 | self.assertEqual(sorted(result), sorted(expected_result)) 140 | 141 | def test_deletion_3(self): 142 | """ 143 | Delete child1 144 | """ 145 | self.child1.delete() 146 | result = list(Node.objects.order_by('parent__position', 'position').values_list('name', 'position')) 147 | expected_result = [(u'Parent 1', 0), (u'Parent 2', 1), (u'Child 2', 0), (u'Child 3', 1), (u'Child 4', 0), (u'Child 5', 1), (u'Child 6', 2)] 148 | self.assertEqual(sorted(result), sorted(expected_result)) 149 | 150 | def test_deletion_4(self): 151 | """ 152 | Delete parent1 153 | """ 154 | self.parent1.delete() 155 | result = list(Node.objects.order_by('parent__position', 'position').values_list('name', 'position')) 156 | expected_result = [(u'Parent 2', 0), (u'Child 4', 0), (u'Child 5', 1), (u'Child 6', 2)] 157 | self.assertEqual(sorted(result), sorted(expected_result)) 158 | 159 | 160 | class ReorderTestCase(TestCase): 161 | def tearDown(self): 162 | Node.objects.all().delete() 163 | 164 | @unittest.skip("Not sure if this should fail or not. Skipping until there is time to figure it out.") 165 | def test_assigning_parent(self): 166 | a = Node.objects.create(name=u"A") 167 | b = Node.objects.create(name=u"B") 168 | c = Node.objects.create(name=u"C") 169 | self.assertEqual(a.position, 0) 170 | self.assertEqual(b.position, 1) 171 | self.assertEqual(c.position, 2) 172 | b.parent = a 173 | b.save() 174 | # A hasn't changed. 175 | self.assertEqual(a.position, 0) 176 | # B has been positioned relative to A. 177 | self.assertEqual(b.position, 0) 178 | # C has moved up to fill the gap left by B. 179 | self.assertEqual(c.position, 1) 180 | 181 | @unittest.skip("Not sure if this should fail or not. Skipping until there is time to figure it out.") 182 | def test_changing_parent(self): 183 | a = Node.objects.create(name=u"A") 184 | b = Node.objects.create(name=u"B") 185 | c = Node.objects.create(name=u"C", parent=a) 186 | d = Node.objects.create(name=u"D", parent=a) 187 | self.assertEqual(a.parent, None) 188 | self.assertEqual(a.position, 0) 189 | self.assertEqual(b.parent, None) 190 | self.assertEqual(b.position, 1) 191 | self.assertEqual(c.parent, a) 192 | self.assertEqual(c.position, 0) 193 | self.assertEqual(d.parent, a) 194 | self.assertEqual(d.position, 1) 195 | c.parent = b 196 | c.save() 197 | # A's position hasn't changed. 198 | self.assertEqual(a.parent, None) 199 | self.assertEqual(a.position, 0) 200 | # B's position hasn't changed. 201 | self.assertEqual(b.parent, None) 202 | self.assertEqual(b.position, 1) 203 | # C's relative position hasn't changed. 204 | self.assertEqual(c.parent, b) 205 | self.assertEqual(c.position, 0) 206 | # D has moved up to fill the gap left by C. 207 | self.assertEqual(d.parent, a) 208 | self.assertEqual(d.position, 0) 209 | 210 | 211 | def suite(): 212 | s = unittest.TestSuite() 213 | s.addTest(unittest.TestLoader().loadTestsFromTestCase(NodesTestCase)) 214 | s.addTest(doctest.DocFileSuite(os.path.join('doctests', 'nodes.txt'), optionflags=doctest.REPORT_ONLY_FIRST_FAILURE)) 215 | return s 216 | -------------------------------------------------------------------------------- /positions/fields.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import warnings 3 | 4 | from django.db import models 5 | from django.db.models.signals import post_delete, post_save, pre_delete 6 | 7 | try: 8 | from django.utils.timezone import now 9 | except ImportError: 10 | now = datetime.datetime.now 11 | 12 | # define basestring for python 3 13 | try: 14 | basestring 15 | except NameError: 16 | basestring = (str, bytes) 17 | 18 | 19 | class PositionField(models.IntegerField): 20 | def __init__(self, verbose_name=None, name=None, default=-1, collection=None, parent_link=None, unique_for_field=None, unique_for_fields=None, *args, **kwargs): 21 | if 'unique' in kwargs: 22 | raise TypeError("%s can't have a unique constraint." % self.__class__.__name__) 23 | super(PositionField, self).__init__(verbose_name, name, default=default, *args, **kwargs) 24 | 25 | # Backwards-compatibility mess begins here. 26 | if collection is not None and unique_for_field is not None: 27 | raise TypeError("'collection' and 'unique_for_field' are incompatible arguments.") 28 | 29 | if collection is not None and unique_for_fields is not None: 30 | raise TypeError("'collection' and 'unique_for_fields' are incompatible arguments.") 31 | 32 | if unique_for_field is not None: 33 | warnings.warn("The 'unique_for_field' argument is deprecated. Please use 'collection' instead.", DeprecationWarning) 34 | if unique_for_fields is not None: 35 | raise TypeError("'unique_for_field' and 'unique_for_fields' are incompatible arguments.") 36 | collection = unique_for_field 37 | 38 | if unique_for_fields is not None: 39 | warnings.warn("The 'unique_for_fields' argument is deprecated. Please use 'collection' instead.", DeprecationWarning) 40 | collection = unique_for_fields 41 | # Backwards-compatibility mess ends here. 42 | 43 | if isinstance(collection, basestring): 44 | collection = (collection,) 45 | self.collection = collection 46 | self._next_sibling_pk_label = '_next_sibling_pk' 47 | if collection is not None: 48 | self._next_sibling_pk_label += '_' + '_'.join(self.collection) 49 | self.parent_link = parent_link 50 | self._collection_changed = None 51 | 52 | def contribute_to_class(self, cls, name): 53 | super(PositionField, self).contribute_to_class(cls, name) 54 | for constraint in cls._meta.unique_together: 55 | if self.name in constraint: 56 | raise TypeError("%s can't be part of a unique constraint." % self.__class__.__name__) 57 | self.auto_now_fields = [] 58 | for field in cls._meta.fields: 59 | if getattr(field, 'auto_now', False): 60 | self.auto_now_fields.append(field) 61 | setattr(cls, self.name, self) 62 | pre_delete.connect(self.prepare_delete, sender=cls) 63 | post_delete.connect(self.update_on_delete, sender=cls) 64 | post_save.connect(self.update_on_save, sender=cls) 65 | 66 | def pre_save(self, model_instance, add): 67 | #NOTE: check if the node has been moved to another collection; if it has, delete it from the old collection. 68 | previous_instance = None 69 | collection_changed = False 70 | if not add and self.collection is not None: 71 | try: 72 | previous_instance = type(model_instance)._default_manager.get(pk=model_instance.pk) 73 | for field_name in self.collection: 74 | field = model_instance._meta.get_field(field_name) 75 | current_field_value = getattr(model_instance, field.attname) 76 | previous_field_value = getattr(previous_instance, field.attname) 77 | if previous_field_value != current_field_value: 78 | collection_changed = True 79 | break 80 | except models.ObjectDoesNotExist: 81 | add = True 82 | if not collection_changed: 83 | previous_instance = None 84 | 85 | self._collection_changed = collection_changed 86 | if collection_changed: 87 | self.remove_from_collection(previous_instance) 88 | 89 | cache_name = self.get_cache_name() 90 | current, updated = getattr(model_instance, cache_name) 91 | 92 | if collection_changed: 93 | current = None 94 | 95 | if add: 96 | if updated is None: 97 | updated = current 98 | current = None 99 | 100 | # existing instance, position not modified; no cleanup required 101 | if current is not None and updated is None: 102 | return current 103 | 104 | # if updated is still unknown set the object to the last position, 105 | # either it is a new object or collection has been changed 106 | if updated is None: 107 | updated = -1 108 | 109 | collection_count = self.get_collection(model_instance).count() 110 | if current is None: 111 | max_position = collection_count 112 | else: 113 | max_position = collection_count - 1 114 | min_position = 0 115 | 116 | # new instance; appended; no cleanup required on post_save 117 | if add and (updated == -1 or updated >= max_position): 118 | setattr(model_instance, cache_name, (max_position, None)) 119 | return max_position 120 | 121 | if max_position >= updated >= min_position: 122 | # positive position; valid index 123 | position = updated 124 | elif updated > max_position: 125 | # positive position; invalid index 126 | position = max_position 127 | elif abs(updated) <= (max_position + 1): 128 | # negative position; valid index 129 | 130 | # Add 1 to max_position to make this behave like a negative list index. 131 | # -1 means the last position, not the last position minus 1 132 | 133 | position = max_position + 1 + updated 134 | else: 135 | # negative position; invalid index 136 | position = min_position 137 | 138 | # instance inserted; cleanup required on post_save 139 | setattr(model_instance, cache_name, (current, position)) 140 | return position 141 | 142 | def __get__(self, instance, owner): 143 | if instance is None: 144 | raise AttributeError("%s must be accessed via instance." % self.name) 145 | current, updated = getattr(instance, self.get_cache_name()) 146 | return current if updated is None else updated 147 | 148 | def __set__(self, instance, value): 149 | if instance is None: 150 | raise AttributeError("%s must be accessed via instance." % self.name) 151 | if value is None: 152 | value = self.default 153 | cache_name = self.get_cache_name() 154 | try: 155 | current, updated = getattr(instance, cache_name) 156 | except AttributeError: 157 | current, updated = value, None 158 | else: 159 | updated = value 160 | 161 | instance.__dict__[self.name] = value # Django 1.10 fix for deferred fields 162 | setattr(instance, cache_name, (current, updated)) 163 | 164 | def get_collection(self, instance): 165 | filters = {} 166 | if self.collection is not None: 167 | for field_name in self.collection: 168 | field = instance._meta.get_field(field_name) 169 | field_value = getattr(instance, field.attname) 170 | if field.null and field_value is None: 171 | filters['%s__isnull' % field.name] = True 172 | else: 173 | filters[field.name] = field_value 174 | model = type(instance) 175 | parent_link = self.parent_link 176 | if parent_link is not None: 177 | model = model._meta.get_field(parent_link).rel.to 178 | return model._default_manager.filter(**filters) 179 | 180 | def get_next_sibling(self, instance): 181 | """ 182 | Returns the next sibling of this instance. 183 | """ 184 | try: 185 | return self.get_collection(instance).filter(**{'%s__gt' % self.name: getattr(instance, self.get_cache_name())[0]})[0] 186 | except: 187 | return None 188 | 189 | def remove_from_collection(self, instance): 190 | """ 191 | Removes a positioned item from the collection. 192 | """ 193 | queryset = self.get_collection(instance) 194 | current = getattr(instance, self.get_cache_name())[0] 195 | updates = {self.name: models.F(self.name) - 1} 196 | if self.auto_now_fields: 197 | right_now = now() 198 | for field in self.auto_now_fields: 199 | updates[field.name] = right_now 200 | queryset.filter(**{'%s__gt' % self.name: current}).update(**updates) 201 | 202 | def prepare_delete(self, sender, instance, **kwargs): 203 | next_sibling = self.get_next_sibling(instance) 204 | if next_sibling: 205 | setattr(instance, self._next_sibling_pk_label, next_sibling.pk) 206 | else: 207 | setattr(instance, self._next_sibling_pk_label, None) 208 | pass 209 | 210 | def update_on_delete(self, sender, instance, **kwargs): 211 | next_sibling_pk = getattr(instance, self._next_sibling_pk_label, None) 212 | if next_sibling_pk: 213 | try: 214 | next_sibling = type(instance)._default_manager.get(pk=next_sibling_pk) 215 | except: 216 | next_sibling = None 217 | if next_sibling: 218 | queryset = self.get_collection(next_sibling) 219 | current = getattr(instance, self.get_cache_name())[0] 220 | updates = {self.name: models.F(self.name) - 1} 221 | if self.auto_now_fields: 222 | right_now = now() 223 | for field in self.auto_now_fields: 224 | updates[field.name] = right_now 225 | queryset.filter(**{'%s__gt' % self.name: current}).update(**updates) 226 | setattr(instance, self._next_sibling_pk_label, None) 227 | 228 | def update_on_save(self, sender, instance, created, **kwargs): 229 | collection_changed = self._collection_changed 230 | self._collection_changed = None 231 | 232 | current, updated = getattr(instance, self.get_cache_name()) 233 | 234 | if updated is None and not collection_changed: 235 | return None 236 | 237 | queryset = self.get_collection(instance).exclude(pk=instance.pk) 238 | 239 | updates = {} 240 | if self.auto_now_fields: 241 | right_now = now() 242 | for field in self.auto_now_fields: 243 | updates[field.name] = right_now 244 | 245 | if updated is None and created: 246 | updated = -1 247 | 248 | if created or collection_changed: 249 | # increment positions gte updated or node moved from another collection 250 | queryset = queryset.filter(**{'%s__gte' % self.name: updated}) 251 | updates[self.name] = models.F(self.name) + 1 252 | elif updated > current: 253 | # decrement positions gt current and lte updated 254 | queryset = queryset.filter(**{'%s__gt' % self.name: current, '%s__lte' % self.name: updated}) 255 | updates[self.name] = models.F(self.name) - 1 256 | else: 257 | # increment positions lt current and gte updated 258 | queryset = queryset.filter(**{'%s__lt' % self.name: current, '%s__gte' % self.name: updated}) 259 | updates[self.name] = models.F(self.name) + 1 260 | 261 | queryset.update(**updates) 262 | setattr(instance, self.get_cache_name(), (updated, None)) 263 | 264 | def south_field_triple(self): 265 | from south.modelsinspector import introspector 266 | field_class = "django.db.models.fields.IntegerField" 267 | args, kwargs = introspector(self) 268 | return (field_class, args, kwargs) 269 | 270 | def get_cache_name(self): 271 | try: 272 | return super(PositionField, self).get_cache_name() 273 | except AttributeError: 274 | return '_%s_cache_' % self.name 275 | --------------------------------------------------------------------------------