Metadata-Version: 2.4
Name: dj-ordered-model
Version: 4.0.0
Summary: Allows Django models to be ordered and provides a simple admin interface for reordering them.
Author-email: Ben Firshman <ben@firshman.co.uk>
License: BSD-3-Clause
Project-URL: Homepage, https://github.com/himkit/dj-ordered-model
Project-URL: Changelog, https://github.com/himkit/dj-ordered-model/blob/main/CHANGES.md
Classifier: Development Status :: 5 - Production/Stable
Classifier: Framework :: Django
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: BSD License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Django>=5.2
Dynamic: license-file

dj-ordered-model
================

A reusable Django app that lets model instances be ordered relative to each
other, and gives the Django admin arrows to reorder them.

Each ordered model gets an integer `order` field that is kept gap-free and
starting at 0. Creating, deleting and moving an object shifts its siblings so
the sequence stays intact, and the ordering can be scoped so that, say, each
user orders their own contacts independently.

This is a fork of [django-ordered-model](https://github.com/django-ordered-model/django-ordered-model).

Requires Django 5.2 or later on Python 3.10 or later.


What is in the repo
-------------------

| Path | What it does |
|------|--------------|
| `ordered_model/models.py` | The core. `OrderedModelBase` implements the ordering logic; `OrderedModel` adds the concrete `order` field. Also holds `OrderedModelManager` / `OrderedModelQuerySet` and the system checks. |
| `ordered_model/admin.py` | `OrderedModelAdmin`, `OrderedTabularInline`, `OrderedStackedInline` and `OrderedInlineModelAdminMixin` — the move controls and the views behind them. |
| `ordered_model/fields.py` | `OrderedManyToManyField`, a `ManyToManyField` whose accessor respects the through model's `Meta.ordering`. |
| `ordered_model/management/commands/reorder_model.py` | `./manage.py reorder_model` — repairs order values that drifted out of band. |
| `ordered_model/signals.py`, `apps.py` | Keep siblings numbered when rows disappear through a cascade or a queryset delete rather than through `Model.delete()`. |
| `ordered_model/templates/`, `static/` | The admin move controls, their arrow images, and the reorder screen's drag & drop script. |
| `ordered_model/locale/` | Translations (de, fr, it, pl, zh_Hans). |
| `tests/` | Test suite. `tests/models.py` holds a fixture model per supported feature combination. `tests/browser.py` is the optional `tox -e browser` drag test, outside the default run. |
| `example/` | A small admin project for trying the reorder screen by hand. See `example/README.md`. |


Installation
------------

```bash
$ pip install dj-ordered-model
```

Or from a checkout of this repository:

```bash
$ pip install .
```

Then add `ordered_model` to `INSTALLED_APPS`.

To see it working before wiring it into your own project, run the example admin
in `example/`:

```bash
$ cd example
$ python manage.py migrate && python manage.py seed && python manage.py runserver
```


Getting started
---------------

Inherit from `OrderedModel` to make a model ordered:

```python
from django.db import models
from ordered_model.models import OrderedModel


class Item(OrderedModel):
    name = models.CharField(max_length=100)
```

Run `./manage.py makemigrations` and `./manage.py migrate` to add the `order`
column. New objects are appended to the end of the list automatically:

```python
foo = Item.objects.create(name="Foo")   # order 0
bar = Item.objects.create(name="Bar")   # order 1
```


Moving objects
--------------

| Call | Effect |
|------|--------|
| `foo.swap(bar)` | Exchange the positions of two objects. |
| `foo.up()` / `foo.down()` | Swap with the neighbour directly above or below. |
| `foo.to(12)` | Move to an arbitrary position, shifting everything between the old and the new position. |
| `foo.above(bar)` / `foo.below(bar)` | Move directly above or below a reference object. |
| `foo.top()` / `foo.bottom()` | Move to the start or the end of the list. |
| `foo.previous()` / `foo.next()` | Return the neighbouring object, without moving anything. |

Each of these runs its re-numbering inside a transaction, so an interrupted move
does not leave the list half shifted.

### Updating fields that a shift would otherwise skip

For performance, `delete()`, `to()`, `above()`, `below()`, `top()` and
`bottom()` shift siblings with a single `UPDATE` rather than saving each row, so
a customised `save()` and fields like `DateTimeField(auto_now=True)` are not
applied to them. Pass the extra values explicitly if you need them:

```python
foo.to(12, extra_update={"modified": now()})
```

This affects only the objects shifted as a side effect, not the object being
moved.


Ordering a subset of objects
----------------------------

Set `order_with_respect_to` to order within a group instead of across the whole
table. For example, so that every user orders their own contacts:

```python
class Contact(OrderedModel):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    phone = models.CharField(max_length=100)
    order_with_respect_to = "user"
```

It also accepts a tuple to group by several fields:

```python
class Model(OrderedModel):
    ...
    order_with_respect_to = ("field_a", "field_b")
```

A many-to-many relationship is ordered through its through model:

```python
class Topping(models.Model):
    name = models.CharField(max_length=100)


class Pizza(models.Model):
    name = models.CharField(max_length=100)
    toppings = models.ManyToManyField(Topping, through="PizzaToppingsThroughModel")


class PizzaToppingsThroughModel(OrderedModel):
    pizza = models.ForeignKey(Pizza, on_delete=models.CASCADE)
    topping = models.ForeignKey(Topping, on_delete=models.CASCADE)
    order_with_respect_to = "pizza"

    class Meta:
        ordering = ("pizza", "order")
```

And it can follow a path through a foreign key, so that items are grouped by a
field of a related object rather than by the relation itself:

```python
class ItemGroup(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    general_info = models.CharField(max_length=100)


class GroupedItem(OrderedModel):
    group = models.ForeignKey(ItemGroup, on_delete=models.CASCADE)
    specific_info = models.CharField(max_length=100)
    order_with_respect_to = "group__user"
```

Here the items live in groups, but their order is per user and independent of
which group an item sits in.

When ordering should span the subclasses of a base model rather than each
subclass separately, name the base class:

```python
class BaseQuestion(OrderedModel):
    order_class_path = __module__ + ".BaseQuestion"
    question = models.TextField(max_length=100)

    class Meta:
        ordering = ("order",)


class MultipleChoiceQuestion(BaseQuestion):
    good_answer = models.TextField(max_length=100)


class OpenQuestion(BaseQuestion):
    answer = models.TextField(max_length=100)
```


System checks
-------------

The app validates its own configuration through Django's check framework, so
most mistakes surface on `./manage.py check` rather than as odd behaviour later.

| ID | Meaning |
|----|---------|
| `E001` | The model has no `Meta.ordering`. |
| `E002` | `order_with_respect_to` is not a string, a tuple or `None`. |
| `E003` | The model's manager does not inherit from `OrderedModelManager`. |
| `W003` | The manager does not inherit from `OrderedModelManager`, but does return an `OrderedModelQuerySet`. This works, but is not ideal. |
| `E004` | The manager does not return a queryset inheriting from `OrderedModelQuerySet`. |
| `E005` | An intermediate field in an `order_with_respect_to` path is not a `ForeignKey`. |
| `E006` | A field named in `order_with_respect_to` does not exist. |
| `E007` | `Meta.ordering` does not include the order field. |
| `W008` | `Meta.ordering` includes the order field, but does not walk it ascending within an ordering group. |

`E007` matters more than it looks: without the order field in `Meta.ordering`,
the rows inside a group come back in whatever order the database picks, so
`previous()`, `next()` and the re-numbering after a delete operate on an
undefined sequence. Put the order field last, after any `order_with_respect_to`
fields.

`W008` covers the orderings that do include the order field but still do not
iterate through it in ascending sequence — `("-order",)`, `F("order").desc()`,
or `("name", "order")`, which sorts by another field first. `previous()` and
`next()` read the adjacent row off such a queryset with `.last()` and
`.first()`, so they return the *farthest* row in the group instead of the
neighbour. `up()`, `down()` and the admin's up and down arrows delegate to them
and move rows several positions at a time. `to()`, `above()`, `below()`,
`top()`, `bottom()` and the re-numbering after a delete do not depend on
iteration order and are unaffected.

Entries ahead of the order field are fine when they name
`order_with_respect_to` fields: those are constant within an ordering group, so
`ordering = ("pizza", "order")` with `order_with_respect_to = "pizza"` is
ascending by the order field wherever it matters, and stays silent.

Descending ordering by the order field is not supported. To move rows on such a
model, make `Meta.ordering` ascending and reverse the sequence where you display
it.

Because a check only reaches whoever runs `./manage.py check`, the admin repeats
it where the broken controls are: `OrderedModelAdmin` adds a warning message to
its changelist, and `OrderedInlineModelAdminMixin` adds one per unsupported
inline on the change page.

A model that raises `W008` also gets no Reorder button and no working up and
down arrows in the admin — the move views refuse them rather than misplace the
row. See [The reorder screen](#the-reorder-screen).


Ordering ManyToMany query results
---------------------------------

Django's `ManyToManyField` [does not respect `Meta.ordering` on the through
model](https://code.djangoproject.com/ticket/30460) when fetching the far side
of the relation. `PizzaToppingsThroughModel.objects.filter(pizza=hawaiian)` is
ordered correctly, but `hawaiian.toppings.all()` is not.

Either add the ordering yourself with
`hawaiian.toppings.all().order_by("pizzatoppingsthroughmodel__order")`, or use
`OrderedManyToManyField`, which does it for you:

```python
from ordered_model.fields import OrderedManyToManyField


class Pizza(models.Model):
    name = models.CharField(max_length=100)
    toppings = OrderedManyToManyField(Topping, through="PizzaToppingsThroughModel")
```

With that, `hawaiian.toppings.all()` comes back in order.

A descending `Meta.ordering` on the through model is read correctly here, but it
raises `W008` and breaks movement on the through model itself — see the system
checks above.


Custom manager and queryset
---------------------------

Subclasses of `OrderedModel` inherit a manager whose queryset adds:

* `above_instance(obj)`, `below_instance(obj)`
* `above(index)`, `below(index)`
* `get_min_order()`, `get_max_order()`, `get_next_order()`
* `increase_order()`, `decrease_order()`
* an order-aware `bulk_create()`

A custom manager must extend `OrderedModelManager` (or check `E003` is raised),
and a custom queryset must extend `OrderedModelQuerySet` (or check `E004` is
raised):

```python
from ordered_model.models import OrderedModel, OrderedModelManager, OrderedModelQuerySet


class ItemQuerySet(OrderedModelQuerySet):
    pass


class ItemManager(OrderedModelManager):
    def get_queryset(self):
        return ItemQuerySet(self.model, using=self._db)


class Item(OrderedModel):
    objects = ItemManager()
```

If another package requires its own `Model`, `QuerySet` or `Manager` classes,
combine them with multiple inheritance — [see this example](https://github.com/django-ordered-model/django-ordered-model/issues/270).


Using an existing field for the order
-------------------------------------

`OrderedModel` adds a `PositiveIntegerField` called `order`. To store the
position in a field you already have, subclass `OrderedModelBase` instead, point
`order_field_name` at your field, and order by it in `Meta`:

```python
from ordered_model.models import OrderedModelBase


class MyModel(OrderedModelBase):
    sort_order = models.PositiveIntegerField(editable=False, db_index=True)
    order_field_name = "sort_order"

    class Meta:
        ordering = ("sort_order",)
```

`order_field_name` tells this library which field to rewrite; `Meta.ordering` is
plain Django. See `CustomOrderFieldModel` in `tests/models.py`.


Admin integration
-----------------

Use `OrderedModelAdmin` and put `move_up_down_links` in `list_display` to get
move arrows on the change list:

> **Deprecated.** `move_up_down_links` in `list_display` still works in 4.0 and
> is removed in 5.0. The arrows only place a row correctly when the changelist
> is sorted by the order field ascending, and render disabled otherwise. Use
> [the reorder screen](#the-reorder-screen), which `OrderedModelAdmin` links
> from every changelist.

```python
from django.contrib import admin
from ordered_model.admin import OrderedModelAdmin
from .models import Item


class ItemAdmin(OrderedModelAdmin):
    list_display = ("name", "move_up_down_links")


admin.site.register(Item, ItemAdmin)
```

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

The arrows are submit buttons: reordering changes data, so it is served over
`POST` only and goes through the surrounding admin form, which supplies the CSRF
token. If you render `move_up_down_links` somewhere other than the admin
changelist or change form, wrap it in a `<form method="post">` that includes
`{% csrf_token %}`. The move views also require the change permission on the
model.

### The reorder screen

`OrderedModelAdmin` adds a **Reorder** button to the changelist, linking to
`/admin/<app>/<model>/reorder/`. That screen is sorted by the order field
ascending and nothing else: the column headers are not clickable, `?o=` is
ignored, and a `ModelAdmin`'s own `ordering` does not apply to it. Its arrows
submit to a move route of their own, which does not re-derive the sort from the
request, so they always move a row to the position you see it go to and land
back on the screen with your filters intact.

The arrows on the ordinary changelist do not have that guarantee. They step
through `Meta.ordering`'s sequence, so on a changelist sorted by another column
they move a row somewhere other than where you aimed. They now render disabled
in that situation, and the move view refuses it server-side.

`move_up_down_links` in `list_display` is therefore **deprecated** and will be
removed in 5.0. Use the reorder screen.

For a model with `order_with_respect_to`, the screen lists nothing until the
filters narrow it to a single group — rows from several groups interleaved
cannot be arranged against each other. Put the `order_with_respect_to` fields in
`list_filter` so a group can be picked.

A model that raises `ordered_model.W008` gets no Reorder button, and the screen
explains why rather than listing rows it cannot correctly move.

A project's `list_editable` does not apply to the screen. The rows there save
themselves as they are dropped, and a formset would be a second, contradictory
way to save the same page.

#### Dragging rows

Each row on the screen has a drag handle. Drop a row where you want it and the
new position is saved immediately; a request that fails puts the row back where
it was and says why, so the order on screen never disagrees with the database.
It works with a mouse, a touch screen or a stylus, and needs no JavaScript
library — the script is loaded through the admin's `Media`, so there is nothing
to add to your templates.

The drag posts to a route of the screen's own:

    POST /admin/<app>/<model>/reorder/<pk>/move-to/
    ref_pk=<pk of the row landed next to>&position=above|below

The destination is named by the primary key of a neighbouring row rather than by
a row index, so a stale page cannot move a row somewhere other than where it was
dropped. The route answers JSON to a request that asks for it (`Accept:
application/json` or `X-Requested-With: XMLHttpRequest`) and redirects back to
the screen otherwise. It requires the change permission on both rows, and
refuses rows ordered within different groups, and rows that share an order value
— repair those with `manage.py reorder_model`.

The arrow controls stay on the screen. They are the keyboard path, the path when
JavaScript is off, and the only way to move a row past a pagination boundary:
dragging works within the page you are looking at.

Django's `admin/<app>/<model>/change_list.html` and `admin/<app>/change_list.html`
conventions keep working under `OrderedModelAdmin`: it names the whole list and
puts its own template last, rather than replacing the convention with a single
name. A template in either of those places is still picked up. Extend the
library's rather than Django's to keep the Reorder button in it:

    {% extends "ordered_model/admin/change_list.html" %}

Setting `change_list_template` on your `ModelAdmin` still overrides all of them,
and the button then only appears if your template extends the library's.

For a many-to-many relationship, use `OrderedTabularInline` or
`OrderedStackedInline` in place of the admin's own inlines:

```python
from django.contrib import admin
from ordered_model.admin import OrderedTabularInline, OrderedInlineModelAdminMixin
from .models import Pizza, PizzaToppingsThroughModel


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


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


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

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

`OrderedStackedInline` works the same way and renders like this:

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

**Note:** the inline classes must be listed in `inlines` so their URL routes get
registered. If you build the list dynamically with `get_inlines()` or
`get_inline_instances()`, treat those as a filter and still list the inlines in
`inlines`, or the change view raises "No Reverse Match".


Repairing a broken ordering
---------------------------

Order values can drift when `save()` and `delete()` are bypassed, or when a
field named in `order_with_respect_to` is changed directly in the database. The
management command renumbers one or more models from scratch:

```bash
$ ./manage.py reorder_model <app_name>.<model_name> [<app_name>.<model_name> ...]
```

Pass `--batch_size` to control how many rows each `UPDATE` batch writes. Running
it without arguments lists the ordered models it can repair.


Compatibility with Django and Python
------------------------------------

| django-ordered-model version | Django version | Python version | DRF |
|------------------------------|----------------|----------------|-----|
| **4.0.x** | **5.2**, **6.x** | **3.10** to **3.13** | not supported |
| **3.8.x** | **3.x**, **4.x**, **5.x** | **3.10** to **3.12** | 3.15 and above |
| **3.7.x** | **3.x**, **4.x** | **3.5** and above | 3.12 and above |
| **3.6.x** | **3.x**, **4.x** | **3.5** and above | 3.12 and above |
| **3.5.x** | **3.x**, **4.x** | **3.5** and above | - |
| **3.4.x** | **2.x**, **3.x** | **3.5** and above | - |
| **3.3.x** | **2.x** | **3.4** and above | - |
| **3.2.x** | **2.x** | **3.4** and above | - |
| **3.1.x** | **2.x** | **3.4** and above | - |
| **3.0.x** | **2.x** | **3.4** and above | - |
| **2.1.x** | **1.x** | **2.7** to 3.6 | - |
| **2.0.x** | **1.x** | **2.7** to 3.6 | - |

The Django Rest Framework serializer was removed in 4.0. Projects that need
`OrderedModelSerializer` should stay on 3.8.x.


Test suite
----------

Against the current environment:

```bash
$ django-admin test --pythonpath=. --settings=tests.settings
```

A single test, using the usual Django test labels:

```bash
$ django-admin test --pythonpath=. --settings=tests.settings tests.tests.OrderGenerationTests
```

Across the whole support matrix, with `tox`. Each environment installs its own
Django, runs the Django checks, then the suite:

```bash
$ tox                     # everything in the envlist
$ tox -e py312-django52   # one environment
$ tox -e black            # formatting check only
```

Code is formatted with [black](https://github.com/psf/black):

```bash
$ black ordered_model/ tests/
```


Credits
-------

Originally written by [Ben Firshman](https://github.com/bfirsh) and maintained
upstream by [Chris Shucksmith](https://github.com/shuckc) and
[Sardorbek Imomaliev](https://github.com/imomaliev), based on
[djangosnippets 998](https://djangosnippets.org/snippets/998/) and
[djangosnippets 259](https://djangosnippets.org/snippets/259/).

BSD licensed, see [LICENSE](./LICENSE).
