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

Skip to content

add DragDropOrderedModelAdmin #246

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 11 commits into
base: take_screenshots
Choose a base branch
from
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
8000

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
9E12
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()
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,
F438 "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 ca A6BD nnot 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