8000 add DragDropOrderedModelAdmin by shuckc · Pull Request #246 · django-ordered-model/django-ordered-model · GitHub
[go: up one dir, main page]

Skip to content
Draft
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,6 @@ dmypy.json

# Cython debug symbols
cython_debug/
testdb
tests/migrations
tests/staticfiles
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Unreleased
- Fix `OrderedTabularInline` for models with custom primary key field (#233)
- Add management command `reorder_model` that can re-order most models with a broken ordering
- Fix handling of keyword arguments passed to `bulk_create` by Django 3 (#235)
- Fix inline admin support for Proxy Models by adding parent model to url name (#242)
- Add admin screenshots to README

3.4.1 - 2020-05-11
------------------
Expand Down
31 changes: 19 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,6 @@ from ordered_model.models import OrderedModel
class Item(OrderedModel):
name = models.CharField(max_length=100)

class Meta(OrderedModel.Meta):
pass
```

Model instances now have a set of methods to move them relative to each other.
Expand Down Expand Up @@ -249,7 +247,7 @@ class Item(OrderedModel):

Custom ordering field
---------------------
Extending `OrderedModel` creates a `models.PositiveIntegerField` field called `order` and the appropriate migrations. If you wish to use an existing model field to store the ordering, you can set the attribute `order_field_name` to match your field name:
Extending `OrderedModel` creates a `models.PositiveIntegerField` field called `order` and the appropriate migrations. It customises the default `class Meta` to then order returned querysets by this field. If you wish to use an existing model field to store the ordering, subclass `OrderedModelBase` instead and set the attribute `order_field_name` to match your field name and the `ordering` attribute on `Meta`:

```python
class MyModel(OrderedModelBase):
Expand All @@ -260,7 +258,7 @@ class MyModel(OrderedModelBase):
class Meta:
ordering = ("sort_order",)
```

Setting `order_field_name` is specific for this library to know which field to change when ordering actions are taken. The `Meta` `ordering` line is existing Django functionality to use a field for sorting.
See `tests/models.py` object `CustomOrderFieldModel` for an example.


Expand All @@ -282,6 +280,9 @@ class ItemAdmin(OrderedModelAdmin):
admin.site.register(Item, ItemAdmin)
```

![ItemAdmin screenshot](./static/items.png)


For a many-to-many relationship you need one of the following inlines.

`OrderedTabularInline` or `OrderedStackedInline` just like the django admin.
Expand All @@ -294,22 +295,26 @@ from ordered_model.admin import OrderedTabularInline, OrderedInlineModelAdminMix
from models import Pizza, PizzaToppingsThroughModel


class PizzaToppingsThroughModelTabularInline(OrderedTabularInline):
class PizzaToppingsTabularInline(OrderedTabularInline):
model = PizzaToppingsThroughModel
fields = ('topping', 'order', 'move_up_down_links',)
readonly_fields = ('order', 'move_up_down_links',)
extra = 1
ordering = ('order',)
extra = 1


class PizzaAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin):
model = Pizza
list_display = ('name', )
inlines = (PizzaToppingsThroughModelTabularInline, )
inlines = (PizzaToppingsTabularInline, )


admin.site.register(Pizza, PizzaAdmin)
```

![PizzaAdmin screenshot](./static/pizza.png)


For the `OrderedStackedInline` it will look like this:

```python
Expand All @@ -318,22 +323,24 @@ from ordered_model.admin import OrderedStackedInline, OrderedInlineModelAdminMix
from models import Pizza, PizzaToppingsThroughModel


class PizzaToppingsThroughModelStackedInline(OrderedStackedInline):
class PizzaToppingsStackedInline(OrderedStackedInline):
model = PizzaToppingsThroughModel
fields = ('topping', 'order', 'move_up_down_links',)
readonly_fields = ('order', 'move_up_down_links',)
extra = 1
fields = ('topping', 'move_up_down_links',)
readonly_fields = ('move_up_down_links',)
ordering = ('order',)
extra = 1


class PizzaAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin):
list_display = ('name', )
inlines = (PizzaToppingsThroughModelStackedInline, )
inlines = (PizzaToppingsStackedInline, )


admin.site.register(Pizza, PizzaAdmin)
```

![PizzaAdmin screenshot](./static/pizza-stacked.png)

**Note:** `OrderedModelAdmin` requires the inline subclasses of `OrderedTabularInline` and `OrderedStackedInline` to be listed on `inlines` so that we register appropriate URL routes. If you are using Django 3.0 feature `get_inlines()` or `get_inline_instances()` to return the list of inlines dynamically, consider it a filter and still add them to `inlines` or you might encounter a “No Reverse Match” error when accessing model change view.

Re-ordering models
Expand Down
156 changes: 116 additions & 40 deletions ordered_model/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.utils.encoding import escape_uri_path, iri_to_uri
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django.template.loader import render_to_string
from django.contrib import admin
Expand Down Expand Up @@ -146,11 +147,17 @@ def get_urls(self):
urls = super().get_urls()
for inline in self.inlines:
if issubclass(inline, OrderedInlineMixin):
urls = inline(self, self.admin_site).get_urls() + urls
urls = inline(self.model, self.admin_site).get_urls() + urls
return urls


class OrderedInlineMixin(BaseOrderedModelAdmin):
def _get_model_info(self):
return dict(
**super()._get_model_info(),
parent_model=self.parent_model._meta.model_name,
)

def get_urls(self):
from django.urls import path

Expand All @@ -162,13 +169,16 @@ def wrapper(*args, **kwargs):
return update_wrapper(wrapper, view)

model_info = self._get_model_info()

return [
path(
"<path:admin_id>/{model}/<path:object_id>/move-<direction>/".format(
**model_info
),
wrap(self.move_view),
name="{app}_{model}_change_order_inline".format(**model_info),
name="{app}_{parent_model}_{model}_change_order_inline".format(
**model_info
),
)
]

Expand Down Expand Up @@ -202,54 +212,43 @@ def move_up_down_links(self, obj):
obj._meta.default_manager._get_order_with_respect_to_filter_kwargs(obj)
or []
)

fields = [
str(value.pk)
for value in order_with_respect_to.values()
if value.__class__ is self.parent_model
if (
type(value) == self.parent_model
or issubclass(self.parent_model, type(value))
)
and value is not None
and value.pk is not None
]
order_obj_name = fields[0] if len(fields) > 0 else None

model_info = self._get_model_info()
31CC if order_obj_name:
return render_to_string(
"ordered_model/admin/order_controls.html",
{
"app_label": model_info["app"],
"model_name": model_info["model"],
"module_name": model_info["model"], # backwards compat
"object_id": obj.pk,
"urls": {
"up": reverse(
"{admin_name}:{app}_{model}_change_order_inline".format(
admin_name=self.admin_site.name, **model_info
),
args=[order_obj_name, obj.pk, "up"],
),
"down": reverse(
"{admin_name}:{app}_{model}_change_order_inline".format(
admin_name=self.admin_site.name, **model_info
),
args=[order_obj_name, obj.pk, "down"],
),
"top": reverse(
"{admin_name}:{app}_{model}_change_order_inline".format(
admin_name=self.admin_site.name, **model_info
),
args=[order_obj_name, obj.pk, "top"],
),
"bottom": reverse(
"{admin_name}:{app}_{model}_change_order_inline".format(
admin_name=self.admin_site.name, **model_info
),
args=[order_obj_name, obj.pk, "bottom"],
),
},
"query_string": self.request_query_string,
if not order_obj_name:
return ""

name = "{admin_name}:{app}_{parent_model}_{model}_change_order_inline".format(
admin_name=self.admin_site.name, **model_info
)

return render_to_string(
"ordered_model/admin/order_controls.html",
{
"app_label": model_info["app"],
"model_name": model_info["model"],
"module_name": model_info["model"], # backwards compat
"object_id": obj.pk,
"urls": {
"up": reverse(name, args=[order_obj_name, obj.pk, "up"]),
"down": reverse(name, args=[order_obj_name, obj.pk, "down"]),
"top": reverse(name, args=[order_obj_name, obj.pk, "top"]),
"bottom": reverse(name, args=[order_obj_name, obj.pk, "bottom"]),
},
)
return ""
"query_string": self.request_query_string,
},
)

move_up_down_links.short_description = _("Move")

Expand All @@ -260,3 +259,80 @@ class OrderedTabularInline(OrderedInlineMixin, admin.TabularInline):

class OrderedStackedInline(OrderedInlineMixin, admin.StackedInline):
pass


class DragDropOrderedModelAdmin(BaseOrderedModelAdmin, admin.ModelAdmin):
def get_urls(self):
from django.urls import path

def wrap(view):
def wrapper(*args, **kwargs):
return self.admin_site.admin_view(view)(*args, **kwargs)

wrapper.model_admin = self
return update_wrapper(wrapper, view)

return [
path(
"<path:object_id>/move-above/<path:other_object_id>/",
wrap(self.move_above_view),
name="{app}_{model}_order_above".format(**self._get_model_info()),
)
] + super().get_urls()

def move_above_view(self, request, object_id, other_object_id):
obj = get_object_or_404(self.model, pk=unquote(object_id))
other_obj = get_object_or_404(self.model, pk=unquote(other_object_id))
obj.above(other_obj)
# go back 3 levels (to get from /pk/move-above/other-pk back to the changelist)
return HttpResponseRedirect("../../../")

def make_draggable(self, obj):
model_info = self._get_model_info()
url = reverse(
"{admin_name}:{app}_{model}_order_above".format(
admin_name=self.admin_site.name, **model_info
),
args=[-1, 0], # placeholder pks, will be replaced in js
)
return mark_safe(
"""
<div class="pk-holder" data-pk="%s"></div> <!-- render the pk into each row -->
<style>[draggable=true] { -khtml-user-drag: element; }</style> <!-- fix for dragging in safari -->
<script>
window.__draggedObjPk = null;
django.jQuery(function () {
const $ = django.jQuery;
if (!window.__listSortableSemaphore) { // make sure this part only runs once
window.__move_to_url = '%s'; // this is the url including the placeholder pks
$('#result_list > tbody > tr').each(function(idx, tr) {
const $tr = $(tr);
$tr.attr('draggable', 'true');
const pk = $tr.find('.pk-holder').attr('data-pk');
$tr.attr('data-pk', pk);
$tr.on('dragstart', function (event) {
event.originalEvent.dataTransfer.setData('text/plain', null); // make draggable work in firefox
window.__draggedObjPk = $(this).attr('data-pk');
});
$tr.on('dragover', false); // make it droppable
$tr.on('drop', function (event) {
event.preventDefault(); // prevent firefox from opening the dataTransfer data
const otherPk = $(this).attr('data-pk');
console.log(window.__draggedObjPk, 'dropped on', otherPk);
const url = window.__move_to_url
.replace('\/0\/', '/' + otherPk + '/')
.replace('\/-1\/', '/' + window.__draggedObjPk + '/');
console.log('redirecting', url);
window.location = url;
});
});
window.__listSortableSemaphore = true;
}
});
</script>
"""
% (obj.pk, url)
)

make_draggable.allow_tags = True
make_draggable.short_description = ""
46 changes: 46 additions & 0 deletions script/take_screenshots.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# requires ubuntu
# sudo apt install cutycapt xvfb

set -x

# https://stackoverflow.com/questions/24390488/django-admin-without-authentication
# https://askubuntu.com/questions/75058/how-can-i-take-a-full-page-screenshot-of-a-webpage-from-the-command-line

# delete test DB if it exists
rm -f testdb
rm -Rf tests/staticfiles
mkdir -p tests/migrations tests/staticfiles
touch tests/migrations/__init__.py
mkdir -p static
killall django-admin

function djangoadmin() {
django-admin $1 --pythonpath=. --settings=tests.settings --skip-checks $2
}
djangoadmin "makemigrations"
djangoadmin "migrate"
# requires Django > 3.0
DJANGO_SUPERUSER_PASSWORD=password DJANGO_SUPERUSER_EMAIL="x@test.com" DJANGO_SUPERUSER_USERNAME=admin \
djangoadmin "createsuperuser" "--no-input"
djangoadmin "collectstatic"

# to refresh sample data, use runserver then this export command
# django-admin dumpdata --pythonpath=. --settings=tests.settings tests --output tests/fixtures/screenshot-sample-data.json --indent 4

djangoadmin "loaddata" "screenshot-sample-data"
django-admin runserver --pythonpath=. --settings=tests.settings_autoauth 7000 &
sleep 2

function capture() {
xvfb-run --server-args="-screen 0, 1024x768x24" cutycapt --url=http://localhost:7000/$1 --out=static/$2
}
capture "admin/tests/item/" "items.png"
capture "admin/tests/pizza/1/change/" "pizza.png"
capture "admin/tests/pizzaproxy/1/change/" "pizza-stacked.png"

sleep 1
killall django-admin
rm -Rf tests/migrations
rm -Rf tests/staticfiles


Binary file added static/items.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added static/pizza-stacked.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added static/pizza.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
0