577 lines
23 KiB
Python
577 lines
23 KiB
Python
import json
|
|
import os
|
|
from itertools import chain
|
|
from types import MethodType
|
|
|
|
from django import forms
|
|
from django.contrib import admin, messages
|
|
from django.contrib.admin.views.main import ORDER_VAR
|
|
from django.contrib.contenttypes.admin import GenericStackedInline, GenericTabularInline
|
|
from django.contrib.contenttypes.forms import BaseGenericInlineFormSet
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.core.exceptions import ImproperlyConfigured
|
|
from django.core.paginator import EmptyPage
|
|
from django.core.serializers.json import DjangoJSONEncoder
|
|
from django.db import router, transaction
|
|
from django.db.models.aggregates import Max
|
|
from django.db.models.expressions import F
|
|
from django.db.models.functions import Coalesce
|
|
from django.db.models.signals import post_save, pre_save
|
|
from django.forms import widgets
|
|
from django.forms.models import BaseInlineFormSet
|
|
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotAllowed
|
|
from django.urls import path, reverse
|
|
from django.utils.safestring import mark_safe
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
__all__ = ['SortableAdminMixin', 'SortableInlineAdminMixin']
|
|
|
|
|
|
def _get_default_ordering(model, model_admin):
|
|
try:
|
|
# first try with the model admin ordering
|
|
none, prefix, field_name = model_admin.ordering[0].rpartition('-')
|
|
except (AttributeError, IndexError, TypeError):
|
|
pass
|
|
else:
|
|
return prefix, field_name
|
|
|
|
try:
|
|
# then try with the model ordering
|
|
none, prefix, field_name = model._meta.ordering[0].rpartition('-')
|
|
except (AttributeError, IndexError):
|
|
raise ImproperlyConfigured(
|
|
f"Model {model.__module__}.{model.__name__} requires a list or tuple 'ordering' in its Meta class"
|
|
)
|
|
else:
|
|
return prefix, field_name
|
|
|
|
|
|
class MovePageActionForm(admin.helpers.ActionForm):
|
|
step = forms.IntegerField(
|
|
required=False,
|
|
initial=1,
|
|
widget=widgets.NumberInput(attrs={'id': 'changelist-form-step'}),
|
|
label=False,
|
|
)
|
|
page = forms.IntegerField(
|
|
required=False, widget=widgets.NumberInput(attrs={'id': 'changelist-form-page'}), label=False
|
|
)
|
|
|
|
|
|
class SortableAdminBase:
|
|
@property
|
|
def media(self):
|
|
css = {'all': ['adminsortable2/css/sortable.css']}
|
|
js = [
|
|
'admin/js/jquery.init.js',
|
|
'adminsortable2/js/plugins/admincompat.js',
|
|
'adminsortable2/js/libs/jquery.ui.core-1.11.4.js',
|
|
'adminsortable2/js/libs/jquery.ui.widget-1.11.4.js',
|
|
'adminsortable2/js/libs/jquery.ui.mouse-1.11.4.js',
|
|
'adminsortable2/js/libs/jquery.ui.touch-punch-0.2.3.js',
|
|
'adminsortable2/js/libs/jquery.ui.sortable-1.11.4.js',
|
|
]
|
|
return super().media + widgets.Media(css=css, js=js)
|
|
|
|
|
|
class SortableAdminMixin(SortableAdminBase):
|
|
BACK, FORWARD, FIRST, LAST, EXACT = range(5)
|
|
action_form = MovePageActionForm
|
|
|
|
@property
|
|
def change_list_template(self):
|
|
opts = self.model._meta
|
|
app_label = opts.app_label
|
|
return [
|
|
os.path.join('adminsortable2', app_label, opts.model_name, 'change_list.html'),
|
|
os.path.join('adminsortable2', app_label, 'change_list.html'),
|
|
'adminsortable2/change_list.html',
|
|
]
|
|
|
|
def __init__(self, model, admin_site):
|
|
self.default_order_direction, self.default_order_field = _get_default_ordering(model, self)
|
|
super().__init__(model, admin_site)
|
|
self.enable_sorting = False
|
|
self.order_by = None
|
|
if not isinstance(self.exclude, (list, tuple)):
|
|
self.exclude = [self.default_order_field]
|
|
elif not self.exclude or self.default_order_field != self.exclude[0]:
|
|
self.exclude = [self.default_order_field] + list(self.exclude)
|
|
if isinstance(self.list_display_links, (list, tuple)) and len(self.list_display_links) == 0:
|
|
self.list_display_links = [self.list_display[0]]
|
|
self._add_reorder_method()
|
|
self.list_display = list(self.list_display)
|
|
|
|
# Insert the magic field into the same position as the first occurrence
|
|
# of the default_order_field, or, if not present, at the start
|
|
try:
|
|
self.default_order_field_index = self.list_display.index(self.default_order_field)
|
|
except ValueError:
|
|
self.default_order_field_index = 0
|
|
self.list_display.insert(self.default_order_field_index, '_reorder')
|
|
|
|
# Remove *all* occurrences of the field from `list_display`
|
|
if self.list_display and self.default_order_field in self.list_display:
|
|
self.list_display = [f for f in self.list_display if f != self.default_order_field]
|
|
|
|
# Remove *all* occurrences of the field from `list_display_links`
|
|
if self.list_display_links and self.default_order_field in self.list_display_links:
|
|
self.list_display_links = [f for f in self.list_display_links if f != self.default_order_field]
|
|
|
|
# Remove *all* occurrences of the field from `ordering`
|
|
if self.ordering and self.default_order_field in self.ordering:
|
|
self.ordering = [f for f in self.ordering if f != self.default_order_field]
|
|
rev_field = '-' + self.default_order_field
|
|
if self.ordering and rev_field in self.ordering:
|
|
self.ordering = [f for f in self.ordering if f != rev_field]
|
|
|
|
def _get_update_url_name(self):
|
|
return f'{self.model._meta.app_label}_{self.model._meta.model_name}_sortable_update'
|
|
|
|
def get_urls(self):
|
|
my_urls = [
|
|
path(
|
|
'adminsortable2_update/',
|
|
self.admin_site.admin_view(self.update_order),
|
|
name=self._get_update_url_name(),
|
|
),
|
|
]
|
|
return my_urls + super().get_urls()
|
|
|
|
def get_actions(self, request):
|
|
actions = super().get_actions(request)
|
|
paginator = self.get_paginator(request, self.get_queryset(request), self.list_per_page)
|
|
if len(paginator.page_range) > 1 and 'all' not in request.GET and self.enable_sorting:
|
|
# add actions for moving items to other pages
|
|
move_actions = ['move_to_exact_page']
|
|
cur_page = int(request.GET.get('p', 0)) + 1
|
|
if len(paginator.page_range) > 2 and cur_page > paginator.page_range[1]:
|
|
move_actions.append('move_to_first_page')
|
|
if cur_page > paginator.page_range[0]:
|
|
move_actions.append('move_to_back_page')
|
|
if cur_page < paginator.page_range[-1]:
|
|
move_actions.append('move_to_forward_page')
|
|
if len(paginator.page_range) > 2 and cur_page < paginator.page_range[-2]:
|
|
move_actions.append('move_to_last_page')
|
|
for fname in move_actions:
|
|
actions.update({fname: self.get_action(fname)})
|
|
return actions
|
|
|
|
def get_changelist(self, request, **kwargs):
|
|
first_order_direction, first_order_field_index = self._get_first_ordering(request)
|
|
if first_order_field_index == self.default_order_field_index:
|
|
self.enable_sorting = True
|
|
self.order_by = f"{first_order_direction}{self.default_order_field}"
|
|
else:
|
|
self.enable_sorting = False
|
|
return super().get_changelist(request, **kwargs)
|
|
|
|
def _get_first_ordering(self, request):
|
|
"""
|
|
Must be consistent with
|
|
`django.contrib.admin.views.main.ChangeList.get_ordering`.
|
|
"""
|
|
order_var = request.GET.get(ORDER_VAR)
|
|
if order_var is None:
|
|
first_order_field_index = self.default_order_field_index
|
|
first_order_direction = self.default_order_direction
|
|
else:
|
|
first_order_field_index = None
|
|
first_order_direction = ""
|
|
for p in order_var.split("."):
|
|
none, prefix, index = p.rpartition("-")
|
|
try:
|
|
index = int(index)
|
|
except ValueError:
|
|
continue # skip it
|
|
else:
|
|
first_order_field_index = index - 1
|
|
first_order_direction = prefix
|
|
break
|
|
return first_order_direction, first_order_field_index
|
|
|
|
@property
|
|
def media(self):
|
|
m = super().media
|
|
if self.enable_sorting:
|
|
m = m + widgets.Media(
|
|
js=[
|
|
'adminsortable2/js/libs/jquery.ui.sortable-1.11.4.js',
|
|
'adminsortable2/js/list-sortable.js',
|
|
]
|
|
)
|
|
return m
|
|
|
|
def _add_reorder_method(self):
|
|
"""
|
|
Adds a bound method, named '_reorder' to the current instance of
|
|
this class, with attributes allow_tags, short_description and
|
|
admin_order_field.
|
|
This can only be done using a function, since it is not possible
|
|
to add dynamic attributes to bound methods.
|
|
"""
|
|
|
|
def func(this, item):
|
|
html = ''
|
|
if this.enable_sorting:
|
|
html = '<div class="drag js-reorder-{1}" order="{0}">' ' </div>'.format(
|
|
getattr(item, this.default_order_field), item.pk
|
|
)
|
|
return mark_safe(html)
|
|
|
|
setattr(func, 'allow_tags', True)
|
|
# if the field used for ordering has a verbose name use it,
|
|
# otherwise default to "Sort"
|
|
for order_field in self.model._meta.fields:
|
|
if order_field.name == self.default_order_field:
|
|
short_description = getattr(order_field, 'verbose_name', None)
|
|
if short_description:
|
|
setattr(func, 'short_description', short_description)
|
|
break
|
|
else:
|
|
setattr(func, 'short_description', _('Sort'))
|
|
setattr(func, 'admin_order_field', self.default_order_field)
|
|
setattr(self, '_reorder', MethodType(func, self))
|
|
|
|
def update_order(self, request):
|
|
if not request.is_ajax() or request.method != 'POST':
|
|
return HttpResponseBadRequest('Not an XMLHttpRequest')
|
|
if request.method != 'POST':
|
|
return HttpResponseNotAllowed('Must be a POST request')
|
|
if not self.has_change_permission(request):
|
|
return HttpResponseForbidden('Missing permissions to perform this request')
|
|
startorder = int(request.POST.get('startorder'))
|
|
endorder = int(request.POST.get('endorder', 0))
|
|
moved_items = list(self._move_item(request, startorder, endorder))
|
|
return HttpResponse(
|
|
json.dumps(moved_items, cls=DjangoJSONEncoder), content_type='application/json;charset=UTF-8'
|
|
)
|
|
|
|
def save_model(self, request, obj, form, change):
|
|
if not change:
|
|
setattr(obj, self.default_order_field, self.get_max_order(request, obj) + 1)
|
|
super().save_model(request, obj, form, change)
|
|
|
|
def move_to_exact_page(self, request, queryset):
|
|
self._bulk_move(request, queryset, self.EXACT)
|
|
|
|
move_to_exact_page.short_description = _('Move selected to specific page')
|
|
|
|
def move_to_back_page(self, request, queryset):
|
|
self._bulk_move(request, queryset, self.BACK)
|
|
|
|
move_to_back_page.short_description = _('Move selected ... pages back')
|
|
|
|
def move_to_forward_page(self, request, queryset):
|
|
self._bulk_move(request, queryset, self.FORWARD)
|
|
|
|
move_to_forward_page.short_description = _('Move selected ... pages forward')
|
|
|
|
def move_to_first_page(self, request, queryset):
|
|
self._bulk_move(request, queryset, self.FIRST)
|
|
|
|
move_to_first_page.short_description = _('Move selected to first page')
|
|
|
|
def move_to_last_page(self, request, queryset):
|
|
self._bulk_move(request, queryset, self.LAST)
|
|
|
|
move_to_last_page.short_description = _('Move selected to last page')
|
|
|
|
def _move_item(self, request, startorder, endorder):
|
|
extra_model_filters = self.get_extra_model_filters(request)
|
|
return self.move_item(startorder, endorder, extra_model_filters)
|
|
|
|
def move_item(self, startorder, endorder, extra_model_filters=None):
|
|
model = self.model
|
|
rank_field = self.default_order_field
|
|
|
|
if endorder < startorder: # Drag up
|
|
move_filter = {
|
|
f'{rank_field}__gte': endorder,
|
|
f'{rank_field}__lte': startorder - 1,
|
|
}
|
|
move_delta = +1
|
|
order_by = f'-{rank_field}'
|
|
elif endorder > startorder: # Drag down
|
|
move_filter = {
|
|
f'{rank_field}__gte': startorder + 1,
|
|
f'{rank_field}__lte': endorder,
|
|
}
|
|
move_delta = -1
|
|
order_by = rank_field
|
|
else:
|
|
return model.objects.none()
|
|
|
|
obj_filters = {rank_field: startorder}
|
|
if extra_model_filters is not None:
|
|
obj_filters.update(extra_model_filters)
|
|
move_filter.update(extra_model_filters)
|
|
|
|
with transaction.atomic():
|
|
try:
|
|
obj = model.objects.get(**obj_filters)
|
|
except model.MultipleObjectsReturned:
|
|
|
|
# noinspection PyProtectedMember
|
|
raise model.MultipleObjectsReturned(
|
|
"Detected non-unique values in field '{rank_field}' used for sorting this model.\n"
|
|
"Consider to run \n python manage.py reorder {model._meta.label}\n"
|
|
"to adjust this inconsistency."
|
|
)
|
|
|
|
move_qs = model.objects.filter(**move_filter).order_by(order_by)
|
|
move_objs = list(move_qs)
|
|
for instance in move_objs:
|
|
setattr(instance, rank_field, getattr(instance, rank_field) + move_delta)
|
|
# Do not run `instance.save()`, because it will be updated
|
|
# later in bulk by `move_qs.update`.
|
|
pre_save.send(
|
|
model,
|
|
instance=instance,
|
|
update_fields=[rank_field],
|
|
raw=False,
|
|
using=router.db_for_write(model, instance=instance),
|
|
)
|
|
move_qs.update(**{rank_field: F(rank_field) + move_delta})
|
|
for instance in move_objs:
|
|
post_save.send(
|
|
model,
|
|
instance=instance,
|
|
update_fields=[rank_field],
|
|
raw=False,
|
|
using=router.db_for_write(model, instance=instance),
|
|
created=False,
|
|
)
|
|
|
|
setattr(obj, rank_field, endorder)
|
|
obj.save(update_fields=[rank_field])
|
|
|
|
return [
|
|
{'pk': instance.pk, 'order': getattr(instance, rank_field)}
|
|
for instance in chain(move_objs, [obj])
|
|
]
|
|
|
|
@staticmethod
|
|
def get_extra_model_filters(request):
|
|
"""
|
|
Returns additional fields to filter sortable objects
|
|
"""
|
|
return {}
|
|
|
|
def get_max_order(self, request, obj=None):
|
|
return self.model.objects.aggregate(max_order=Coalesce(Max(self.default_order_field), 0))['max_order']
|
|
|
|
def _bulk_move(self, request, queryset, method):
|
|
if not self.enable_sorting:
|
|
return
|
|
objects = self.model.objects.order_by(self.order_by)
|
|
paginator = self.paginator(objects, self.list_per_page)
|
|
current_page_number = int(request.GET.get('p', 0)) + 1
|
|
|
|
if method == self.EXACT:
|
|
page_number = int(request.POST.get('page', current_page_number))
|
|
target_page_number = page_number
|
|
elif method == self.BACK:
|
|
step = int(request.POST.get('step', 1))
|
|
target_page_number = current_page_number - step
|
|
elif method == self.FORWARD:
|
|
step = int(request.POST.get('step', 1))
|
|
target_page_number = current_page_number + step
|
|
elif method == self.FIRST:
|
|
target_page_number = 1
|
|
elif method == self.LAST:
|
|
target_page_number = paginator.num_pages
|
|
else:
|
|
raise Exception('Invalid method')
|
|
|
|
if target_page_number == current_page_number:
|
|
# If you want the selected items to be moved to the start of the current page, then just do not return here
|
|
return
|
|
|
|
try:
|
|
page = paginator.page(target_page_number)
|
|
except EmptyPage as ex:
|
|
self.message_user(request, str(ex), level=messages.ERROR)
|
|
return
|
|
|
|
queryset_size = queryset.count()
|
|
page_size = page.end_index() - page.start_index() + 1
|
|
if queryset_size > page_size:
|
|
msg = _(f"The target page size is {page_size}. It is too small for {queryset_size} items.")
|
|
self.message_user(request, msg, level=messages.ERROR)
|
|
return
|
|
|
|
endorders_start = getattr(objects[page.start_index() - 1], self.default_order_field)
|
|
endorders_step = -1 if self.order_by.startswith('-') else 1
|
|
endorders = range(endorders_start, endorders_start + endorders_step * queryset_size, endorders_step)
|
|
|
|
if page.number > current_page_number: # Move forward (like drag down)
|
|
queryset = queryset.reverse()
|
|
endorders = reversed(endorders)
|
|
|
|
for obj, endorder in zip(queryset, endorders):
|
|
startorder = getattr(obj, self.default_order_field)
|
|
self._move_item(request, startorder, endorder)
|
|
|
|
def changelist_view(self, request, extra_context=None):
|
|
if extra_context is None:
|
|
extra_context = {}
|
|
|
|
extra_context['sortable_update_url'] = self.get_update_url(request)
|
|
extra_context['default_order_direction'] = self.default_order_direction
|
|
return super().changelist_view(request, extra_context)
|
|
|
|
def get_update_url(self, request):
|
|
"""
|
|
Returns a callback URL used for updating items via AJAX drag-n-drop
|
|
"""
|
|
return reverse(f'{self.admin_site.name}:{self._get_update_url_name()}')
|
|
|
|
|
|
class PolymorphicSortableAdminMixin(SortableAdminMixin):
|
|
"""
|
|
If the admin class is used for a polymorphic model, hence inherits from ``PolymorphicParentModelAdmin``
|
|
rather than ``admin.ModelAdmin``, then additionally inherit from ``PolymorphicSortableAdminMixin``
|
|
rather than ``SortableAdminMixin``.
|
|
"""
|
|
|
|
def get_max_order(self, request, obj=None):
|
|
return self.base_model.objects.aggregate(max_order=Coalesce(Max(self.default_order_field), 0))[
|
|
'max_order'
|
|
]
|
|
|
|
|
|
class CustomInlineFormSetMixin:
|
|
def __init__(self, *args, **kwargs):
|
|
self.default_order_direction, self.default_order_field = _get_default_ordering(self.model, self)
|
|
|
|
if self.default_order_field not in self.form.base_fields:
|
|
self.form.base_fields[self.default_order_field] = self.model._meta.get_field(
|
|
self.default_order_field
|
|
).formfield()
|
|
|
|
self.form.base_fields[self.default_order_field].is_hidden = True
|
|
self.form.base_fields[self.default_order_field].required = False
|
|
self.form.base_fields[self.default_order_field].widget = widgets.HiddenInput()
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def get_max_order(self):
|
|
query_set = self.model.objects.filter(**{self.fk.get_attname(): self.instance.pk})
|
|
return query_set.aggregate(max_order=Coalesce(Max(self.default_order_field), 0))['max_order']
|
|
|
|
def save_new(self, form, commit=True):
|
|
"""
|
|
New objects do not have a valid value in their ordering field.
|
|
On object save, add an order bigger than all other order fields
|
|
for the current parent_model.
|
|
Strange behaviour when field has a default, this might be evaluated
|
|
on new object and the value will be not None, but the default value.
|
|
"""
|
|
obj = super().save_new(form, commit=False)
|
|
|
|
default_order_field = getattr(obj, self.default_order_field, None)
|
|
if default_order_field is None or default_order_field >= 0:
|
|
max_order = self.get_max_order()
|
|
setattr(obj, self.default_order_field, max_order + 1)
|
|
if commit:
|
|
obj.save()
|
|
# form.save_m2m() can be called via the formset later on
|
|
# if commit=False
|
|
if commit and hasattr(form, 'save_m2m'):
|
|
form.save_m2m()
|
|
return obj
|
|
|
|
|
|
class CustomInlineFormSet(CustomInlineFormSetMixin, BaseInlineFormSet):
|
|
pass
|
|
|
|
|
|
class SortableInlineAdminMixin(SortableAdminBase):
|
|
formset = CustomInlineFormSet
|
|
|
|
def get_fields(self, request, obj=None):
|
|
fields = super().get_fields(request, obj)
|
|
_, default_order_field = _get_default_ordering(self.model, self)
|
|
fields = list(fields)
|
|
|
|
if not (default_order_field in fields):
|
|
# If the order field is not in the field list, add it
|
|
fields.append(default_order_field)
|
|
elif fields[0] == default_order_field:
|
|
"""
|
|
Remove the order field and add it again immediately to ensure it is not on first position.
|
|
This ensures that django's template for tabular inline renders the first column with colspan="2":
|
|
|
|
```
|
|
{% for field in inline_admin_formset.fields %}
|
|
{% if not field.widget.is_hidden %}
|
|
<th{% if forloop.first %} colspan="2"{% endif %}
|
|
```
|
|
|
|
See https://github.com/jrief/django-admin-sortable2/issues/82
|
|
"""
|
|
fields.append(fields.pop(0))
|
|
|
|
return fields
|
|
|
|
@property
|
|
def is_stacked(self):
|
|
return isinstance(self, admin.StackedInline)
|
|
|
|
@property
|
|
def is_tabular(self):
|
|
return isinstance(self, admin.TabularInline)
|
|
|
|
@property
|
|
def media(self):
|
|
shared = super().media + widgets.Media(
|
|
js=('adminsortable2/js/libs/jquery.ui.sortable-1.11.4.js', 'adminsortable2/js/inline-sortable.js')
|
|
)
|
|
if isinstance(self, admin.StackedInline):
|
|
return shared + widgets.Media(
|
|
js=('adminsortable2/js/inline-sortable.js', 'adminsortable2/js/inline-stacked.js')
|
|
)
|
|
else:
|
|
# assume TabularInline (don't return None in any case)
|
|
return shared + widgets.Media(
|
|
js=('adminsortable2/js/inline-sortable.js', 'adminsortable2/js/inline-tabular.js')
|
|
)
|
|
|
|
@property
|
|
def template(self):
|
|
if self.is_stacked:
|
|
return 'adminsortable2/stacked.html'
|
|
elif self.is_tabular:
|
|
return 'adminsortable2/tabular.html'
|
|
raise ImproperlyConfigured(
|
|
f'Class {self.__module__}.{self.__class__} must also derive from admin.TabularInline or '
|
|
f'admin.StackedInline'
|
|
)
|
|
|
|
|
|
class CustomGenericInlineFormSet(CustomInlineFormSetMixin, BaseGenericInlineFormSet):
|
|
def get_max_order(self):
|
|
query_set = self.model.objects.filter(
|
|
**{
|
|
self.ct_fk_field.name: self.instance.pk,
|
|
self.ct_field.name: ContentType.objects.get_for_model(
|
|
self.instance, for_concrete_model=self.for_concrete_model
|
|
),
|
|
}
|
|
)
|
|
return query_set.aggregate(max_order=Coalesce(Max(self.default_order_field), 0))['max_order']
|
|
|
|
|
|
class SortableGenericInlineAdminMixin(SortableInlineAdminMixin):
|
|
formset = CustomGenericInlineFormSet
|
|
|
|
@property
|
|
def is_stacked(self):
|
|
return isinstance(self, GenericStackedInline)
|
|
|
|
@property
|
|
def is_tabular(self):
|
|
return isinstance(self, GenericTabularInline)
|