├── .coveragerc ├── .gitignore ├── .travis.yml ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── orderable ├── __init__.py ├── admin.py ├── managers.py ├── models.py ├── querysets.py ├── static │ └── admin │ │ └── img │ │ └── drag_handle.gif ├── templates │ └── admin │ │ ├── edit_inline │ │ └── orderable_tabular.html │ │ └── orderable_change_list.html └── tests │ ├── __init__.py │ ├── models.py │ ├── run.py │ ├── test_admin.py │ ├── test_manager.py │ ├── test_models.py │ └── test_querysets.py ├── requirements.txt ├── setup.cfg └── setup.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = orderable 3 | omit = *tests* 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.pot 3 | *.pyc 4 | local_settings.py 5 | .hypothesis/ 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | os: linux 3 | dist: xenial 4 | services: 5 | - postgresql 6 | python: 7 | - "2.7" 8 | - "3.4" 9 | - "3.5" 10 | - "3.6" 11 | - "3.7" 12 | env: 13 | - DJANGO='django>=1.11,<2' 14 | - DJANGO='django>=2,<2.1' 15 | - DJANGO='django>=2.1,<2.2' 16 | - DJANGO='django>=2.2,<3' 17 | - DJANGO='django>=3<3.1' 18 | - DJANGO='--pre django' 19 | jobs: 20 | exclude: 21 | - python: "2.7" 22 | env: DJANGO='--pre django' 23 | - python: "2.7" 24 | env: DJANGO='django>=3<3.1' 25 | - python: "2.7" 26 | env: DJANGO='django>=2.2,<3' 27 | - python: "2.7" 28 | env: DJANGO='django>=2.1,<2.2' 29 | - python: "2.7" 30 | env: DJANGO='django>=2,<2.1' 31 | - python: "3.4" 32 | env: DJANGO='--pre django' 33 | - python: "3.4" 34 | env: DJANGO='django>=3<3.1' 35 | - python: "3.4" 36 | env: DJANGO='django>=2.2,<3' 37 | - python: "3.4" 38 | env: DJANGO='django>=2.1,<2.2' 39 | - python: "3.5" 40 | env: DJANGO='django>=3<3.1' 41 | - python: "3.7" 42 | env: DJANGO='django>=1.11,<2' 43 | allow_failures: 44 | - env: DJANGO='--pre django' 45 | fast_finish: true 46 | before_script: 47 | - psql -c 'CREATE DATABASE orderable' -U postgres; 48 | install: 49 | - pip install $DJANGO 50 | - pip install -r requirements.txt 51 | - pip install -e . 52 | script: "make test" 53 | notifications: 54 | email: false 55 | before_install: 56 | pip install codecov 57 | after_success: 58 | codecov 59 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | v6.1.2 2 | ====== 3 | 4 | * Replace django.conf.url with django.urls.path 5 | 6 | v6.1.1 7 | ====== 8 | 9 | * Fix UUID as Primary key. 10 | 11 | v6.1.0 12 | ====== 13 | 14 | * Add Orderable.validate_unique to exclude `sort_order` when it is unique_together with something. 15 | * Change staticfiles to static for Django 3.0 16 | * Add Django 2.3 and 3 to TravisCI build 17 | 18 | v6.0.1 19 | ====== 20 | 21 | * Add Django 2.1 to TravisCI build 22 | * Fix edit inline issues causing reordering to fail when items have equal sort_order values. 23 | 24 | v6.0.0 25 | ====== 26 | 27 | * Drop support for django 1.10 and below. Things probably still work on the old versions, we're just no longer supporting them. 28 | * Drop support for Python 3.3. Again, it probably still works, just not supported. 29 | * Add django 2.0 support (nothing significant actually changed -- only tests). 30 | * Fix `sort_order_display` in Django admin change_list view. 31 | 32 | v5.0.0 33 | ====== 34 | 35 | * Drop support for django versions before 1.8. 36 | * Add `before` and `after` to `OrderableQueryset` to return the next item in the set. 37 | - example usage: `ordered_queryset.objects.after(ordered_object)` 38 | * Add `OrderableQueryset.set_orders()` to perform a mass rearrangement of items. This now requires custom model managers to inherit from `OrderableManager`. 39 | * Add `_pass_through_save()` to `Orderable`. This allows you to skip the insertion sorting performed by the `save()` method, such as when updating multiple objects at once to rearrange them. (`set_orders()` uses it.) 40 | 41 | v4.0.5 42 | ====== 43 | 44 | * Use `attr` to set input field values in the DOM instead of `val`, fixing the drag/drop behaviour. 45 | 46 | v4.0.4 47 | ====== 48 | 49 | * (Also) use `{% static %}` instead of `{{ STATIC_URL }}` to display the drag handle image in the orderable_tabular.html. 50 | 51 | v4.0.3 52 | ====== 53 | 54 | * Use `{% static %}` instead of `{{ STATIC_URL }}` to display the drag handle image. 55 | 56 | v4.0.2 57 | ====== 58 | 59 | * Fix `IntegrityError` in `Orderable.save` when `sort_order` has a `unique` constraint. 60 | 61 | v4.0.1 62 | ====== 63 | 64 | * Fix jQuery (and UI) missing from OrderableTabularInline. 65 | 66 | v4.0.0 67 | ====== 68 | 69 | * Drop support for Django versions less than 1.6. 70 | * Prevent integrity errors with unique_together conditions containing sort_order. 71 | 72 | v3.1.0 73 | ====== 74 | 75 | * Default to hosted jQuery and jQuery UI from google CDN. `\o/` 76 | 77 | v3.0.0 78 | ====== 79 | 80 | * Drop support for django 1.5 and python 2.6. 81 | * Add (preliminary) support for django 1.7 and python 3.4. 82 | * Code quality related refactor. 83 | 84 | 85 | v2.0.3 86 | ====== 87 | 88 | * Accept zero as a valid value for sort_order 89 | 90 | v2.0.2 91 | ====== 92 | 93 | * Use model_name instead of deprecated module_name in django >= 1.6 94 | 95 | v2.0.1 96 | ====== 97 | 98 | * Added tests to `Orderable.save` 99 | * Removed `django.db.transation.atomic` from the save method in django 1.6. 100 | * Reduced number of queries on insert of new `Orderable`. 101 | 102 | v2.0.0 103 | ====== 104 | 105 | * Don't use `commit_on_success` in model save. 106 | 107 | **Warning** Potentially backwards incompatible. We have removed this feature 108 | because it can cause database transactions to be commited that would 109 | otherwise have been rolled back. 110 | 111 | We recommend you make sure to use 'django.middleware.transaction.TransactionMiddleware', or at least use `django.db.transaction.commit_on_success` on any code that invokes the save method as it would otherwise be vulnerable to race conditions. If you are on django 1.6 we would instead recommend setting `settings.ATOMIC_REQUESTS = True`. 112 | 113 | This version added `django.db.transation.atomic` to `Orderable.save` method 114 | (in django 1.6 only) but it was immediately removed in `2.0.1`. 115 | 116 | v1.2.0 117 | ====== 118 | 119 | * Don't patch `__setattr__` for everything 120 | 121 | v1.1.1 122 | ====== 123 | 124 | * Don't fall over on KeyError. 125 | 126 | v1.1 127 | ==== 128 | 129 | * Refactor database code for better efficiency/lower collisions. 130 | * Add `db_index=True` to `sort_order` field. 131 | * Fix `OrderableTabularInline`. 132 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2014, Incuna Ltd 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, 8 | this 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 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 18 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 21 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README* 2 | recursive-include */templates * 3 | recursive-include */static * -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | 3 | help: 4 | @echo "Usage:" 5 | @echo " make help -- displays this help" 6 | @echo " make test -- runs tests" 7 | @echo " make release -- pushes to pypi" 8 | 9 | test: 10 | @coverage run orderable/tests/run.py 11 | @coverage report -m 12 | @flake8 13 | 14 | release: 15 | python setup.py register -r pypi sdist bdist_wheel 16 | twine upload dist/* 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Orderable 2 | 3 | 4 | Add manual sort order to Django objects via an abstract base class and admin classes. Project includes: 5 | 6 | * Abstract base Model 7 | * Admin class 8 | * Inline admin class 9 | * Admin templates 10 | 11 | 12 | ## Demo 13 | 14 | 15 | ![django-orderable demo](https://cloud.githubusercontent.com/assets/30606/6326221/667992e0-bb47-11e4-923e-29334573ff5c.gif) 16 | 17 | ## Installation 18 | 19 | 20 | Grab from the PyPI: 21 | 22 | pip install django-orderable 23 | 24 | 25 | Add to your INSTALLED_APPS: 26 | 27 | ... 28 | 'orderable', 29 | ... 30 | 31 | Subclass the Orderable class: 32 | 33 | from orderable.models import Orderable 34 | 35 | 36 | class Book(Orderable): 37 | ... 38 | 39 | Subclass the appropriate Orderable admin classes: 40 | 41 | from orderable.admin import OrderableAdmin, OrderableTabularInline 42 | 43 | 44 | class SomeInlineClass(OrderableTabularInline): 45 | ... 46 | 47 | class SomeAdminClass(OrderableAdmin): 48 | list_display = ('__unicode__', 'sort_order_display') 49 | ... 50 | 51 | 52 | jQuery and jQuery UI are used in the Admin for the draggable UI. You may override the versions with your own (rather than using Google's CDN): 53 | 54 | class SomeAdminClass(OrderableAdmin): 55 | class Media: 56 | extend = False 57 | js = ( 58 | 'path/to/jquery.js', 59 | 'path/to/jquery.ui.js', 60 | ) 61 | 62 | 63 | ## Notes 64 | 65 | ### `class Meta` 66 | 67 | If your subclass of `Orderable` defines [`class Meta`](https://docs.djangoproject.com/en/2.0/ref/models/options/) then make sure it subclasses `Orderable.Meta` one so the model is sorted by `sort_order`. ie: 68 | 69 | class MyOrderable(Orderable): 70 | class Meta(Orderable.Meta): 71 | ... 72 | 73 | ### Custom Managers 74 | 75 | Similarly, if your model has a custom manager, subclass `orderable.managers.OrderableManager` instead of `django.db.models.Manager`. 76 | 77 | ### Transactions 78 | 79 | Saving orderable models invokes a fair number of database queries, and in order 80 | to avoid race conditions should be run in a transaction. 81 | 82 | ### Adding Orderable to Existing Models 83 | 84 | You will need to populate the required `sort_order` field. Typically this is 85 | done by adding the field in one migration with a default of `0`, then creating 86 | a data migration to set the value to that of its primary key: 87 | 88 | 89 | for obj in orm['appname.Model'].objects.all(): 90 | obj.sort_order = obj.pk 91 | obj.save() 92 | 93 | 94 | ### Multiple Models using Orderable 95 | 96 | When multiple models inherit from Orderable the `next()` and `previous()` 97 | methods will look for the next/previous model with a sort order. However you'll 98 | likely want to have the various sort orders determined by a foreign key or some 99 | other predicate. The easiest way (currently) is to override the method in 100 | question. 101 | 102 | -------------------------------------------------------------------------------- /orderable/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/incuna/django-orderable/3a92652fd2b86a213bbcaef04d5ee150575c50fd/orderable/__init__.py -------------------------------------------------------------------------------- /orderable/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.core.exceptions import PermissionDenied 3 | from django.http import HttpResponse 4 | from django.urls import path 5 | from django.utils.decorators import method_decorator 6 | from django.views.decorators.csrf import csrf_protect 7 | 8 | 9 | csrf_protect_m = method_decorator(csrf_protect) 10 | 11 | 12 | class OrderableAdmin(admin.ModelAdmin): 13 | """ 14 | jQuery orderable objects in the admin. 15 | 16 | You'll want your object to subclass orderable.models.Orderable and you 17 | want to add sort_order_display to list_display. 18 | """ 19 | list_display = ('__str__', 'sort_order_display') 20 | 21 | change_list_template = "admin/orderable_change_list.html" 22 | 23 | def get_urls(self): 24 | patterns = super(OrderableAdmin, self).get_urls() 25 | patterns.insert( 26 | # insert just before (.+) rule 27 | # (see django.contrib.admin.options.ModelAdmin.get_urls) 28 | -1, 29 | path( 30 | 'reorder/', 31 | self.reorder_view, 32 | name=self.get_url_name() 33 | ) 34 | ) 35 | return patterns 36 | 37 | def get_url_name(self): 38 | meta = self.model._meta 39 | model_name = meta.model_name 40 | 41 | return '{0}admin_{1}_{2}_reorder'.format( 42 | self.admin_site.name, meta.app_label, model_name, 43 | ) 44 | 45 | @csrf_protect_m 46 | def reorder_view(self, request): 47 | """The 'reorder' admin view for this model.""" 48 | model = self.model 49 | 50 | if not self.has_change_permission(request): 51 | raise PermissionDenied 52 | 53 | if request.method == "POST": 54 | object_pks = request.POST.getlist('neworder[]') 55 | model.objects.set_orders(object_pks) 56 | 57 | return HttpResponse("OK") 58 | 59 | class Media: 60 | js = ( 61 | '//ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js', 62 | '//ajax.googleapis.com/ajax/libs/jqueryui/1.11.2/jquery-ui.min.js', 63 | ) 64 | 65 | 66 | class OrderableTabularInline(admin.TabularInline): 67 | """ 68 | jQuery orderable objects in the admin. 69 | 70 | You'll want your object to subclass orderable.models.Orderable. 71 | """ 72 | template = "admin/edit_inline/orderable_tabular.html" 73 | 74 | class Media: 75 | js = ( 76 | '//ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js', 77 | '//ajax.googleapis.com/ajax/libs/jqueryui/1.11.2/jquery-ui.min.js', 78 | ) 79 | -------------------------------------------------------------------------------- /orderable/managers.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Manager 2 | 3 | from .querysets import OrderableQueryset 4 | 5 | 6 | """ 7 | A manager for Orderables. If you customise your model's manager, be sure to inherit from 8 | this rather than django.db.models.Manager; it's required for the drag/drop ordering 9 | to work in the admin. 10 | """ 11 | OrderableManager = Manager.from_queryset(OrderableQueryset) 12 | -------------------------------------------------------------------------------- /orderable/models.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ObjectDoesNotExist 2 | from django.db import IntegrityError, models, transaction 3 | from django.utils.html import format_html 4 | 5 | from .managers import OrderableManager 6 | 7 | 8 | class Orderable(models.Model): 9 | """ 10 | An orderable object that keeps all the instances in an enforced order. 11 | 12 | If there's a unique_together which includes the sort_order field then that 13 | will be used when checking for collisions etc. 14 | 15 | This works well for inlines, which can be manually reordered by entering 16 | numbers, and the save function will prevent against collisions. 17 | 18 | For main objects, you would want to also use "OrderableAdmin", which will 19 | make a nice jquery admin interface. 20 | """ 21 | sort_order = models.IntegerField(blank=True, db_index=True) 22 | 23 | objects = OrderableManager() 24 | 25 | class Meta: 26 | abstract = True 27 | ordering = ['sort_order'] 28 | 29 | def get_unique_fields(self): 30 | """List field names that are unique_together with `sort_order`.""" 31 | for unique_together in self._meta.unique_together: 32 | if 'sort_order' in unique_together: 33 | unique_fields = list(unique_together) 34 | unique_fields.remove('sort_order') 35 | return ['%s_id' % f for f in unique_fields] 36 | return [] 37 | 38 | def get_filtered_manager(self): 39 | manager = self.__class__.objects 40 | kwargs = {field: getattr(self, field) for field in self.get_unique_fields()} 41 | return manager.filter(**kwargs) 42 | 43 | def next(self): 44 | if not self.sort_order: 45 | return None 46 | 47 | return self.get_filtered_manager().after(self) 48 | 49 | def prev(self): 50 | if not self.sort_order: 51 | return None 52 | 53 | return self.get_filtered_manager().before(self) 54 | 55 | def validate_unique(self, exclude=None): 56 | if self._is_sort_order_unique_together_with_something(): 57 | exclude = exclude or [] 58 | if 'sort_order' not in exclude: 59 | exclude.append('sort_order') 60 | return super(Orderable, self).validate_unique(exclude=exclude) 61 | 62 | def _is_sort_order_unique_together_with_something(self): 63 | """ 64 | Is the sort_order field unique_together with something 65 | """ 66 | unique_together = self._meta.unique_together 67 | for fields in unique_together: 68 | if 'sort_order' in fields and len(fields) > 1: 69 | return True 70 | return False 71 | 72 | @staticmethod 73 | def _update(qs): 74 | """ 75 | Increment the sort_order in a queryset. 76 | 77 | Handle IntegrityErrors caused by unique constraints. 78 | """ 79 | try: 80 | with transaction.atomic(): 81 | qs.update(sort_order=models.F('sort_order') + 1) 82 | except IntegrityError: 83 | for obj in qs.order_by('-sort_order'): 84 | qs.filter(pk=obj.pk).update(sort_order=models.F('sort_order') + 1) 85 | 86 | def _save(self, objects, old_pos, new_pos): 87 | """WARNING: Intensive giggery-pokery zone.""" 88 | to_shift = objects.exclude(pk=self.pk) if self.pk else objects 89 | 90 | # If not set, insert at end. 91 | if self.sort_order is None: 92 | self._move_to_end(objects) 93 | 94 | # New insert. 95 | elif not self.pk and not old_pos: 96 | # Increment `sort_order` on objects with: 97 | # sort_order > new_pos. 98 | to_shift = to_shift.filter(sort_order__gte=self.sort_order) 99 | self._update(to_shift) 100 | self.sort_order = new_pos 101 | 102 | # self.sort_order decreased. 103 | elif old_pos and new_pos < old_pos: 104 | self._move_to_end(objects) 105 | super(Orderable, self).save() 106 | # Increment `sort_order` on objects with: 107 | # sort_order >= new_pos and sort_order < old_pos 108 | to_shift = to_shift.filter(sort_order__gte=new_pos, sort_order__lt=old_pos) 109 | self._update(to_shift) 110 | self.sort_order = new_pos 111 | 112 | # self.sort_order increased. 113 | elif old_pos and new_pos > old_pos: 114 | self._move_to_end(objects) 115 | super(Orderable, self).save() 116 | # Decrement sort_order on objects with: 117 | # sort_order <= new_pos and sort_order > old_pos. 118 | to_shift = to_shift.filter(sort_order__lte=new_pos, sort_order__gt=old_pos) 119 | to_shift.update(sort_order=models.F('sort_order') - 1) 120 | self.sort_order = new_pos 121 | 122 | def _move_to_end(self, objects): 123 | """Temporarily save `self.sort_order` elsewhere (max_obj).""" 124 | max_obj = objects.all().aggregate(models.Max('sort_order'))['sort_order__max'] 125 | self.sort_order = max_obj + 1 if max_obj else 1 126 | 127 | def _unique_togethers_changed(self): 128 | for field in self.get_unique_fields(): 129 | if getattr(self, '_original_%s' % field, False): 130 | return True 131 | return False 132 | 133 | def save(self, *args, **kwargs): 134 | """Keep the unique order in sync.""" 135 | objects = self.get_filtered_manager() 136 | old_pos = getattr(self, '_original_sort_order', None) 137 | new_pos = self.sort_order 138 | 139 | if old_pos is None and self._unique_togethers_changed(): 140 | self.sort_order = None 141 | new_pos = None 142 | 143 | try: 144 | with transaction.atomic(): 145 | self._save(objects, old_pos, new_pos) 146 | except IntegrityError: 147 | with transaction.atomic(): 148 | old_pos = objects.filter(pk=self.pk).values_list( 149 | 'sort_order', flat=True)[0] 150 | self._save(objects, old_pos, new_pos) 151 | 152 | # Call the "real" save() method. 153 | super(Orderable, self).save(*args, **kwargs) 154 | 155 | def sort_order_display(self): 156 | return format_html( 157 | '{}', 158 | self.id, self.sort_order, 159 | ) 160 | 161 | sort_order_display.allow_tags = True 162 | sort_order_display.short_description = 'Order' 163 | sort_order_display.admin_order_field = 'sort_order' 164 | 165 | def __setattr__(self, attr, value): 166 | """ 167 | Cache original value of `sort_order` when a change is made to it. 168 | 169 | Also cache values of other unique together fields. 170 | 171 | Greatly inspired by http://code.google.com/p/django-audit/ 172 | """ 173 | if attr == 'sort_order' or attr in self.get_unique_fields(): 174 | try: 175 | current = self.__dict__[attr] 176 | except (AttributeError, KeyError, ObjectDoesNotExist): 177 | pass 178 | else: 179 | previously_set = getattr(self, '_original_%s' % attr, False) 180 | if current != value and not previously_set: 181 | setattr(self, '_original_%s' % attr, current) 182 | super(Orderable, self).__setattr__(attr, value) 183 | -------------------------------------------------------------------------------- /orderable/querysets.py: -------------------------------------------------------------------------------- 1 | from django.db import models, transaction 2 | 3 | 4 | class OrderableQueryset(models.QuerySet): 5 | """ 6 | Adds additional functionality to `Orderable.objects` and querysets. 7 | 8 | Provides access to the next and previous ordered object within the queryset. 9 | 10 | As a related manager this will provide the filtering automatically. 11 | """ 12 | def before(self, orderable): 13 | return self.filter(sort_order__lt=orderable.sort_order).last() 14 | 15 | def after(self, orderable): 16 | return self.filter(sort_order__gt=orderable.sort_order).first() 17 | 18 | def set_orders(self, object_pks): 19 | """ 20 | Perform a mass update of sort_orders across the full queryset. 21 | Accepts a list, object_pks, of the intended order for the objects. 22 | 23 | Works as follows: 24 | - Compile a list of all sort orders in the queryset. Leave out anything that 25 | isn't in the object_pks list - this deals with pagination and any 26 | inconsistencies. 27 | - Get the maximum among all model object sort orders. Update the queryset to add 28 | it to all the existing sort order values. This lifts them 'out of the way' of 29 | unique_together clashes when setting the intended sort orders. 30 | - Set the sort order on each object. Use only sort_order values that the objects 31 | had before calling this method, so they get rearranged in place. 32 | Performs O(n) queries. 33 | """ 34 | objects_to_sort = self.filter(pk__in=object_pks) 35 | max_value = self.model.objects.all().aggregate( 36 | models.Max('sort_order') 37 | )['sort_order__max'] 38 | 39 | # Call list() on the values right away, so they don't get affected by the 40 | # update() later (since values_list() is lazy). 41 | orders = list(objects_to_sort.values_list('sort_order', flat=True)) 42 | 43 | # Check there are no unrecognised entries in the object_pks list. If so, 44 | # throw an error. We only have to check that they're the same length because 45 | # orders is built using only entries in object_pks, and all the pks are unique, 46 | # so if their lengths are the same, the elements must match up exactly. 47 | if len(orders) != len(object_pks): 48 | pks = set(objects_to_sort.values_list('pk', flat=True)) 49 | message = 'The following object_pks are not in this queryset: {}'.format( 50 | [pk for pk in object_pks if pk not in pks] 51 | ) 52 | raise TypeError(message) 53 | 54 | with transaction.atomic(): 55 | objects_to_sort.update(sort_order=models.F('sort_order') + max_value) 56 | for pk, order in zip(object_pks, orders): 57 | # Use update() to save a query per item and dodge the insertion sort 58 | # code in save(). 59 | self.filter(pk=pk).update(sort_order=order) 60 | 61 | # Return the operated-on queryset for convenience. 62 | return objects_to_sort 63 | -------------------------------------------------------------------------------- /orderable/static/admin/img/drag_handle.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/incuna/django-orderable/3a92652fd2b86a213bbcaef04d5ee150575c50fd/orderable/static/admin/img/drag_handle.gif -------------------------------------------------------------------------------- /orderable/templates/admin/edit_inline/orderable_tabular.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | {% include "admin/edit_inline/tabular.html" %} 4 | 5 | 6 | 15 | 16 | 17 | 84 | -------------------------------------------------------------------------------- /orderable/templates/admin/orderable_change_list.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_list.html" %} 2 | 3 | {% load static %} 4 | 5 | {% block extrastyle %} 6 | {{ block.super }} 7 | 13 | 14 | {% endblock %} 15 | 16 | {% block extrahead %} 17 | {{ block.super }} 18 | 19 | 87 | 88 | 89 | {% endblock %} 90 | -------------------------------------------------------------------------------- /orderable/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/incuna/django-orderable/3a92652fd2b86a213bbcaef04d5ee150575c50fd/orderable/tests/__init__.py -------------------------------------------------------------------------------- /orderable/tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from ..models import Orderable 4 | 5 | 6 | class Task(Orderable): 7 | """A basic orderable model for tests.""" 8 | 9 | def __str__(self): 10 | return 'Task {}'.format(self.pk) 11 | 12 | 13 | class SubTask(Orderable): 14 | """An orderable model with unique_together.""" 15 | task = models.ForeignKey('Task', models.CASCADE) 16 | 17 | class Meta(Orderable.Meta): 18 | unique_together = ('task', 'sort_order') 19 | 20 | def __str__(self): 21 | return 'SubTask {}'.format(self.pk) 22 | -------------------------------------------------------------------------------- /orderable/tests/run.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | import sys 3 | 4 | import django 5 | from django.conf import settings 6 | from django.test.runner import DiscoverRunner 7 | 8 | 9 | settings.configure( 10 | DATABASES={'default': { 11 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 12 | 'NAME': 'orderable', 13 | 'HOST': 'localhost' 14 | }}, 15 | INSTALLED_APPS=( 16 | 'orderable.tests', 17 | ), 18 | MIDDLEWARE_CLASSES=[], 19 | ) 20 | 21 | 22 | django.setup() 23 | 24 | 25 | if __name__ == "__main__": 26 | parser = ArgumentParser(description="Run the Django test suite.") 27 | parser.add_argument( 28 | '-v', '--verbosity', default=1, type=int, choices=[0, 1, 2, 3], 29 | help='Verbosity level; 0=minimal output, 1=normal output, 2=all output') 30 | parser.add_argument( 31 | '--noinput', action='store_false', dest='interactive', default=True, 32 | help='Tells Django to NOT prompt the user for input of any kind.') 33 | parser.add_argument( 34 | '--failfast', action='store_true', dest='failfast', default=False, 35 | help='Tells Django to stop running the test suite after first failed ' 36 | 'test.') 37 | parser.add_argument( 38 | '-k', '--keepdb', action='store_true', dest='keepdb', default=False, 39 | help='Tells Django to preserve the test database between runs.') 40 | parser.add_argument( 41 | '--reverse', action='store_true', default=False, 42 | help='Sort test suites and test cases in opposite order to debug ' 43 | 'test side effects not apparent with normal execution lineup.') 44 | parser.add_argument( 45 | '--debug-sql', action='store_true', dest='debug_sql', default=False, 46 | help='Turn on the SQL query logger within tests (Django 1.8+ only)') 47 | options = parser.parse_args() 48 | runner_kwargs = { 49 | 'verbosity': options.verbosity, 50 | 'interactive': options.interactive, 51 | 'failfast': options.failfast, 52 | 'keepdb': options.keepdb, 53 | 'reverse': options.reverse, 54 | } 55 | runner_kwargs['debug_sql'] = options.debug_sql 56 | test_runner = DiscoverRunner(**runner_kwargs) 57 | failures = test_runner.run_tests(['orderable']) 58 | if failures: 59 | sys.exit(1) 60 | -------------------------------------------------------------------------------- /orderable/tests/test_admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin import AdminSite 2 | from django.test import TestCase 3 | 4 | from orderable.admin import OrderableAdmin 5 | from orderable.tests.models import Task 6 | 7 | 8 | class OrderableAdminTest(TestCase): 9 | def test_get_url_name(self): 10 | expected_name = 'adminadmin_tests_task_reorder' 11 | admin_site = AdminSite() 12 | 13 | url_name = OrderableAdmin(Task, admin_site).get_url_name() 14 | self.assertEqual(url_name, expected_name) 15 | -------------------------------------------------------------------------------- /orderable/tests/test_manager.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from .models import SubTask, Task 4 | 5 | 6 | class TestOrderableManager(TestCase): 7 | @classmethod 8 | def setUpTestData(cls): 9 | tasks = [Task.objects.create(sort_order=i) for i in range(3)] 10 | cls.first_task, cls.middle_task, cls.last_task = tasks 11 | 12 | def test_gets_next(self): 13 | next_task = Task.objects.after(self.first_task) 14 | self.assertEqual(next_task, self.middle_task) 15 | 16 | def test_gets_previous(self): 17 | previous_task = Task.objects.before(self.last_task) 18 | self.assertEqual(previous_task, self.middle_task) 19 | 20 | def test_returns_none_if_after_on_last(self): 21 | next_task = Task.objects.after(self.last_task) 22 | self.assertIsNone(next_task) 23 | 24 | def test_returns_none_if_previous_on_first(self): 25 | previous_task = Task.objects.before(self.first_task) 26 | self.assertIsNone(previous_task) 27 | 28 | 29 | class TestOrderableRelatedManager(TestCase): 30 | @classmethod 31 | def setUpTestData(cls): 32 | cls.task = Task.objects.create() 33 | 34 | sub_tasks = [ 35 | SubTask.objects.create(task=cls.task, sort_order=i) for i in range(3) 36 | ] 37 | cls.first_sub_task, cls.middle_sub_task, cls.last_sub_task = sub_tasks 38 | 39 | def test_gets_next(self): 40 | next_sub_task = self.task.subtask_set.after(self.first_sub_task) 41 | self.assertEqual(next_sub_task, self.middle_sub_task) 42 | 43 | def test_gets_previous(self): 44 | previous_sub_task = self.task.subtask_set.before(self.last_sub_task) 45 | self.assertEqual(previous_sub_task, self.middle_sub_task) 46 | 47 | def test_returns_none_if_after_on_last(self): 48 | next_sub_task = self.task.subtask_set.after(self.last_sub_task) 49 | self.assertIsNone(next_sub_task) 50 | 51 | def test_returns_none_if_previous_on_first(self): 52 | previous_sub_task = self.task.subtask_set.before(self.first_sub_task) 53 | self.assertIsNone(previous_sub_task) 54 | -------------------------------------------------------------------------------- /orderable/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from hypothesis import example, given 3 | from hypothesis.extra.django import TestCase 4 | from hypothesis.strategies import integers, lists 5 | 6 | from .models import SubTask, Task 7 | 8 | 9 | class TestOrderingOnSave(TestCase): 10 | def test_normal_save(self): 11 | """Normal saves should avoid giggery pokery.""" 12 | task = Task.objects.create() 13 | 14 | with self.assertNumQueries(3): 15 | # Queries: 16 | # SAVEPOINT 17 | # UPDATE 18 | # COMMIT 19 | task.save() 20 | 21 | def test_unspecified_order(self): 22 | """New inserts should default to the end of the list. 23 | 24 | Index: 1 2 25 | Before: old 26 | After: old new 27 | """ 28 | old = Task.objects.create(sort_order=1) 29 | 30 | with self.assertNumQueries(4): 31 | # Queries 32 | # Savepoint 33 | # Find last position in list 34 | # Save to last position in list 35 | # Commit 36 | new = Task.objects.create() 37 | 38 | tasks = Task.objects.all() 39 | # Make sure list is in correct order 40 | self.assertSequenceEqual(tasks, [old, new]) 41 | # Make sure sort_order is unique 42 | self.assertEqual(len(tasks), len(set(t.sort_order for t in tasks))) 43 | 44 | def test_insert_on_create(self): 45 | """New insert should bump rest of list along if sort_order specified. 46 | 47 | Index: 1 2 3 48 | Before: old_1 old_2 old_3 49 | After: old_1 new old_2 old_3 50 | """ 51 | old_1 = Task.objects.create(sort_order=1) 52 | old_2 = Task.objects.create(sort_order=2) 53 | old_3 = Task.objects.create(sort_order=3) 54 | 55 | # Insert between old_1 and old_2 56 | with self.assertNumQueries(6): 57 | # Queries: 58 | # Savepoint 59 | # Savepoint 60 | # Bump old_2 to position 3 61 | # Release savepoint 62 | # Release savepoint 63 | # Save new in position 2 64 | new = Task.objects.create(sort_order=old_2.sort_order) 65 | 66 | tasks = Task.objects.all() 67 | # Make sure list is in correct order 68 | self.assertSequenceEqual(tasks, [old_1, new, old_2, old_3]) 69 | # Make sure sort_order is still unique 70 | self.assertEqual(len(tasks), len(set(t.sort_order for t in tasks))) 71 | 72 | def test_increase_order(self): 73 | """Increasing sort_order should move back those in the middle. 74 | 75 | Moving 2 to 4: 76 | 77 | Index: 1 2 3 4 5 78 | Before: item1 item2 item3 item4 item5 79 | After: item1 item3 item4 item2 item5 80 | """ 81 | item1 = Task.objects.create(sort_order=1) 82 | item2 = Task.objects.create(sort_order=2) 83 | item3 = Task.objects.create(sort_order=3) 84 | item4 = Task.objects.create(sort_order=4) 85 | item5 = Task.objects.create(sort_order=5) 86 | 87 | # Move item2 to position 4 88 | with self.assertNumQueries(6): 89 | # Queries: 90 | # Savepoint 91 | # Find end of list 92 | # Move item2 to end of list 93 | # Shuffle item3 and item4 back by one 94 | # Save item2 to new desired position 95 | # Commit 96 | item2.sort_order = item4.sort_order 97 | item2.save() 98 | 99 | tasks = Task.objects.all() 100 | # Make sure list is in correct order 101 | expected = [item1, item3, item4, item2, item5] 102 | self.assertSequenceEqual(tasks, expected) 103 | # Make sure sort_order is still unique 104 | self.assertEqual(len(tasks), len(set(t.sort_order for t in tasks))) 105 | 106 | def test_decrease_order(self): 107 | """Decreasing sort_order should bump those in the middle on. 108 | 109 | Moving 4 to 2: 110 | 111 | Index: 1 2 3 4 5 112 | Before: item1 item2 item3 item4 item5 113 | After: item1 item4 item2 item3 item5 114 | """ 115 | item1 = Task.objects.create(sort_order=1) 116 | item2 = Task.objects.create(sort_order=2) 117 | item3 = Task.objects.create(sort_order=3) 118 | item4 = Task.objects.create(sort_order=4) 119 | item5 = Task.objects.create(sort_order=5) 120 | 121 | # Move item4 to position 2 122 | with self.assertNumQueries(8): 123 | # Queries: 124 | # Savepoint 125 | # Find end of list 126 | # Move item4 to end of list 127 | # Savepoint 128 | # Bump item2 and item3 on by one 129 | # Release savepoint 130 | # Release savepoint 131 | # Save item4 to new desired position 132 | item4.sort_order = item2.sort_order 133 | item4.save() 134 | 135 | tasks = Task.objects.all() 136 | # Make sure list is in correct order 137 | expected = [item1, item4, item2, item3, item5] 138 | self.assertSequenceEqual(tasks, expected) 139 | # Make sure sort_order is still unique 140 | self.assertEqual(len(tasks), len(set(t.sort_order for t in tasks))) 141 | 142 | def test_zero_sort_order(self): 143 | """Zero should be a valid value for sort_order.""" 144 | zero_task = Task.objects.create(sort_order=0) 145 | self.assertEqual(zero_task.sort_order, 0) 146 | 147 | def test_reordering(self): 148 | """Check you can reassign a complete new order. 149 | 150 | This is similar to a formset or the admin reorder view. 151 | """ 152 | tasks = [ 153 | Task.objects.create(), 154 | Task.objects.create(), 155 | Task.objects.create(), 156 | Task.objects.create(), 157 | ] 158 | tasks[0].sort_order = 3 159 | tasks[0].save() 160 | tasks[1].sort_order = 4 161 | tasks[1].save() 162 | tasks[2].sort_order = 1 163 | tasks[2].save() 164 | tasks[3].sort_order = 2 165 | tasks[3].save() 166 | self.assertSequenceEqual(Task.objects.all(), [ 167 | tasks[2], 168 | tasks[3], 169 | tasks[0], 170 | tasks[1], 171 | ]) 172 | 173 | 174 | class TestSubTask(TestCase): 175 | 176 | def test_duplicated_sort_order_on_different_parents(self): 177 | task = Task.objects.create() 178 | task_2 = Task.objects.create() 179 | subtask = SubTask.objects.create(task=task) 180 | subtask_2 = SubTask.objects.create(task=task_2) 181 | self.assertEqual(subtask.sort_order, subtask_2.sort_order) 182 | 183 | def test_reordering(self): 184 | """Check you can reassign a complete new order. 185 | 186 | This is similar to a formset or the admin reorder view. The subtask 187 | version differs importantly because there is a database level 188 | uniqueness constraint. 189 | """ 190 | task = Task.objects.create() 191 | subtasks = [ 192 | SubTask.objects.create(task=task, sort_order=3), 193 | SubTask.objects.create(task=task, sort_order=4), 194 | SubTask.objects.create(task=task, sort_order=1), 195 | SubTask.objects.create(task=task, sort_order=2), 196 | ] 197 | self.assertSequenceEqual(task.subtask_set.all(), [ 198 | subtasks[2], 199 | subtasks[3], 200 | subtasks[0], 201 | subtasks[1], 202 | ]) 203 | 204 | def test_changing_parent(self): 205 | """Check changing the unique together parent.""" 206 | task = Task.objects.create() 207 | task_2 = Task.objects.create() 208 | subtask = SubTask.objects.create(task=task) 209 | subtask_2 = SubTask.objects.create(task=task_2) 210 | self.assertEqual(subtask.sort_order, subtask_2.sort_order) 211 | subtask_2 = SubTask.objects.get(pk=subtask_2.pk) 212 | subtask_2.task = task 213 | subtask_2.save() 214 | self.assertSequenceEqual(task.subtask_set.all(), [subtask, subtask_2]) 215 | 216 | def test_next_and_prev(self): 217 | task = Task.objects.create() 218 | task_2 = Task.objects.create() 219 | subtask = SubTask.objects.create(task=task) 220 | subtask_2 = SubTask.objects.create(task=task) 221 | subtask_3 = SubTask.objects.create(task=task_2) 222 | 223 | self.assertEqual(subtask.next(), subtask_2) 224 | self.assertIsNone(subtask_2.next()) 225 | 226 | self.assertIsNone(subtask_3.prev()) 227 | self.assertIsNone(subtask_3.next()) 228 | 229 | @given(lists(integers(min_value=1), min_size=1, unique=True)) 230 | @example([2, 3, 1]) 231 | @example([2, 3, 4]) 232 | def test_save_subtask_no_errors(self, sort_orders): 233 | """Ensure Orderable.save does not raise IntegrityError.""" 234 | task = Task.objects.create() 235 | 236 | for order in sort_orders: 237 | subtask = SubTask.objects.create(task=task, sort_order=order) 238 | 239 | subtask.sort_order = 2 240 | subtask.save() 241 | 242 | def test_validate_unique(self): 243 | """validate_unique should validate when sort_order conflicts.""" 244 | task = Task.objects.create() 245 | subtask = SubTask.objects.create(task=task, sort_order=1) 246 | SubTask.objects.create(task=task, sort_order=2) 247 | 248 | subtask.sort_order = 2 249 | try: 250 | subtask.validate_unique() 251 | except ValidationError: 252 | self.fail("SubTask.clean() raised ValidationError unexpectedly!") 253 | 254 | def test_validate_unique_exclude_sort_order(self): 255 | """validate_unique should validate when sort_order excluded.""" 256 | task = Task.objects.create() 257 | subtask = SubTask.objects.create(task=task, sort_order=1) 258 | SubTask.objects.create(task=task, sort_order=2) 259 | 260 | subtask.sort_order = 2 261 | try: 262 | subtask.validate_unique(exclude=('sort_order',)) 263 | except ValidationError: 264 | self.fail("SubTask.clean() raised ValidationError unexpectedly!") 265 | 266 | def test_validate_unique_not_unique_sort_order(self): 267 | """ 268 | validate_unique should validate when sort_order not in unique_together. 269 | """ 270 | task = Task.objects.create(sort_order=1) 271 | Task.objects.create(sort_order=2) 272 | 273 | task.sort_order = 1 274 | try: 275 | task.validate_unique() 276 | except ValidationError: 277 | self.fail("Task.clean() raised ValidationError unexpectedly!") 278 | -------------------------------------------------------------------------------- /orderable/tests/test_querysets.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from .models import Task 4 | 5 | 6 | class TestOrderableQueryset(TestCase): 7 | def test_set_orders(self): 8 | """The sort_order values are rearranged effectively in-place.""" 9 | task_1 = Task.objects.create(sort_order=4, pk=1) 10 | task_2 = Task.objects.create(sort_order=7, pk=2) 11 | 12 | Task.objects.set_orders([2, 1]) 13 | 14 | task_1.refresh_from_db() 15 | task_2.refresh_from_db() 16 | self.assertEqual(task_1.sort_order, 7) 17 | self.assertEqual(task_2.sort_order, 4) 18 | 19 | def test_set_orders_wrong_pks(self): 20 | """ 21 | When a pk is submitted that doesn't match an existing Task, we get a 22 | descriptive error message. 23 | """ 24 | Task.objects.create(sort_order=1, pk=1) 25 | 26 | expected_msg = 'The following object_pks are not in this queryset: [2]' 27 | with self.assertRaises(TypeError, msg=expected_msg): 28 | Task.objects.set_orders([1, 2]) 29 | 30 | def test_set_orders_subset(self): 31 | """ 32 | When only some Tasks are mentioned in the object_pks list, they're swapped around 33 | and the others are unaffected. 34 | """ 35 | task_1 = Task.objects.create(sort_order=1, pk=1) 36 | task_2 = Task.objects.create(sort_order=4, pk=2) 37 | task_3 = Task.objects.create(sort_order=5, pk=3) 38 | task_4 = Task.objects.create(sort_order=8, pk=4) 39 | 40 | Task.objects.set_orders([3, 2]) 41 | 42 | self.assertSequenceEqual(Task.objects.all(), [task_1, task_3, task_2, task_4]) 43 | self.assertSequenceEqual( 44 | Task.objects.values_list('sort_order', flat=True), 45 | [1, 4, 5, 8], 46 | ) 47 | 48 | def test_set_orders_performance(self): 49 | """ 50 | When only some Tasks are mentioned in the object_pks list, they're swapped around 51 | and the others are unaffected. 52 | """ 53 | Task.objects.create(sort_order=1, pk=1) 54 | Task.objects.create(sort_order=4, pk=2) 55 | Task.objects.create(sort_order=5, pk=3) 56 | Task.objects.create(sort_order=8, pk=4) 57 | 58 | with self.assertNumQueries(7): 59 | """ 60 | SELECT MAX("tests_task"."sort_order") AS "sort_order__max" FROM "tests_task" 61 | SELECT "tests_task"."sort_order" 62 | FROM "tests_task" 63 | WHERE "tests_task"."id" IN (3, 2) ORDER BY "tests_task"."sort_order" ASC 64 | 65 | SAVEPOINT "s47832829518016_x11457" 66 | UPDATE "tests_task" 67 | SET "sort_order" = ("tests_task"."sort_order" + 8) 68 | WHERE "tests_task"."id" IN (3, 2) 69 | 70 | UPDATE "tests_task" SET "sort_order" = 4 WHERE "tests_task"."id" = 3 71 | UPDATE "tests_task" SET "sort_order" = 5 WHERE "tests_task"."id" = 2 72 | 73 | RELEASE SAVEPOINT "s47832829518016_x11457" 74 | """ 75 | Task.objects.set_orders([3, 2]) 76 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | coverage==3.7.1 2 | flake8==3.6.0 3 | flake8-import-order==0.18 4 | hypothesis==2.0.0 5 | psycopg2==2.7.4 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | [flake8] 4 | application-import-names = orderable 5 | import-order-style = google 6 | max-complexity = 10 7 | max-line-length = 90 8 | statistics = true 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | 4 | setup( 5 | name='django-orderable', 6 | packages=find_packages(), 7 | include_package_data=True, 8 | version='6.1.2', 9 | description='Add manual sort order to Django objects via an abstract base ' 10 | 'class and admin classes.', 11 | author='Incuna Ltd', 12 | author_email='admin@incuna.com', 13 | url='https://github.com/incuna/django-orderable', 14 | long_description_content_type='text/markdown', 15 | long_description=open('README.md').read(), 16 | license='BSD', 17 | classifiers=[ 18 | 'Development Status :: 5 - Production/Stable', 19 | 'Framework :: Django', 20 | 'Intended Audience :: Developers', 21 | 'License :: OSI Approved :: BSD License', 22 | 'Natural Language :: English', 23 | 'Programming Language :: Python :: 2', 24 | 'Programming Language :: Python :: 2.7', 25 | 'Programming Language :: Python :: 3', 26 | 'Programming Language :: Python :: 3.4', 27 | 'Programming Language :: Python :: 3.5', 28 | 'Programming Language :: Python :: 3.6', 29 | ], 30 | ) 31 | --------------------------------------------------------------------------------