├── .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 | 
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 |
--------------------------------------------------------------------------------