├── .editorconfig
├── .github
└── workflows
│ ├── publish.yml
│ └── tests.yml
├── .gitignore
├── .readthedocs.yaml
├── CHANGELOG.md
├── LICENSE
├── MANIFEST.in
├── README.md
├── adminsortable2
├── __init__.py
├── admin.py
├── locale
│ ├── de
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── en
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── es
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── fa
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── fr
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── it
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── pl
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── ru
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ └── uk
│ │ └── LC_MESSAGES
│ │ ├── django.mo
│ │ └── django.po
├── management
│ ├── __init__.py
│ └── commands
│ │ ├── __init__.py
│ │ └── reorder.py
├── models.py
├── static
│ └── adminsortable2
│ │ ├── css
│ │ └── sortable.css
│ │ └── icons
│ │ └── drag.png
└── templates
│ └── adminsortable2
│ └── change_list.html
├── client
├── admin-sortable2.ts
└── build.cjs
├── demo.gif
├── docs
├── Makefile
├── make.bat
└── source
│ ├── _static
│ ├── django-admin-sortable2.gif
│ ├── list-view-end.png
│ ├── list-view.png
│ ├── stacked-inline-view.png
│ └── tabular-inline-view.png
│ ├── conf.py
│ ├── contributing.rst
│ ├── index.rst
│ ├── installation.rst
│ └── usage.rst
├── package-lock.json
├── package.json
├── parler_example
├── .coveragerc
├── manage.py
├── parler_test_app
│ ├── __init__.py
│ ├── admin.py
│ ├── migrations
│ │ ├── 0001_initial.py
│ │ └── __init__.py
│ └── models.py
├── settings.py
└── urls.py
├── patches
├── stacked-django-4.0.patch
└── tabular-django-4.0.patch
├── setup.py
├── testapp
├── __init__.py
├── admin.py
├── conftest.py
├── fixtures
│ └── data.json
├── manage.py
├── middleware.py
├── migrations
│ ├── 0001_initial.py
│ └── __init__.py
├── models.py
├── pytest.ini
├── requirements.txt
├── settings.py
├── templates
│ └── testapp
│ │ └── impexp_change_list.html
├── test_add_sortable.py
├── test_e2e_inline.py
├── test_e2e_sortable.py
├── test_parse_ordering_part.py
├── urls.py
└── wsgi.py
└── tsconfig.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 |
3 | root = true
4 |
5 | [*]
6 | charset = utf-8
7 | end_of_line = lf
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 | indent_style = tab
11 | indent_size = 4
12 |
13 | [*.py]
14 | max_line_length = 119
15 | indent_style = space
16 |
17 | [*.rst]
18 | max_line_length = 100
19 |
20 | [*.json]
21 | indent_style = space
22 | indent_size = 2
23 |
24 | [*.yml]
25 | indent_style = space
26 | indent_size = 2
27 |
28 | [*.md]
29 | trim_trailing_whitespace = false
30 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish django-admin-sortable2
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | tags:
8 | - '*'
9 |
10 | jobs:
11 | publish:
12 | name: "Publish release"
13 | runs-on: "ubuntu-latest"
14 |
15 | environment:
16 | name: deploy
17 |
18 | strategy:
19 | matrix:
20 | python-version: ["3.11"]
21 | node-version: ["20.x"]
22 |
23 | steps:
24 | - uses: actions/checkout@v3
25 | - name: Use Node.js ${{ matrix.node-version }}
26 | uses: actions/setup-node@v3
27 | - name: Set up Python ${{ matrix.python-version }}
28 | uses: actions/setup-python@v5
29 | - name: Install Dependencies
30 | run: |
31 | npm ci --include=dev
32 | python -m pip install build --user
33 | - name: Build Client
34 | run: |
35 | npm run build
36 | npm run build -- --debug
37 | - name: Patch templates
38 | run: |
39 | mkdir -p adminsortable2/templates/adminsortable2/edit_inline
40 | DJANGO_VERSIONS=("4.2" "5.0" "5.1" "5.2")
41 | for django_version in ${DJANGO_VERSIONS[@]}; do
42 | echo $django_version
43 | curl --silent --output adminsortable2/templates/adminsortable2/edit_inline/stacked-django-$django_version.html https://raw.githubusercontent.com/django/django/stable/$django_version.x/django/contrib/admin/templates/admin/edit_inline/stacked.html
44 | curl --silent --output adminsortable2/templates/adminsortable2/edit_inline/tabular-django-$django_version.html https://raw.githubusercontent.com/django/django/stable/$django_version.x/django/contrib/admin/templates/admin/edit_inline/tabular.html
45 | patch -p0 adminsortable2/templates/adminsortable2/edit_inline/stacked-django-$django_version.html patches/stacked-django-4.0.patch
46 | patch -p0 adminsortable2/templates/adminsortable2/edit_inline/tabular-django-$django_version.html patches/tabular-django-4.0.patch
47 | done
48 | - name: Build 🐍 Python 📦 Package
49 | run: python -m build --sdist --wheel --outdir dist/
50 | - name: Publish 🐍 Python 📦 Package to PyPI
51 | if: startsWith(github.ref, 'refs/tags')
52 | uses: pypa/gh-action-pypi-publish@master
53 | with:
54 | password: ${{ secrets.PYPI_API_TOKEN_SORTABLE2 }}
55 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: Test django-admin-sortable2
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | paths-ignore:
8 | - '**.md'
9 | - '**.rst'
10 | - '/docs/**'
11 | pull_request:
12 | branches:
13 | - master
14 | paths-ignore:
15 | - '**.md'
16 | - '**.rst'
17 | - '/docs/**'
18 |
19 | jobs:
20 | build:
21 |
22 | runs-on: ubuntu-latest
23 |
24 | strategy:
25 | matrix:
26 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
27 | node-version: ["20.x"]
28 | django-version: ["4.2.*", "5.0.*", "5.1.*", "5.2.*"]
29 | exclude: # https://docs.djangoproject.com/en/5.0/faq/install/#what-python-version-can-i-use-with-django
30 | - python-version: "3.12"
31 | django-version: "4.2.*"
32 | - python-version: "3.13"
33 | django-version: "4.2.*"
34 | - python-version: "3.9"
35 | django-version: "5.0.*"
36 | - python-version: "3.9"
37 | django-version: "5.1.*"
38 | - python-version: "3.9"
39 | django-version: "5.2.*"
40 | - python-version: "3.10"
41 | django-version: "5.2.*"
42 |
43 | steps:
44 | - uses: actions/checkout@v3
45 | - name: Use Node.js ${{ matrix.node-version }}
46 | uses: actions/setup-node@v3
47 | - name: Set up Python ${{ matrix.python-version }}
48 | uses: actions/setup-python@v5
49 | with:
50 | python-version: ${{ matrix.python-version }}
51 | - name: Install Dependencies
52 | run: |
53 | npm ci --include=dev
54 | python -m pip install --upgrade pip
55 | python -m pip install --upgrade setuptools wheel
56 | python -m pip install "Django==${{ matrix.django-version }}"
57 | python -m pip install -r testapp/requirements.txt
58 | python -m playwright install
59 | python -m playwright install-deps
60 | - name: Build Client
61 | run: |
62 | npm run build
63 | - name: Patch templates
64 | run: |
65 | mkdir -p adminsortable2/templates/adminsortable2/edit_inline
66 | DJANGO_VERSIONS=("4.2" "5.0" "5.1" "5.2")
67 | for django_version in ${DJANGO_VERSIONS[@]}; do
68 | echo $django_version
69 | curl --silent --output adminsortable2/templates/adminsortable2/edit_inline/stacked-django-$django_version.html https://raw.githubusercontent.com/django/django/stable/$django_version.x/django/contrib/admin/templates/admin/edit_inline/stacked.html
70 | curl --silent --output adminsortable2/templates/adminsortable2/edit_inline/tabular-django-$django_version.html https://raw.githubusercontent.com/django/django/stable/$django_version.x/django/contrib/admin/templates/admin/edit_inline/tabular.html
71 | patch -p0 adminsortable2/templates/adminsortable2/edit_inline/stacked-django-$django_version.html patches/stacked-django-4.0.patch
72 | patch -p0 adminsortable2/templates/adminsortable2/edit_inline/tabular-django-$django_version.html patches/tabular-django-4.0.patch
73 | done
74 | - name: Test with pytest
75 | run: |
76 | mkdir -p workdir
77 | python -m pytest testapp
78 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | *.log
3 | *.pot
4 | *.pyc
5 | *.egg-info
6 | *.coverage
7 | *.tsbuildinfo
8 | *~
9 | .DS_Store
10 | .tmp*
11 | .tox
12 | .venv
13 | env
14 | local_settings.py
15 | build
16 | docs/_build
17 | dist
18 | node_modules/
19 | workdir/*
20 | htmlcov
21 | adminsortable2/static/adminsortable2/js/adminsortable2.*
22 | adminsortable2/templates/adminsortable2/edit_inline
23 |
--------------------------------------------------------------------------------
/.readthedocs.yaml:
--------------------------------------------------------------------------------
1 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
2 |
3 | # Required
4 | version: 2
5 |
6 | # Set the version of Python and other tools you might need
7 | build:
8 | os: ubuntu-22.04
9 | tools:
10 | python: "3.11"
11 |
12 | # Build documentation in the docs/ directory with Sphinx
13 | sphinx:
14 | configuration: docs/source/conf.py
15 |
16 | # We recommend specifying your dependencies to enable reproducible builds:
17 | # https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
18 | python:
19 | install:
20 | - requirements: testapp/requirements.txt
21 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # CHANGELOG
2 |
3 | ## Release history of [django-admin-sortable2](https://github.com/jrief/django-admin-sortable2/)
4 |
5 | ### 2.2.8
6 | - Remove paths `docs` and `parler_example` from installable package.
7 |
8 | ### 2.2.7
9 | - Add support for Django-5.2.
10 |
11 | ### 2.2.6
12 | - Fix adding new models with inlines: automatically set order of new entries.
13 |
14 | ### 2.2.5
15 | - Fix sorting in parallel requests.
16 | - Add support for Python 3.13.
17 |
18 | ### 2.2.4
19 | - Fix: Selected ordering is not always preserved when using "Save as new" in inline admin.
20 |
21 | ### 2.2.3
22 | - Add compatibility for Django-5.1
23 |
24 | ### 2.2.2
25 | - Fix: Coalesce type error on `default_ordering_field`.
26 |
27 | ### 2.2.1
28 | - Fix: With setting `DEBUG = True`, loading the unminimized JavaScript files failed. They now are added during build
29 | time.
30 |
31 | ### 2.2
32 | - Add support for Django-5.0
33 | - Add support for Python-3.12
34 | - Drop support for Django-4.1 and lower.
35 | - Drop support for Python-3.8
36 |
37 | ### 2.1.11
38 | - Upgrade all external dependencies to their latest versions.
39 | - Adopt E2E tests to use Playwright's `locator`.
40 |
41 | ### 2.1.10
42 | - Do not create sourcemaps in production build.
43 |
44 | ### 2.1.9
45 | - Folder `testapp` is not published on PyPI anymore.
46 |
47 | ### 2.1.8
48 | - Fix: In combination with [django-nested-admin](https://github.com/theatlantic/django-nested-admin) integration fails
49 | with an error in inlien formsets.
50 |
51 | ### 2.1.7
52 | - Yanked.
53 |
54 | ### 2.1.6
55 | - Add support for Django-4.2.
56 |
57 | ### 2.1.5
58 | - Fix: In `SortableInlineAdminMixin.get_fields()`, convert potential tuple to list in order to append extra elements.
59 |
60 | ### 2.1.4
61 | - Fix: It not is not possible anymore, to move items beyond the last item, i.e. after an empty extra rows for new items
62 | or even after the row with the "Add another chapter" link.
63 |
64 | ### 2.1.3
65 | - Fix #328: Replace `uglify` against `terser` to minify JavaScript files.
66 |
67 | ### 2.1.2
68 | - Fix #320: Adding Jinja2 as template engine didn't work because it is unable to handle file references using `pathlib.Path`.
69 |
70 | ### 2.1.1
71 | - Enlarge top/down buttons on top of header of `SortableTabularInline`.
72 |
73 | ### 2.1
74 | - Introduce classes `adminsortable2.admin.SortableStackedInline` and `adminsortable2.admin.SortableTabularInline`
75 | to resort items to the begin or end of the list of those inlines.
76 | - Add support for Django-4.1.
77 |
78 | ### 2.0.5
79 | - Fix: When using an `InlineAdmin` with a self defined form, the default ordering
80 | has been ignored.
81 | - Fix: Skip instance check, if model used in an `InlineAdmin` is a proxy model.
82 |
83 |
84 | ### 2.0.4
85 | - Fix [\#309](https://github.com/jrief/django-admin-sortable2/issues/309):
86 | Prevent JavaScrip error when using *InlineAdmin without sortable list view.
87 | - Internally use Python's `pathlib` instead of `os.path`.
88 | - In DEBUG mode, load unminimized JavaScript.
89 |
90 |
91 | ### 2.0.3
92 | - Fix [\#304](https://github.com/jrief/django-admin-sortable2/issues/304):
93 | ModelAdmin not inheriting from SortableAdminMixin prevented sortable Stacked-/TabluraInlineAdmin be sortable.
94 |
95 |
96 | ### 2.0.2
97 | - Fix [\#303](https://github.com/jrief/django-admin-sortable2/issues/303):
98 | Use CSRF-Token from input field rather than from Cookie.
99 |
100 |
101 | ### 2.0.1
102 | - Fix [\#302](https://github.com/jrief/django-admin-sortable2/issues/302):
103 | Django's ManifestStaticFilesStorage references missing file `adminsortable2.js.map`.
104 |
105 |
106 | ### 2.0
107 | - Drop support for Django 3.2 and lower.
108 | - Replace jQuery-UI's [sortable](https://jqueryui.com/sortable/) against
109 | [Sortable.js](https://sortablejs.github.io/Sortable/).
110 | - Use TypeScript instead of JavaScript for all client side code.
111 | - Remove all extended Django admin templates: This allows a smoother upgrade
112 | for future Django versions.
113 | - New feature: Select multiple rows and drag them to a new position.
114 |
115 |
116 | ### 1.0.4
117 | - Fix [\#294](https://github.com/jrief/django-admin-sortable2/issues/294):
118 | issue in 1.0.3 where `install_requires` unintentionally dropped Django 2.2
119 |
120 |
121 | ### 1.0.3
122 | - Adding support for Django 4 and Python 3.10.
123 |
124 |
125 | ### 1.0.2
126 | - Fix regression introduced in 1.0.1, adding double item rows on
127 | SortableInlineAdminMixin and TabularInline.
128 |
129 |
130 | ### 1.0.1
131 | - Fix CSS classes change introduced in Django-2.1.
132 | - Prepared to run on Django-4.0.
133 | - Ditch Travis-CI in favor of GitHub Actions.
134 |
135 |
136 | ### 1.0
137 | - Drop support for Python-2.7, 3.4 and 3.5.
138 | - Drop support for Django-1.10, 1.11, 2.0 and 2.1.
139 | - Add Python-3.9 to the testing matrix.
140 | - Refactor code base to clean Python-3 syntax.
141 |
142 |
143 | ### 0.7.8
144 | - Fix [\#207](https://github.com/jrief/django-admin-sortable2/issues/207):
145 | Last item not displayed in stacked- and tabular inline admins, if model doesn\'t have add permission.
146 |
147 |
148 | ### 0.7.7
149 | - Add support for Django-3.1.
150 |
151 |
152 | ### 0.7.6
153 | - Fix [\#241](https://github.com/jrief/django-admin-sortable2/issues/241):
154 | Move items when the order column is not first.
155 | - Fix [\#242](https://github.com/jrief/django-admin-sortable2/issues/242):
156 | Bulk move when sorting order is descending.
157 | - Fix [\#243](https://github.com/jrief/django-admin-sortable2/issues/243):
158 | Bulk move to last page when it is too small.
159 | - Refactor aggregates to use Coalasce for empty querysets.
160 |
161 |
162 | ### 0.7.5
163 | - Add support for Django-3.0.
164 |
165 |
166 | ### 0.7.4
167 | - Fix [\#208](https://github.com/jrief/django-admin-sortable2/issues/208):
168 | Correctly apply custom css classes from the
169 | `InlineModelAdmin.classes` attribute then using `StackedInline`.
170 |
171 |
172 | ### 0.7.3
173 | - Fix [\#220](https://github.com/jrief/django-admin-sortable2/issues/220):
174 | If model admin declares `list_display_links = None`, no link is autogenerated for the detail view.
175 |
176 |
177 | ### 0.7.2
178 | - Fully adopted and tested with Django-2.2
179 |
180 |
181 | ### 0.7.1
182 | - Fix issue with JavaScript loading in Django-2.2.
183 |
184 |
185 | ### 0.7
186 | - Add support for Django-2.0 and Django-2.1.
187 | - Drop support for Django-1.9 and lower.
188 | - Check for changed function signature in Django-2.1.
189 |
190 |
191 | ### 0.6.21
192 | - Added jQuery compatibility layer for Django-2.1.
193 |
194 |
195 | ### 0.6.20
196 | - Dysfunctional.
197 |
198 |
199 | ### 0.6.19
200 | - Fix [\#183](https://github.com/jrief/django-admin-sortable2/issues/183):
201 | Use `mark_safe` for reorder `div`.
202 |
203 |
204 | ### 0.6.18
205 | - Fix: Method `adminsortable2.admin.SortableInlineAdminMixin.get_fields` always return
206 | a list instead of sometimes a tuple.
207 |
208 |
209 | ### 0.6.17
210 | - [\#171](https://github.com/jrief/django-admin-sortable2/issues/171):
211 | Adhere to Content Security Policy best practices by removing inline scripts.
212 | - Adopted to Django-2.0 keeping downwards compatibility until Django-1.9.
213 | - Better explanation what to do in case of sorting inconsistency.
214 |
215 |
216 | ### 0.6.16
217 | - Fixes [\#137](https://github.com/jrief/django-admin-sortable2/issues/137):
218 | Allow standard collapsible tabular inline.
219 |
220 |
221 | ### 0.6.15
222 | - Fix [\#164](https://github.com/jrief/django-admin-sortable2/issues/164):
223 | TypeError when `display_list` in admin contains a callable.
224 | - Fix [\#160](https://github.com/jrief/django-admin-sortable2/issues/160):
225 | Updated ordering values not getting saved in `TabluarInlineAdmin` / `StackedInlineAdmin`.
226 |
227 |
228 | ### 0.6.14
229 | - Fix [\#162](https://github.com/jrief/django-admin-sortable2/issues/162):
230 | In model admin, setting `actions` to `None` or `[]` breaks the sortable functionality.
231 |
232 |
233 | ### 0.6.13
234 | - Fix [\#159](https://github.com/jrief/django-admin-sortable2/issues/159):
235 | Make stacked inline\'s header more clear that it is sortable.
236 |
237 |
238 | ### 0.6.12
239 | - Fix [\#155](https://github.com/jrief/django-admin-sortable2/issues/155):
240 | Sortable column not the first field by default.
241 |
242 |
243 | ### 0.6.11
244 | - Fix [\#147](https://github.com/jrief/django-admin-sortable2/issues/147):
245 | Use current admin site name instead of `admin`.
246 | - Fix [\#122](https://github.com/jrief/django-admin-sortable2/issues/122):
247 | Columns expand continuously with each sort.
248 |
249 |
250 | ### 0.6.9 and 0.6.10
251 | - Fix [\#139](https://github.com/jrief/django-admin-sortable2/issues/139):
252 | Better call of post_save signal.
253 |
254 |
255 | ### 0.6.8
256 | - Fix [\#135](https://github.com/jrief/django-admin-sortable2/issues/135):
257 | Better call of pre_save signal.
258 | - On `./manage.py reorder ...`, name the model using `app_label.model_name` rather than
259 | requiring the fully qualified path.
260 | - In `adminsortable2.admin.SortableAdminMixin` renamed method `update` to `update_order`,
261 | to prevent potential naming conflicts.
262 |
263 |
264 | ### 0.6.7
265 | - Add class `PolymorphicSortableAdminMixin` so that method `get_max_order` references the
266 | ordering field from the base model.
267 |
268 |
269 | ### 0.6.6
270 | - Fix: Drag\'n Drop reordering now send \[pre\|post\]\_save signals for all updated instances.
271 |
272 |
273 | ### 0.6.5
274 | - Fix: Reorder management command now accepts args.
275 |
276 |
277 | ### 0.6.4
278 | - Drop support for Django-1.5.
279 | - `change_list_template` now is extendible.
280 | - Fixed concatenation if `exclude` is tuple.
281 | - Support reverse sorting in CustomInlineFormSet.
282 |
283 |
284 | ### 0.6.3
285 | - Setup.py ready for Python 3.
286 |
287 |
288 | ### 0.6.2
289 | - Fixed regression from 0.6.0: Multiple sortable inlines are now possible again.
290 |
291 |
292 | ### 0.6.1
293 | - Removed global variables from Javascript namespace.
294 |
295 |
296 | ### 0.6.0
297 | - Compatible with Django 1.9.
298 | - In the list view, it now is possible to move items to any arbitrary page.
299 |
300 |
301 | ### 0.5.0
302 | - Changed the namespace from `adminsortable` to `adminsortable2` to allow both this project and
303 | [django-admin-sortable](https://github.com/jazzband/django-admin-sortable) to co-exist in
304 | the same project. This is helpful for projects to transition from one to the other library.
305 | It also allows existing projects\'s migrations which previously relied on django-admin-sortable
306 | to continue to work.
307 |
308 |
309 | ### 0.3.2
310 | - Fix [\#42](https://github.com/jrief/django-admin-sortable2/issues/42):
311 | Sorting does not work when ordering is descending.
312 |
313 |
314 | ### 0.3.2
315 | - Use property method `media()` instead of hard coded `Media` class.
316 | - Use the `verbose_name` from the column used to keep the order of fields instead of a
317 | hard coded \"Sort\".
318 | - When updating order in change_list_view, use the CSRF protection token.
319 |
320 |
321 | ### 0.3.1
322 | - Fixed issue \#25:
323 | `admin.TabularInline` problem in Django 1.5.x
324 | - Fixed problem when adding new Inline Form Fields.
325 | - PEP8 cleanup.
326 |
327 |
328 | ### 0.3.0
329 | - Support for Python-3.3.
330 | - Fixed: Add list-sortable.js on changelist only. Issue \#31.
331 |
332 |
333 | ### 0.2.9
334 | - Fixed: StackedInlines do not add an empty field after saving the model.
335 | - Added management command to preset initial ordering.
336 |
337 |
338 | ### 0.2.8
339 | - Refactored documentation for Read-The-Docs
340 |
341 |
342 | ### 0.2.7
343 | - Fixed: MethodType takes only two attributes
344 |
345 |
346 | ### 0.2.6
347 | - Fixed: Unsortable inline models become draggable when there is a sortable inline model
348 |
349 |
350 | ### 0.2.5
351 | - Bulk actions are added only when they make sense.
352 | - Fixed bug when clicking on table header for ordering field.
353 |
354 |
355 | ### 0.2.4
356 | - Fix CustomInlineFormSet to allow customization.
357 |
358 |
359 | ### 0.2.2
360 | - Distinction between different versions of jQuery in case django-cms is installed side by side.
361 |
362 | ### 0.2.0
363 | - Added sortable stacked and tabular inlines.
364 |
365 |
366 | ### 0.1.2
367 | - Fixed: All field names other than \"order\" are now allowed.
368 |
369 |
370 | ### 0.1.1
371 | - Fixed compatibility issue when used together with django-cms.
372 |
373 |
374 | ### 0.1.0
375 | - First version published on PyPI.
376 |
377 |
378 | ### 0.0.1
379 | - First working release.
380 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Jacob Rief
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
24 | Exception:
25 |
26 | The distribution package contains two template files from Django itself which
27 | are patched during build time. These files are licensed under Django's own
28 | licensing terms. Please check https://github.com/django/django/blob/main/LICENSE
29 | for details.
30 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.md
2 | include setup.py
3 | recursive-include adminsortable2 *.py
4 | recursive-include adminsortable2/locale *
5 | recursive-include adminsortable2/static *
6 | recursive-include adminsortable2/templates *
7 | recursive-exclude testapp *
8 | recursive-exclude * *.pyc
9 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # django-admin-sortable2
2 |
3 | This Django package adds functionality for generic drag-and-drop ordering of items in the List, the Stacked- and the
4 | Tabular-Inlines Views of the Django Admin interface.
5 |
6 | [](https://github.com/jrief/django-admin-sortable2/actions/workflows/tests.yml)
7 | [](https://pypi.python.org/pypi/django-admin-sortable2)
8 | [](https://pypi.python.org/pypi/django-admin-sortable2)
9 | [](https://pypi.python.org/pypi/django-admin-sortable2)
10 | [](https://img.shields.io/pypi/dm/django-admin-sortable2.svg)
11 | [](https://github.com/jrief/django-admin-sortable2/blob/master/LICENSE)
12 |
13 | Check the demo:
14 |
15 | 
16 |
17 | This library offers simple mixin classes which enrich the functionality of any existing class inheriting from
18 | `admin.ModelAdmin`, `admin.StackedInline` or `admin.TabularInline`.
19 |
20 | It thus makes it very easy to integrate with existing models and their model admin interfaces. Existing models can
21 | inherit from `models.Model` or any other class derived thereof. No special base class is required.
22 |
23 |
24 | ## Version 2.0
25 |
26 | This is a major rewrite of this **django-admin-sortable2**. It replaces the client side part against
27 | [Sortable.JS](https://sortablejs.github.io/Sortable/) and thus the need for jQuery.
28 |
29 | Replacing that library allowed me to add a new feature: Multiple items can now be dragged and dropped together.
30 |
31 |
32 | ## Project's Home
33 |
34 | https://github.com/jrief/django-admin-sortable2
35 |
36 | Detailled documentation can be found on [ReadTheDocs](https://django-admin-sortable2.readthedocs.org/en/latest/).
37 |
38 | Before reporting bugs or asking questions, please read the
39 | [contributor's guide](https://django-admin-sortable2.readthedocs.io/en/latest/contributing.html).
40 |
41 |
42 | ## License
43 |
44 | Licensed under the terms of the MIT license.
45 |
46 | Copyright © 2013-2022 Jacob Rief and contributors.
47 |
48 | Please follow me on
49 | [](https://twitter.com/jacobrief)
50 | for updates and other news.
51 |
--------------------------------------------------------------------------------
/adminsortable2/__init__.py:
--------------------------------------------------------------------------------
1 | __version__ = '2.2.8'
2 |
--------------------------------------------------------------------------------
/adminsortable2/admin.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import json
4 | from pathlib import Path
5 | from itertools import chain
6 | from types import MethodType
7 | from typing import Optional
8 |
9 | from django import VERSION as DJANGO_VERSION
10 | from django.conf import settings
11 | from django.contrib import admin, messages
12 | from django.contrib.contenttypes.forms import BaseGenericInlineFormSet
13 | from django.contrib.contenttypes.models import ContentType
14 | from django.core.exceptions import ImproperlyConfigured
15 | from django.core.paginator import EmptyPage
16 | from django.db import router, transaction, models
17 | from django.db.models import OrderBy
18 | from django.db.models.aggregates import Max
19 | from django.db.models.expressions import BaseExpression, F
20 | from django.db.models.functions import Coalesce
21 | from django.db.models.signals import post_save, pre_save
22 | from django.forms import widgets
23 | from django.forms.fields import IntegerField
24 | from django.forms.models import BaseInlineFormSet
25 | from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, HttpResponseForbidden
26 | from django.utils.safestring import mark_safe
27 | from django.utils.translation import gettext_lazy as _
28 | from django.urls import path, reverse
29 |
30 | __all__ = ['SortableAdminMixin', 'SortableInlineAdminMixin']
31 |
32 |
33 | def _parse_ordering_part(part: OrderBy | BaseExpression | F | str) -> tuple[str, Optional[str]]:
34 | if isinstance(part, str):
35 | return ('-', part[1:]) if part.startswith('-') else ('', part)
36 | elif isinstance(part, OrderBy) and isinstance(part.expression, F):
37 | return ('-' if part.descending else ''), part.expression.name
38 | elif isinstance(part, F):
39 | return '', part.name
40 | else:
41 | return '', None
42 |
43 |
44 | def _get_default_ordering(model, model_admin):
45 | try:
46 | # first try with the model admin ordering
47 | prefix, field_name = _parse_ordering_part(model_admin.ordering[0])
48 | except (AttributeError, IndexError, TypeError):
49 | pass
50 | else:
51 | if field_name is not None:
52 | return prefix, field_name
53 |
54 | try:
55 | # then try with the model ordering
56 | prefix, field_name = _parse_ordering_part(model._meta.ordering[0])
57 | except (AttributeError, IndexError):
58 | pass
59 | else:
60 | if field_name is not None:
61 | return prefix, field_name
62 |
63 | raise ImproperlyConfigured(
64 | f"Model {model.__module__}.{model.__name__} requires a list or tuple 'ordering' in its Meta class"
65 | )
66 |
67 |
68 | class MovePageActionForm(admin.helpers.ActionForm):
69 | step = IntegerField(
70 | required=False,
71 | initial=1,
72 | widget=widgets.NumberInput(attrs={'id': 'changelist-form-step'}),
73 | label=False
74 | )
75 | page = IntegerField(
76 | required=False,
77 | widget=widgets.NumberInput(attrs={'id': 'changelist-form-page'}),
78 | label=False
79 | )
80 |
81 |
82 | class SortableAdminBase:
83 | @property
84 | def media(self):
85 | css = {'all': ['adminsortable2/css/sortable.css']}
86 | js = ['adminsortable2/js/adminsortable2{}.js'.format('' if settings.DEBUG else '.min')]
87 | return super().media + widgets.Media(css=css, js=js)
88 |
89 | def get_formset_kwargs(self, request, obj, inline, prefix):
90 | formset_params = super().get_formset_kwargs(request, obj, inline, prefix)
91 | if hasattr(inline, 'default_order_direction') and hasattr(inline, 'default_order_field'):
92 | formset_params.update(
93 | default_order_direction=inline.default_order_direction,
94 | default_order_field=inline.default_order_field,
95 | )
96 | return formset_params
97 |
98 | def get_inline_formsets(self, request, formsets, inline_instances, obj=None, **kwargs):
99 | inline_admin_formsets = super().get_inline_formsets(request, formsets, inline_instances, obj, **kwargs)
100 | for inline_admin_formset in inline_admin_formsets:
101 | if hasattr(inline_admin_formset.formset, 'default_order_direction'):
102 | classes = inline_admin_formset.classes.split()
103 | classes.append('sortable')
104 | if inline_admin_formset.formset.default_order_direction == '-':
105 | classes.append('reversed')
106 | inline_admin_formset.classes = ' '.join(classes)
107 | return inline_admin_formsets
108 |
109 |
110 | class SortableAdminMixin(SortableAdminBase):
111 | BACK, FORWARD, FIRST, LAST, EXACT = range(5)
112 | action_form = MovePageActionForm
113 |
114 | @property
115 | def change_list_template(self):
116 | opts = self.model._meta
117 | app_label = opts.app_label
118 | templates = [
119 | Path('adminsortable2') / Path(app_label) / Path(opts.model_name) / Path('change_list.html'),
120 | Path('adminsortable2') / Path(app_label) / Path('change_list.html'),
121 | Path('adminsortable2/change_list.html'),
122 | ]
123 | return [str(path) for path in templates]
124 |
125 | def __init__(self, model, admin_site):
126 | self.default_order_direction, self.default_order_field = _get_default_ordering(model, self)
127 | super().__init__(model, admin_site)
128 | self.enable_sorting = False
129 | self.order_by = None
130 | self._add_reorder_method()
131 |
132 | def get_list_display(self, request):
133 | list_display = list(super().get_list_display(request))
134 | try:
135 | index = list_display.index(self.default_order_field)
136 | except ValueError:
137 | list_display.insert(0, '_reorder_')
138 | else:
139 | list_display[index] = '_reorder_'
140 | if len(list_display) == 1:
141 | list_display.append('__str__')
142 | return list_display
143 |
144 | def get_list_display_links(self, request, list_display):
145 | list_display_links = list(super().get_list_display_links(request, list_display))
146 | if '_reorder_' in list_display_links:
147 | list_display_links.remove('_reorder_')
148 | if len(list_display_links) == 0:
149 | list_display_links = [ld for ld in list_display if ld != '_reorder_'][:1]
150 | return list_display_links
151 |
152 | def get_fields(self, request, obj=None):
153 | fields = list(super().get_fields(request, obj))
154 | if self.default_order_field in fields:
155 | fields.remove(self.default_order_field)
156 | return fields
157 |
158 | def _get_update_url_name(self):
159 | return f'{self.model._meta.app_label}_{self.model._meta.model_name}_sortable_update'
160 |
161 | def get_urls(self):
162 | my_urls = [
163 | path(
164 | 'adminsortable2_update/',
165 | self.admin_site.admin_view(self.update_order),
166 | name=self._get_update_url_name()
167 | ),
168 | ]
169 | return my_urls + super().get_urls()
170 |
171 | def get_actions(self, request):
172 | actions = super().get_actions(request)
173 | qs = self.get_queryset(request)
174 | paginator = self.get_paginator(request, qs, self.list_per_page)
175 | if paginator.num_pages > 1 and 'all' not in request.GET and self.enable_sorting:
176 | # add actions for moving items to other pages
177 | move_actions = []
178 | cur_page = int(request.GET.get('p', 1))
179 | if cur_page > 1:
180 | move_actions.append('move_to_first_page')
181 | if cur_page > paginator.page_range[1]:
182 | move_actions.append('move_to_back_page')
183 | if cur_page < paginator.page_range[-2]:
184 | move_actions.append('move_to_forward_page')
185 | if cur_page < paginator.page_range[-1]:
186 | move_actions.append('move_to_last_page')
187 | if len(paginator.page_range) > 4:
188 | move_actions.append('move_to_exact_page')
189 | for fname in move_actions:
190 | actions.update({fname: self.get_action(fname)})
191 | return actions
192 |
193 | def get_changelist_instance(self, request):
194 | cl = super().get_changelist_instance(request)
195 | qs = self.get_queryset(request)
196 | ordering = cl.get_ordering(request, qs)
197 | assert len(ordering) > 0 # `ChangeList.get_ordering` always returns deterministic ordering.
198 | order_direction, order_field = _parse_ordering_part(ordering[0])
199 | if order_field == self.default_order_field:
200 | self.enable_sorting = True
201 | self.order_by = f'{order_direction}{order_field}'
202 | else:
203 | self.enable_sorting = False
204 | return cl
205 |
206 | def _add_reorder_method(self):
207 | """
208 | Adds a bound method, named '_reorder_' to the current instance of
209 | this class, with attributes allow_tags, short_description and
210 | admin_order_field.
211 | This can only be done using a function, since it is not possible
212 | to add dynamic attributes to bound methods.
213 | """
214 | def func(this, item):
215 | if this.enable_sorting:
216 | order = getattr(item, this.default_order_field)
217 | html = f'
'
218 | else:
219 | html = '
'
220 | return mark_safe(html)
221 |
222 | # if the field used for ordering has a verbose name use it, otherwise default to "Sort"
223 | for order_field in self.model._meta.fields:
224 | if order_field.name == self.default_order_field:
225 | short_description = getattr(order_field, 'verbose_name', None)
226 | if short_description:
227 | setattr(func, 'short_description', short_description)
228 | break
229 | else:
230 | setattr(func, 'short_description', _("Sort"))
231 | setattr(func, 'admin_order_field', self.default_order_field)
232 | setattr(self, '_reorder_', MethodType(func, self))
233 |
234 | def update_order(self, request):
235 | if request.method != 'POST':
236 | return HttpResponseNotAllowed(f"Method {request.method} not allowed")
237 | if not self.has_change_permission(request):
238 | return HttpResponseForbidden('Missing permissions to perform this request')
239 | try:
240 | extra_model_filters = self.get_extra_model_filters(request)
241 | num_updated = self._update_order(json.loads(request.body).get('updatedItems'), extra_model_filters)
242 | return HttpResponse(f"Updated {num_updated} items")
243 | except Exception as exc:
244 | return HttpResponseBadRequest(f"Invalid POST request: {exc}")
245 |
246 | def _update_order(self, updated_items, extra_model_filters):
247 | queryset = self.model.objects.filter(**extra_model_filters)
248 | updated_objects = []
249 | for item in updated_items:
250 | obj = queryset.get(pk=item[0])
251 | setattr(obj, self.default_order_field, item[1])
252 | updated_objects.append(obj)
253 | return self.model.objects.bulk_update(updated_objects, [self.default_order_field])
254 |
255 | def save_model(self, request, obj, form, change):
256 | if not change:
257 | setattr(
258 | obj, self.default_order_field,
259 | self.get_max_order(request, obj) + 1
260 | )
261 | super().save_model(request, obj, form, change)
262 |
263 | def move_to_exact_page(self, request, queryset):
264 | self._bulk_move(request, queryset, self.EXACT)
265 | move_to_exact_page.short_description = _('Move selected to specific page')
266 |
267 | def move_to_back_page(self, request, queryset):
268 | self._bulk_move(request, queryset, self.BACK)
269 | move_to_back_page.short_description = _('Move selected ... pages back')
270 |
271 | def move_to_forward_page(self, request, queryset):
272 | self._bulk_move(request, queryset, self.FORWARD)
273 | move_to_forward_page.short_description = _('Move selected ... pages forward')
274 |
275 | def move_to_first_page(self, request, queryset):
276 | self._bulk_move(request, queryset, self.FIRST)
277 | move_to_first_page.short_description = _('Move selected to first page')
278 |
279 | def move_to_last_page(self, request, queryset):
280 | self._bulk_move(request, queryset, self.LAST)
281 | move_to_last_page.short_description = _('Move selected to last page')
282 |
283 | def _move_item(self, startorder, endorder, extra_model_filters):
284 | model = self.model
285 | rank_field = self.default_order_field
286 |
287 | if endorder < startorder: # Drag up
288 | move_filter = {
289 | f'{rank_field}__gte': endorder,
290 | f'{rank_field}__lte': startorder - 1,
291 | }
292 | move_delta = +1
293 | order_by = f'-{rank_field}'
294 | elif endorder > startorder: # Drag down
295 | move_filter = {
296 | f'{rank_field}__gte': startorder + 1,
297 | f'{rank_field}__lte': endorder,
298 | }
299 | move_delta = -1
300 | order_by = rank_field
301 | else:
302 | return model.objects.none()
303 |
304 | obj_filters = {rank_field: startorder}
305 | if extra_model_filters is not None:
306 | obj_filters.update(extra_model_filters)
307 | move_filter.update(extra_model_filters)
308 |
309 | with transaction.atomic():
310 | try:
311 | obj = model.objects.select_for_update().get(**obj_filters)
312 | except model.MultipleObjectsReturned:
313 |
314 | # noinspection PyProtectedMember
315 | raise model.MultipleObjectsReturned(
316 | f"Detected non-unique values in field '{rank_field}' used for sorting this model.\n"
317 | f"Consider to run \n python manage.py reorder {model._meta.label}\n"
318 | "to adjust this inconsistency."
319 | )
320 |
321 | move_qs = model.objects.select_for_update().filter(**move_filter).order_by(order_by)
322 | move_objs = list(move_qs)
323 | for instance in move_objs:
324 | setattr(
325 | instance, rank_field,
326 | getattr(instance, rank_field) + move_delta
327 | )
328 | # Do not run `instance.save()`, because it will be updated
329 | # later in bulk by `move_qs.update`.
330 | pre_save.send(
331 | model,
332 | instance=instance,
333 | update_fields=[rank_field],
334 | raw=False,
335 | using=router.db_for_write(model, instance=instance),
336 | )
337 | move_qs.update(**{rank_field: F(rank_field) + move_delta})
338 | for instance in move_objs:
339 | post_save.send(
340 | model,
341 | instance=instance,
342 | update_fields=[rank_field],
343 | raw=False,
344 | using=router.db_for_write(model, instance=instance),
345 | created=False,
346 | )
347 |
348 | setattr(obj, rank_field, endorder)
349 | obj.save(update_fields=[rank_field])
350 |
351 | return {instance.pk: getattr(instance, rank_field) for instance in chain(move_objs, [obj])}
352 |
353 | @staticmethod
354 | def get_extra_model_filters(request):
355 | """
356 | Returns additional fields to filter sortable objects
357 | """
358 | return {}
359 |
360 | def get_max_order(self, request, obj=None):
361 | return self.model.objects.aggregate(
362 | max_order=Coalesce(Max(self.default_order_field, output_field=models.IntegerField()), 0),
363 | )['max_order']
364 |
365 | def _bulk_move(self, request, queryset, method):
366 | if not self.enable_sorting:
367 | return
368 | objects = self.model.objects.order_by(self.order_by)
369 | paginator = self.paginator(objects, self.list_per_page)
370 | current_page_number = int(request.GET.get('p', 1))
371 |
372 | if method == self.EXACT:
373 | page_number = int(request.POST.get('page', current_page_number))
374 | target_page_number = page_number
375 | elif method == self.BACK:
376 | step = int(request.POST.get('step', 1))
377 | target_page_number = current_page_number - step
378 | elif method == self.FORWARD:
379 | step = int(request.POST.get('step', 1))
380 | target_page_number = current_page_number + step
381 | elif method == self.FIRST:
382 | target_page_number = 1
383 | elif method == self.LAST:
384 | target_page_number = paginator.num_pages
385 | else:
386 | raise Exception('Invalid method')
387 |
388 | if target_page_number == current_page_number:
389 | # If you want the selected items to be moved to the start of the current page, then just do not return here
390 | return
391 |
392 | try:
393 | page = paginator.page(target_page_number)
394 | except EmptyPage as ex:
395 | self.message_user(request, str(ex), level=messages.ERROR)
396 | return
397 |
398 | queryset_size = queryset.count()
399 | page_size = page.end_index() - page.start_index() + 1
400 | endorders_step = -1 if self.order_by.startswith('-') else 1
401 | if queryset_size > page_size:
402 | # move objects to last and penultimate page
403 | endorders_end = getattr(objects[page.end_index() - 1], self.default_order_field) + endorders_step
404 | endorders = range(
405 | endorders_end - endorders_step * queryset_size,
406 | endorders_end,
407 | endorders_step
408 | )
409 | else:
410 | endorders_start = getattr(objects[page.start_index() - 1], self.default_order_field)
411 | endorders = range(
412 | endorders_start,
413 | endorders_start + endorders_step * queryset_size,
414 | endorders_step
415 | )
416 |
417 | if page.number > current_page_number:
418 | # Move forward
419 | queryset = queryset.reverse()
420 | endorders = reversed(endorders)
421 |
422 | extra_model_filters = self.get_extra_model_filters(request)
423 | for obj, endorder in zip(queryset, endorders):
424 | startorder = getattr(obj, self.default_order_field)
425 | self._move_item(startorder, endorder, extra_model_filters)
426 |
427 | def changelist_view(self, request, extra_context=None):
428 | extra_context = extra_context or {}
429 | extra_context['sortable_update_url'] = self.get_update_url(request)
430 | extra_context['base_change_list_template'] = super().change_list_template or 'admin/change_list.html'
431 | return super().changelist_view(request, extra_context)
432 |
433 | def get_update_url(self, request):
434 | """
435 | Returns a callback URL used for updating items via AJAX drag-n-drop
436 | """
437 | return reverse(f'{self.admin_site.name}:{self._get_update_url_name()}')
438 |
439 |
440 | class PolymorphicSortableAdminMixin(SortableAdminMixin):
441 | """
442 | If the admin class is used for a polymorphic model, hence inherits from ``PolymorphicParentModelAdmin``
443 | rather than ``admin.ModelAdmin``, then additionally inherit from ``PolymorphicSortableAdminMixin``
444 | rather than ``SortableAdminMixin``.
445 | """
446 | def get_max_order(self, request, obj=None):
447 | return self.base_model.objects.aggregate(
448 | max_order=Coalesce(Max(self.default_order_field, output_field=IntegerField), 0),
449 | output_field=IntegerField,
450 | )['max_order']
451 |
452 |
453 | class CustomInlineFormSetMixin:
454 | def __init__(self, default_order_direction=None, default_order_field=None, **kwargs):
455 | self.default_order_direction = default_order_direction
456 | self.default_order_field = default_order_field
457 | if default_order_field:
458 | if default_order_field in self.form.base_fields:
459 | order_field = self.form.base_fields[default_order_field]
460 | else:
461 | order_field = self.model._meta.get_field(default_order_field).formfield()
462 | self.form.base_fields[default_order_field] = order_field
463 | self.form.declared_fields[default_order_field] = order_field
464 |
465 | order_field.is_hidden = True
466 | order_field.required = False
467 | order_field.widget = widgets.HiddenInput(attrs={'class': '_reorder_'})
468 |
469 | super().__init__(**kwargs)
470 |
471 | def get_max_order(self):
472 | query_set = self.model.objects.filter(
473 | **{self.fk.get_attname(): self.instance.pk}
474 | )
475 | return query_set.aggregate(
476 | max_order=Coalesce(Max(self.default_order_field), 0)
477 | )['max_order']
478 |
479 | def save_new(self, form, commit=True):
480 | """
481 | New objects do not have a valid value in their ordering field.
482 | On object save, add an order bigger than all other order fields
483 | for the current parent_model.
484 | Strange behaviour when field has a default, this might be evaluated
485 | on new object and the value will be not None, but the default value.
486 | """
487 | obj = super().save_new(form, commit=False)
488 |
489 | order_field_value = getattr(obj, self.default_order_field, None)
490 | if order_field_value is None or order_field_value <= 0:
491 | max_order = self.get_max_order()
492 | setattr(obj, self.default_order_field, max_order + 1)
493 | if commit:
494 | obj.save()
495 | # form.save_m2m() can be called via the formset later on
496 | # if commit=False
497 | if commit and hasattr(form, 'save_m2m'):
498 | form.save_m2m()
499 | return obj
500 |
501 |
502 | class CustomInlineFormSet(CustomInlineFormSetMixin, BaseInlineFormSet):
503 | pass
504 |
505 |
506 | class SortableInlineAdminMixin:
507 | formset = CustomInlineFormSet
508 |
509 | def __init__(self, parent_model, admin_site):
510 | if parent_model in admin_site._registry:
511 | assert isinstance(admin_site._registry[parent_model], SortableAdminBase), \
512 | "{} must inherit from SortableAdminBase since {} inherits from SortableInlineAdminMixin.".format(
513 | admin_site._registry[parent_model], self.__class__.__name__
514 | )
515 | self.default_order_direction, self.default_order_field = _get_default_ordering(self.model, self)
516 | super().__init__(parent_model, admin_site)
517 |
518 | def get_fields(self, *args, **kwargs):
519 | fields = list(super().get_fields(*args, **kwargs))
520 | if self.default_order_field not in fields:
521 | fields.append(self.default_order_field)
522 | return fields
523 |
524 |
525 | class SortableStackedInline(SortableInlineAdminMixin, admin.StackedInline):
526 | template = 'adminsortable2/edit_inline/stacked-django-{0}.{1}.html'.format(*DJANGO_VERSION)
527 |
528 |
529 | class SortableTabularInline(SortableInlineAdminMixin, admin.TabularInline):
530 | template = 'adminsortable2/edit_inline/tabular-django-{0}.{1}.html'.format(*DJANGO_VERSION)
531 |
532 |
533 | class CustomGenericInlineFormSet(CustomInlineFormSetMixin, BaseGenericInlineFormSet):
534 | def get_max_order(self):
535 | query_set = self.model.objects.filter(
536 | **{
537 | self.ct_fk_field.name: self.instance.pk,
538 | self.ct_field.name: ContentType.objects.get_for_model(
539 | self.instance,
540 | for_concrete_model=self.for_concrete_model
541 | )
542 | }
543 | )
544 | return query_set.aggregate(
545 | max_order=Coalesce(Max(self.default_order_field), 0)
546 | )['max_order']
547 |
548 |
549 | class SortableGenericInlineAdminMixin(SortableInlineAdminMixin):
550 | formset = CustomGenericInlineFormSet
551 |
--------------------------------------------------------------------------------
/adminsortable2/locale/de/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/django-admin-sortable2/be156d89f2dc8418393c8efa56895547f4b469a9/adminsortable2/locale/de/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/adminsortable2/locale/de/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: \n"
9 | "Report-Msgid-Bugs-To: \n"
10 | "POT-Creation-Date: 2022-07-04 08:34+0200\n"
11 | "PO-Revision-Date: 2015-09-25 13:29+0200\n"
12 | "Last-Translator: \n"
13 | "Language-Team: \n"
14 | "Language: de\n"
15 | "MIME-Version: 1.0\n"
16 | "Content-Type: text/plain; charset=UTF-8\n"
17 | "Content-Transfer-Encoding: 8bit\n"
18 | "X-Generator: Poedit 1.8.4\n"
19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n"
20 |
21 | #: admin.py:208
22 | msgid "Sort"
23 | msgstr "Sortieren"
24 |
25 | #: admin.py:243
26 | msgid "Move selected to specific page"
27 | msgstr "Auswahl auf bestimmte Seite verschieben"
28 |
29 | #: admin.py:247
30 | msgid "Move selected ... pages back"
31 | msgstr "Auswahl um ... Seiten rückwärts verschieben"
32 |
33 | #: admin.py:251
34 | msgid "Move selected ... pages forward"
35 | msgstr "Auswahl um ... Seiten vorwärts verschieben"
36 |
37 | #: admin.py:255
38 | msgid "Move selected to first page"
39 | msgstr "Auswahl zur ersten Seite verschieben"
40 |
41 | #: admin.py:259
42 | msgid "Move selected to last page"
43 | msgstr "Auswahl zur letzten Seite verschieben"
44 |
45 | #: templates/adminsortable2/edit_inline/stacked-django-4.0.html:16
46 | #: templates/adminsortable2/edit_inline/stacked-django-4.1.html:16
47 | #: templates/adminsortable2/edit_inline/tabular-django-4.0.html:36
48 | #: templates/adminsortable2/edit_inline/tabular-django-4.1.html:36
49 | msgid "Change"
50 | msgstr "Ändern"
51 |
52 | #: templates/adminsortable2/edit_inline/stacked-django-4.0.html:16
53 | #: templates/adminsortable2/edit_inline/stacked-django-4.1.html:16
54 | #: templates/adminsortable2/edit_inline/tabular-django-4.0.html:36
55 | #: templates/adminsortable2/edit_inline/tabular-django-4.1.html:36
56 | msgid "View"
57 | msgstr "Ansehen"
58 |
59 | #: templates/adminsortable2/edit_inline/stacked-django-4.0.html:18
60 | #: templates/adminsortable2/edit_inline/stacked-django-4.1.html:18
61 | #: templates/adminsortable2/edit_inline/tabular-django-4.0.html:38
62 | #: templates/adminsortable2/edit_inline/tabular-django-4.1.html:38
63 | msgid "View on site"
64 | msgstr "Auf der Webseite anzeigen"
65 |
66 | #: templates/adminsortable2/edit_inline/stacked-django-4.0.html:20
67 | #: templates/adminsortable2/edit_inline/stacked-django-4.1.html:20
68 | #: templates/adminsortable2/edit_inline/tabular-django-4.0.html:39
69 | #: templates/adminsortable2/edit_inline/tabular-django-4.1.html:39
70 | msgid "Move to first position"
71 | msgstr "An erste Stelle verschieben"
72 |
73 | #: templates/adminsortable2/edit_inline/stacked-django-4.0.html:20
74 | #: templates/adminsortable2/edit_inline/stacked-django-4.1.html:20
75 | #: templates/adminsortable2/edit_inline/tabular-django-4.0.html:39
76 | #: templates/adminsortable2/edit_inline/tabular-django-4.1.html:39
77 | msgid "Move to last position"
78 | msgstr "An letzte Stelle verschieben"
79 |
80 | #: templates/adminsortable2/edit_inline/tabular-django-4.0.html:22
81 | #: templates/adminsortable2/edit_inline/tabular-django-4.1.html:22
82 | msgid "Delete?"
83 | msgstr "Löschen?"
84 |
--------------------------------------------------------------------------------
/adminsortable2/locale/en/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/django-admin-sortable2/be156d89f2dc8418393c8efa56895547f4b469a9/adminsortable2/locale/en/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/adminsortable2/locale/en/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: PACKAGE VERSION\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2015-11-10 17:40+0100\n"
12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 | "Last-Translator: FULL NAME \n"
14 | "Language-Team: LANGUAGE \n"
15 | "Language: \n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=UTF-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 |
20 | #: admin.py:152 templates/adminsortable2/tabular-1.5.html:9
21 | #: templates/adminsortable2/tabular-1.6.html:9
22 | msgid "Sort"
23 | msgstr ""
24 |
25 | #: admin.py:175
26 | msgid "Move selected to specific page"
27 | msgstr ""
28 |
29 | #: admin.py:179
30 | msgid "Move selected ... pages back"
31 | msgstr ""
32 |
33 | #: admin.py:183
34 | msgid "Move selected ... pages forward"
35 | msgstr ""
36 |
37 | #: admin.py:187
38 | msgid "Move selected to first page"
39 | msgstr ""
40 |
41 | #: admin.py:191
42 | msgid "Move selected to last page"
43 | msgstr ""
44 |
45 | #: templates/adminsortable2/stacked-1.5.html:10
46 | #: templates/adminsortable2/stacked-1.6.html:10
47 | #: templates/adminsortable2/tabular-1.5.html:31
48 | #: templates/adminsortable2/tabular-1.6.html:31
49 | msgid "View on site"
50 | msgstr ""
51 |
52 | #: templates/adminsortable2/stacked-1.5.html:28
53 | #: templates/adminsortable2/stacked-1.6.html:28
54 | #: templates/adminsortable2/tabular-1.5.html:78
55 | #: templates/adminsortable2/tabular-1.6.html:77
56 | msgid "Remove"
57 | msgstr ""
58 |
59 | #: templates/adminsortable2/stacked-1.5.html:29
60 | #: templates/adminsortable2/stacked-1.6.html:29
61 | #: templates/adminsortable2/tabular-1.5.html:77
62 | #: templates/adminsortable2/tabular-1.6.html:76
63 | #, python-format
64 | msgid "Add another %(verbose_name)s"
65 | msgstr ""
66 |
67 | #: templates/adminsortable2/tabular-1.5.html:17
68 | #: templates/adminsortable2/tabular-1.6.html:17
69 | msgid "Delete?"
70 | msgstr ""
71 |
--------------------------------------------------------------------------------
/adminsortable2/locale/es/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/django-admin-sortable2/be156d89f2dc8418393c8efa56895547f4b469a9/adminsortable2/locale/es/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/adminsortable2/locale/es/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: PACKAGE VERSION\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2016-11-07 13:08-0600\n"
12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 | "Last-Translator: FULL NAME \n"
14 | "Language-Team: LANGUAGE \n"
15 | "Language: es\n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=UTF-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n"
20 | #: adminsortable2/admin.py:152
21 | #: adminsortable2/templates/adminsortable2/tabular.html:9
22 | msgid "Sort"
23 | msgstr "Orden"
24 |
25 | #: adminsortable2/admin.py:175
26 | msgid "Move selected to specific page"
27 | msgstr "Mover seleccionado a una página específica"
28 |
29 | #: adminsortable2/admin.py:179
30 | msgid "Move selected ... pages back"
31 | msgstr "Mover seleccionado ... páginas atrás"
32 |
33 | #: adminsortable2/admin.py:183
34 | msgid "Move selected ... pages forward"
35 | msgstr "Mover seleccionado ... páginas adelante"
36 |
37 | #: adminsortable2/admin.py:187
38 | msgid "Move selected to first page"
39 | msgstr "Mover seleccionado a la primera página"
40 |
41 | #: adminsortable2/admin.py:191
42 | msgid "Move selected to last page"
43 | msgstr "Mover seleccionado a la última página"
44 |
45 | #: adminsortable2/templates/adminsortable2/stacked.html:8
46 | #: adminsortable2/templates/adminsortable2/tabular.html:32
47 | msgid "Change"
48 | msgstr "Cambiar"
49 |
50 | #: adminsortable2/templates/adminsortable2/stacked.html:10
51 | #: adminsortable2/templates/adminsortable2/tabular.html:34
52 | msgid "View on site"
53 | msgstr "Ver en el sitio"
54 |
55 | #: adminsortable2/templates/adminsortable2/stacked.html:26
56 | #: adminsortable2/templates/adminsortable2/tabular.html:81
57 | msgid "Remove"
58 | msgstr "Eliminar"
59 |
60 | #: adminsortable2/templates/adminsortable2/stacked.html:27
61 | #: adminsortable2/templates/adminsortable2/tabular.html:80
62 | #, python-format
63 | msgid "Add another %(verbose_name)s"
64 | msgstr "Añadir otro %(verbose_name)s"
65 |
66 | #: adminsortable2/templates/adminsortable2/tabular.html:17
67 | msgid "Delete?"
68 | msgstr "Borrar?"
69 |
--------------------------------------------------------------------------------
/adminsortable2/locale/fa/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/django-admin-sortable2/be156d89f2dc8418393c8efa56895547f4b469a9/adminsortable2/locale/fa/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/adminsortable2/locale/fa/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: PACKAGE VERSION\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2024-09-20 18:53-0500\n"
12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 | "Last-Translator: FULL NAME \n"
14 | "Language-Team: LANGUAGE \n"
15 | "Language: \n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=UTF-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 |
20 | #: admin.py:152 templates/adminsortable2/tabular-1.5.html:9
21 | #: templates/adminsortable2/tabular-1.6.html:9
22 | msgid "Sort"
23 | msgstr "مرتبسازی"
24 |
25 | #: admin.py:175
26 | msgid "Move selected to specific page"
27 | msgstr "انتقال موارد انتخاب شده به صفحه خاص"
28 |
29 | #: admin.py:179
30 | msgid "Move selected ... pages back"
31 | msgstr "انتقال موارد انتخاب شده به صفحه قبل"
32 |
33 | #: admin.py:183
34 | msgid "Move selected ... pages forward"
35 | msgstr "انتقال موارد انتخاب شده به صفحه بعد"
36 |
37 | #: admin.py:187
38 | msgid "Move selected to first page"
39 | msgstr "انتقال موارد انتخاب شده به اولین صفحه"
40 |
41 | #: admin.py:191
42 | msgid "Move selected to last page"
43 | msgstr "انتقال موارد انتخاب شده به آخرین صفحه"
44 |
45 | #: templates/adminsortable2/stacked-1.5.html:10
46 | #: templates/adminsortable2/stacked-1.6.html:10
47 | #: templates/adminsortable2/tabular-1.5.html:31
48 | #: templates/adminsortable2/tabular-1.6.html:31
49 | msgid "View on site"
50 | msgstr "مشاهده در وبگاه"
51 |
52 | #: templates/adminsortable2/stacked-1.5.html:28
53 | #: templates/adminsortable2/stacked-1.6.html:28
54 | #: templates/adminsortable2/tabular-1.5.html:78
55 | #: templates/adminsortable2/tabular-1.6.html:77
56 | msgid "Remove"
57 | msgstr "حذف"
58 |
59 | #: templates/adminsortable2/stacked-1.5.html:29
60 | #: templates/adminsortable2/stacked-1.6.html:29
61 | #: templates/adminsortable2/tabular-1.5.html:77
62 | #: templates/adminsortable2/tabular-1.6.html:76
63 | #, python-format
64 | msgid "Add another %(verbose_name)s"
65 | msgstr "افزودن یک %(verbose_name)s دیگر"
66 |
67 | #: templates/adminsortable2/tabular-1.5.html:17
68 | #: templates/adminsortable2/tabular-1.6.html:17
69 | msgid "Delete?"
70 | msgstr "حذف؟"
71 |
--------------------------------------------------------------------------------
/adminsortable2/locale/fr/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/django-admin-sortable2/be156d89f2dc8418393c8efa56895547f4b469a9/adminsortable2/locale/fr/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/adminsortable2/locale/fr/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | #
5 | # Translators:
6 | # Doryan R , 2019
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: django-admin-sortable2\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2014-06-06 17:55+0200\n"
12 | "PO-Revision-Date: 2019-02-15 13:21+0000\n"
13 | "Last-Translator: Doryan R \n"
14 | "Language-Team: French (http://www.transifex.com/jrief/django-admin-sortable2/language/fr/)\n"
15 | "MIME-Version: 1.0\n"
16 | "Content-Type: text/plain; charset=UTF-8\n"
17 | "Content-Transfer-Encoding: 8bit\n"
18 | "Language: fr\n"
19 | "Plural-Forms: nplurals=2; plural=(n > 1);\n"
20 |
21 | #: admin.py:127 templates/adminsortable/tabular-1.5.html:9
22 | #: templates/adminsortable/tabular-1.6.html:9
23 | msgid "Sort"
24 | msgstr "Trier"
25 |
26 | #: admin.py:151
27 | msgid "Move selected to previous page"
28 | msgstr "Déplacer la sélection vers la page précédente"
29 |
30 | #: admin.py:155
31 | msgid "Move selected to next page"
32 | msgstr "Déplacer la sélection vers la page suivante"
33 |
34 | #: admin.py:159
35 | msgid "Move selected to first page"
36 | msgstr "Déplacer la sélection vers la première page"
37 |
38 | #: admin.py:163
39 | msgid "Move selected to last page"
40 | msgstr "Déplacer la sélection vers la dernière page"
41 |
42 | #: templates/adminsortable/stacked-1.5.html:10
43 | #: templates/adminsortable/stacked-1.6.html:10
44 | #: templates/adminsortable/tabular-1.5.html:31
45 | #: templates/adminsortable/tabular-1.6.html:31
46 | msgid "View on site"
47 | msgstr "Voir sur le site"
48 |
49 | #: templates/adminsortable/stacked-1.5.html:28
50 | #: templates/adminsortable/stacked-1.6.html:28
51 | #: templates/adminsortable/tabular-1.5.html:78
52 | #: templates/adminsortable/tabular-1.6.html:77
53 | msgid "Remove"
54 | msgstr "Retirer"
55 |
56 | #: templates/adminsortable/stacked-1.5.html:29
57 | #: templates/adminsortable/stacked-1.6.html:29
58 | #: templates/adminsortable/tabular-1.5.html:77
59 | #: templates/adminsortable/tabular-1.6.html:76
60 | #, python-format
61 | msgid "Add another %(verbose_name)s"
62 | msgstr "Ajouter un autre %(verbose_name)s"
63 |
64 | #: templates/adminsortable/tabular-1.5.html:17
65 | #: templates/adminsortable/tabular-1.6.html:17
66 | msgid "Delete?"
67 | msgstr "Effacer?"
68 |
--------------------------------------------------------------------------------
/adminsortable2/locale/it/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/django-admin-sortable2/be156d89f2dc8418393c8efa56895547f4b469a9/adminsortable2/locale/it/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/adminsortable2/locale/it/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: \n"
9 | "Report-Msgid-Bugs-To: \n"
10 | "POT-Creation-Date: 2020-12-09 21:24+0100\n"
11 | "PO-Revision-Date: 2020-12-09 21:49+0100\n"
12 | "Last-Translator: \n"
13 | "Language-Team: \n"
14 | "Language: it\n"
15 | "MIME-Version: 1.0\n"
16 | "Content-Type: text/plain; charset=UTF-8\n"
17 | "Content-Transfer-Encoding: 8bit\n"
18 | "X-Generator: Poedit 2.3\n"
19 |
20 | #: admin.py templates/adminsortable2/tabular.html
21 | msgid "Sort"
22 | msgstr "Ordinamento"
23 |
24 | #: admin.py
25 | msgid "Move selected to specific page"
26 | msgstr "Spostare selezione alla pagina specifica"
27 |
28 | #: admin.py
29 | msgid "Move selected ... pages back"
30 | msgstr "Spostare selezione alla pagina precedente"
31 |
32 | #: admin.py
33 | msgid "Move selected ... pages forward"
34 | msgstr "Spostare selezione alla pagina successiva"
35 |
36 | #: admin.py
37 | msgid "Move selected to first page"
38 | msgstr "Spostare selezione alla prima pagina"
39 |
40 | #: admin.py
41 | msgid "Move selected to last page"
42 | msgstr "Spostare selezione all'ultima pagina"
43 |
44 | #: admin.py
45 | msgid "The target page size is {}. It is too small for {} items."
46 | msgstr "La dimensione della pagina di destinazione è {}. È troppo piccolo per {} articoli."
47 |
48 | #: templates/adminsortable2/stacked.html templates/adminsortable2/tabular.html
49 | msgid "Change"
50 | msgstr "Modifica"
51 |
52 | #: templates/adminsortable2/stacked.html templates/adminsortable2/tabular.html
53 | msgid "View on site"
54 | msgstr "Vedi sul sito"
55 |
56 | #: templates/adminsortable2/stacked.html templates/adminsortable2/tabular.html
57 | msgid "Remove"
58 | msgstr "Rimuovi"
59 |
60 | #: templates/adminsortable2/stacked.html templates/adminsortable2/tabular.html
61 | #, python-format
62 | msgid "Add another %(verbose_name)s"
63 | msgstr "Aggiungi un altro %(verbose_name)s"
64 |
65 | #: templates/adminsortable2/tabular.html
66 | msgid "Delete?"
67 | msgstr "Eliminare?"
68 |
--------------------------------------------------------------------------------
/adminsortable2/locale/pl/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/django-admin-sortable2/be156d89f2dc8418393c8efa56895547f4b469a9/adminsortable2/locale/pl/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/adminsortable2/locale/pl/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: \n"
9 | "Report-Msgid-Bugs-To: \n"
10 | "POT-Creation-Date: 2016-02-19 08:47+0100\n"
11 | "PO-Revision-Date: 2016-02-19 09:07+0100\n"
12 | "MIME-Version: 1.0\n"
13 | "Content-Type: text/plain; charset=UTF-8\n"
14 | "Content-Transfer-Encoding: 8bit\n"
15 | "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 "
16 | "|| n%100>=20) ? 1 : 2);\n"
17 | "Last-Translator: \n"
18 | "Language-Team: \n"
19 | "Language: pl\n"
20 | "X-Generator: Poedit 1.8.7\n"
21 |
22 | #: .\adminsortable2\admin.py:134
23 | #: .\adminsortable2\templates\adminsortable2\tabular.html:9
24 | msgid "Sort"
25 | msgstr "Sortuj"
26 |
27 | #: .\adminsortable2\admin.py:157
28 | msgid "Move selected to specific page"
29 | msgstr "Przenieś na konkretną stronę"
30 |
31 | #: .\adminsortable2\admin.py:161
32 | msgid "Move selected ... pages back"
33 | msgstr "Przenieś o dowolną liczbę stron do tyłu"
34 |
35 | #: .\adminsortable2\admin.py:165
36 | msgid "Move selected ... pages forward"
37 | msgstr "Przenieś o dowolną liczbę stron do przodu"
38 |
39 | #: .\adminsortable2\admin.py:169
40 | msgid "Move selected to first page"
41 | msgstr "Przenieś na pierwszą stronę"
42 |
43 | #: .\adminsortable2\admin.py:173
44 | msgid "Move selected to last page"
45 | msgstr "Przenieś na ostatnią stronę"
46 |
47 | #: .\adminsortable2\templates\adminsortable2\stacked.html:8
48 | msgid "Change"
49 | msgstr "Zmień"
50 |
51 | #: .\adminsortable2\templates\adminsortable2\stacked.html:10
52 | #: .\adminsortable2\templates\adminsortable2\tabular.html:31
53 | msgid "View on site"
54 | msgstr "Zobacz na stronie"
55 |
56 | #: .\adminsortable2\templates\adminsortable2\stacked.html:27
57 | #: .\adminsortable2\templates\adminsortable2\tabular.html:77
58 | msgid "Remove"
59 | msgstr "Usuń"
60 |
61 | #: .\adminsortable2\templates\adminsortable2\stacked.html:28
62 | #: .\adminsortable2\templates\adminsortable2\tabular.html:76
63 | #, python-format
64 | msgid "Add another %(verbose_name)s"
65 | msgstr "Dodaj %(verbose_name)s"
66 |
67 | #: .\adminsortable2\templates\adminsortable2\tabular.html:17
68 | msgid "Delete?"
69 | msgstr "Usunąć?"
70 |
--------------------------------------------------------------------------------
/adminsortable2/locale/ru/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/django-admin-sortable2/be156d89f2dc8418393c8efa56895547f4b469a9/adminsortable2/locale/ru/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/adminsortable2/locale/ru/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: PACKAGE VERSION\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2015-11-10 17:40+0100\n"
12 | "PO-Revision-Date: 2015-02-16 23:58+0200\n"
13 | "Last-Translator: \n"
14 | "Language-Team: LANGUAGE \n"
15 | "Language: \n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=UTF-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "X-Translated-Using: django-rosetta 0.7.4\n"
20 |
21 | #: admin.py:152 templates/adminsortable2/tabular-1.5.html:9
22 | #: templates/adminsortable2/tabular-1.6.html:9
23 | msgid "Sort"
24 | msgstr "Сортировать"
25 |
26 | #: admin.py:175
27 | #, fuzzy
28 | #| msgid "Move selected to first page"
29 | msgid "Move selected to specific page"
30 | msgstr "Переместить выбранные на первую страницу"
31 |
32 | #: admin.py:179
33 | #, fuzzy
34 | #| msgid "Move selected to next page"
35 | msgid "Move selected ... pages back"
36 | msgstr "Переместить выделенные на следующую страницу"
37 |
38 | #: admin.py:183
39 | #, fuzzy
40 | #| msgid "Move selected to next page"
41 | msgid "Move selected ... pages forward"
42 | msgstr "Переместить выделенные на следующую страницу"
43 |
44 | #: admin.py:187
45 | msgid "Move selected to first page"
46 | msgstr "Переместить выбранные на первую страницу"
47 |
48 | #: admin.py:191
49 | msgid "Move selected to last page"
50 | msgstr "Переместить выделенные на последнюю страницу"
51 |
52 | #: templates/adminsortable2/stacked-1.5.html:10
53 | #: templates/adminsortable2/stacked-1.6.html:10
54 | #: templates/adminsortable2/tabular-1.5.html:31
55 | #: templates/adminsortable2/tabular-1.6.html:31
56 | msgid "View on site"
57 | msgstr "Смотреть на сайте"
58 |
59 | #: templates/adminsortable2/stacked-1.5.html:28
60 | #: templates/adminsortable2/stacked-1.6.html:28
61 | #: templates/adminsortable2/tabular-1.5.html:78
62 | #: templates/adminsortable2/tabular-1.6.html:77
63 | msgid "Remove"
64 | msgstr "Удалить"
65 |
66 | #: templates/adminsortable2/stacked-1.5.html:29
67 | #: templates/adminsortable2/stacked-1.6.html:29
68 | #: templates/adminsortable2/tabular-1.5.html:77
69 | #: templates/adminsortable2/tabular-1.6.html:76
70 | #, python-format
71 | msgid "Add another %(verbose_name)s"
72 | msgstr "Добавить еще %(verbose_name)s"
73 |
74 | #: templates/adminsortable2/tabular-1.5.html:17
75 | #: templates/adminsortable2/tabular-1.6.html:17
76 | msgid "Delete?"
77 | msgstr "Удалить?"
78 |
79 | #~ msgid "Move selected to previous page"
80 | #~ msgstr "Переместить выделенные на предыдущую страницу"
81 |
--------------------------------------------------------------------------------
/adminsortable2/locale/uk/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/django-admin-sortable2/be156d89f2dc8418393c8efa56895547f4b469a9/adminsortable2/locale/uk/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/adminsortable2/locale/uk/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: PACKAGE VERSION\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2015-11-10 17:40+0100\n"
12 | "PO-Revision-Date: 2015-02-16 23:58+0200\n"
13 | "Last-Translator: \n"
14 | "Language-Team: LANGUAGE \n"
15 | "Language: \n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=UTF-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "X-Translated-Using: django-rosetta 0.7.4\n"
20 |
21 | #: admin.py:152 templates/adminsortable2/tabular-1.5.html:9
22 | #: templates/adminsortable2/tabular-1.6.html:9
23 | msgid "Sort"
24 | msgstr "Сортування"
25 |
26 | #: admin.py:175
27 | #, fuzzy
28 | #| msgid "Move selected to first page"
29 | msgid "Move selected to specific page"
30 | msgstr "Перемістити вибрані на першу сторінку"
31 |
32 | #: admin.py:179
33 | #, fuzzy
34 | #| msgid "Move selected to next page"
35 | msgid "Move selected ... pages back"
36 | msgstr "Перемістити виділені на наступну сторінку"
37 |
38 | #: admin.py:183
39 | #, fuzzy
40 | #| msgid "Move selected to next page"
41 | msgid "Move selected ... pages forward"
42 | msgstr "Перемістити виділені на наступну сторінку"
43 |
44 | #: admin.py:187
45 | msgid "Move selected to first page"
46 | msgstr "Перемістити вибрані на першу сторінку"
47 |
48 | #: admin.py:191
49 | msgid "Move selected to last page"
50 | msgstr "Перемістити виділені на останню сторінку"
51 |
52 | #: templates/adminsortable2/stacked-1.5.html:10
53 | #: templates/adminsortable2/stacked-1.6.html:10
54 | #: templates/adminsortable2/tabular-1.5.html:31
55 | #: templates/adminsortable2/tabular-1.6.html:31
56 | msgid "View on site"
57 | msgstr "Переглянути на сайті"
58 |
59 | #: templates/adminsortable2/stacked-1.5.html:28
60 | #: templates/adminsortable2/stacked-1.6.html:28
61 | #: templates/adminsortable2/tabular-1.5.html:78
62 | #: templates/adminsortable2/tabular-1.6.html:77
63 | msgid "Remove"
64 | msgstr "Видалити"
65 |
66 | #: templates/adminsortable2/stacked-1.5.html:29
67 | #: templates/adminsortable2/stacked-1.6.html:29
68 | #: templates/adminsortable2/tabular-1.5.html:77
69 | #: templates/adminsortable2/tabular-1.6.html:76
70 | #, python-format
71 | msgid "Add another %(verbose_name)s"
72 | msgstr "Додати ще %(verbose_name)s"
73 |
74 | #: templates/adminsortable2/tabular-1.5.html:17
75 | #: templates/adminsortable2/tabular-1.6.html:17
76 | msgid "Delete?"
77 | msgstr "Видалити?"
78 |
79 | #~ msgid "Move selected to previous page"
80 | #~ msgstr "Перемістити виділені на попередню сторінку"
81 |
--------------------------------------------------------------------------------
/adminsortable2/management/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/django-admin-sortable2/be156d89f2dc8418393c8efa56895547f4b469a9/adminsortable2/management/__init__.py
--------------------------------------------------------------------------------
/adminsortable2/management/commands/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/django-admin-sortable2/be156d89f2dc8418393c8efa56895547f4b469a9/adminsortable2/management/commands/__init__.py
--------------------------------------------------------------------------------
/adminsortable2/management/commands/reorder.py:
--------------------------------------------------------------------------------
1 | from django.apps import apps
2 | from django.core.management.base import BaseCommand, CommandError
3 |
4 |
5 | class Command(BaseCommand):
6 | args = ''
7 | help = 'Restore the primary ordering fields of a model containing a special ordering field'
8 |
9 | def add_arguments(self, parser):
10 | parser.add_argument('models', nargs='+', type=str)
11 |
12 | def handle(self, *args, **options):
13 | for modelname in options['models']:
14 | try:
15 | app_label, model_name = modelname.rsplit('.', 1)
16 | Model = apps.get_model(app_label, model_name)
17 | except ImportError:
18 | raise CommandError('Unable to load model "%s"' % modelname)
19 |
20 | if not hasattr(Model._meta, 'ordering') or len(Model._meta.ordering) == 0:
21 | raise CommandError(f'Model "{modelname}" does not define field "ordering" in its Meta class')
22 |
23 | orderfield = Model._meta.ordering[0]
24 | if orderfield[0] == '-':
25 | orderfield = orderfield[1:]
26 |
27 | for order, obj in enumerate(Model.objects.iterator(), start=1):
28 | setattr(obj, orderfield, order)
29 | obj.save()
30 |
31 | self.stdout.write(f'Successfully reordered model "{modelname}"')
32 |
--------------------------------------------------------------------------------
/adminsortable2/models.py:
--------------------------------------------------------------------------------
1 | # Django needs this to see it as a project
2 |
--------------------------------------------------------------------------------
/adminsortable2/static/adminsortable2/css/sortable.css:
--------------------------------------------------------------------------------
1 | /* hide action input fields */
2 | #changelist-form-step, #changelist-form-page {
3 | display: none;
4 | margin-left: 0.5em;
5 | }
6 |
7 | /* styles for list view */
8 | #result_list thead th.column-_reorder_ {
9 | width: 50px;
10 | }
11 |
12 | #result_list div.drag {
13 | background: url(../icons/drag.png) repeat 0px 0px;
14 | cursor: no-drop;
15 | opacity: 0.5;
16 | }
17 | #result_list div.drag.handle {
18 | cursor: grab;
19 | opacity: 1;
20 | }
21 | /* styles for tabular inlines */
22 | fieldset.sortable table tr.form-row.has_original td.original p {
23 | cursor: grab;
24 | background-image: url(../icons/drag.png);
25 | background-repeat: repeat;
26 | width: calc(100% - 18px);
27 | }
28 | /* styles for stacked inlines */
29 | fieldset.sortable .inline-related.has_original h3 {
30 | background: url(../icons/drag.png) repeat;
31 | cursor: grab;
32 | }
33 | fieldset.sortable :is(.inline-related, td.original) span.sort {
34 | float: right;
35 | margin-right: 10px;
36 | }
37 | fieldset.sortable :is(.inline-related, td.original) span.sort i {
38 | width: 0;
39 | height: 0;
40 | display: inline-block;
41 | border-style: solid;
42 | margin: 2.5px;
43 | cursor: pointer;
44 | }
45 | fieldset.sortable span.sort i.move-begin {
46 | border-width: 0 7.5px 7.5px 7.5px;
47 | border-color: transparent transparent currentColor transparent;
48 | }
49 | fieldset.sortable span.sort i.move-end {
50 | border-width: 7.5px 7.5px 0 7.5px;
51 | border-color: currentColor transparent transparent transparent;
52 | }
53 |
--------------------------------------------------------------------------------
/adminsortable2/static/adminsortable2/icons/drag.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/django-admin-sortable2/be156d89f2dc8418393c8efa56895547f4b469a9/adminsortable2/static/adminsortable2/icons/drag.png
--------------------------------------------------------------------------------
/adminsortable2/templates/adminsortable2/change_list.html:
--------------------------------------------------------------------------------
1 | {% extends base_change_list_template %}
2 |
3 | {% block extrahead %}
4 | {{ block.super }}
5 |
12 | {% endblock %}
13 |
--------------------------------------------------------------------------------
/client/admin-sortable2.ts:
--------------------------------------------------------------------------------
1 | import Sortable, { MoveEvent, MultiDrag, SortableEvent } from 'sortablejs';
2 |
3 | Sortable.mount(new MultiDrag());
4 |
5 | class ListSortable {
6 | private readonly tableBody: HTMLTableSectionElement;
7 | private readonly config: any;
8 | private readonly sortable: Sortable;
9 | private readonly observer: MutationObserver;
10 | private firstOrder: number | undefined;
11 | private orderDirection: number | undefined;
12 |
13 | constructor(table: HTMLTableElement, config: any) {
14 | this.tableBody = table.querySelector('tbody')!;
15 | this.config = config;
16 | this.sortable = Sortable.create(this.tableBody, {
17 | animation: 150,
18 | handle: '.handle',
19 | draggable: 'tr',
20 | selectedClass: 'selected',
21 | multiDrag: true,
22 | onStart: event => this.onStart(event),
23 | onEnd: event => this.onEnd(event),
24 | });
25 | this.observer = new MutationObserver(mutationsList => this.selectActionChanged(mutationsList));
26 | const tableRows = this.tableBody.querySelectorAll('tr');
27 | tableRows.forEach(tableRow => this.observer.observe(tableRow, {attributes: true}));
28 | }
29 |
30 | private selectActionChanged(mutationsList: Array) {
31 | for (const mutation of mutationsList) {
32 | if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
33 | const tableRow = mutation.target as HTMLTableRowElement;
34 | if (tableRow.classList.contains('selected')) {
35 | Sortable.utils.select(tableRow);
36 | } else {
37 | Sortable.utils.deselect(tableRow);
38 | }
39 | }
40 | }
41 | }
42 |
43 | private async onStart(evt: SortableEvent) {
44 | evt.oldIndex; // element index within parent
45 | const firstOrder = this.tableBody.querySelector('tr:first-child')?.querySelector('.handle')?.getAttribute('order');
46 | const lastOrder = this.tableBody.querySelector('tr:last-child')?.querySelector('.handle')?.getAttribute('order');
47 | if (firstOrder && lastOrder) {
48 | this.firstOrder = parseInt(firstOrder);
49 | this.orderDirection = parseInt(lastOrder) > this.firstOrder ? 1 : -1;
50 | }
51 | }
52 |
53 | private async onEnd(evt: SortableEvent) {
54 | if (typeof evt.newIndex !== 'number' || typeof evt.oldIndex !== 'number'
55 | || typeof this.firstOrder !== 'number'|| typeof this.orderDirection !== 'number'
56 | || !(evt.item instanceof HTMLTableRowElement))
57 | return;
58 |
59 | let firstChild: number, lastChild: number;
60 | if (evt.items.length === 0) {
61 | // single dragged item
62 | if (evt.newIndex < evt.oldIndex) {
63 | // drag up
64 | firstChild = evt.newIndex;
65 | lastChild = evt.oldIndex;
66 | } else if (evt.newIndex > evt.oldIndex) {
67 | // drag down
68 | firstChild = evt.oldIndex;
69 | lastChild = evt.newIndex;
70 | } else {
71 | return;
72 | }
73 | } else {
74 | // multiple dragged items
75 | firstChild = this.tableBody.querySelectorAll('tr').length;
76 | lastChild = 0;
77 | evt.oldIndicies.forEach(item => {
78 | firstChild = Math.min(firstChild, item.index)
79 | lastChild = Math.max(lastChild, item.index)
80 | });
81 | evt.newIndicies.forEach(item => {
82 | firstChild = Math.min(firstChild, item.index)
83 | lastChild = Math.max(lastChild, item.index)
84 | });
85 | }
86 |
87 | const updatedRows = this.tableBody.querySelectorAll(`tr:nth-child(n+${firstChild + 1}):nth-child(-n+${lastChild + 1})`);
88 | if (updatedRows.length === 0)
89 | return;
90 |
91 | let order;
92 | if (firstChild === 0) {
93 | order = this.firstOrder;
94 | } else {
95 | order = this.tableBody.querySelector(`tr:nth-child(${firstChild}) .handle`)?.getAttribute('order');
96 | if (!order)
97 | return;
98 | order = parseInt(order) + this.orderDirection;
99 | }
100 | const updatedItems = new Map();
101 | for (let row of updatedRows) {
102 | const pk = row.querySelector('.handle')?.getAttribute('pk');
103 | if (pk) {
104 | row.querySelector('.handle')?.setAttribute('order', String(order));
105 | updatedItems.set(pk, order);
106 | order += this.orderDirection;
107 | }
108 | }
109 |
110 | const response = await fetch(this.config.update_url, {
111 | method: 'POST',
112 | headers: this.headers,
113 | body: JSON.stringify({
114 | updatedItems: Array.from(updatedItems.entries()),
115 | })
116 | });
117 | if (response.status === 200) {
118 | this.resetActions();
119 | } else {
120 | console.error(`The server responded: ${response.statusText}`);
121 | }
122 | }
123 |
124 | private resetActions() {
125 | // reset default action checkboxes behaviour
126 | if (!window.hasOwnProperty('Actions'))
127 | return;
128 | const actionsEls = this.tableBody.querySelectorAll('tr input.action-select');
129 | actionsEls.forEach(elem => {
130 | const tableRow = elem.closest('tr');
131 | // @ts-ignore
132 | Sortable.utils.deselect(tableRow);
133 | tableRow?.classList.remove('selected');
134 | (elem as HTMLInputElement).checked = false;
135 | });
136 | const elem = document.getElementById('action-toggle');
137 | if (elem instanceof HTMLInputElement) {
138 | elem.checked = false;
139 | }
140 | // @ts-ignore
141 | window.Actions(actionsEls);
142 | }
143 |
144 | public get headers(): Headers {
145 | const headers = new Headers();
146 | headers.append('Accept', 'application/json');
147 | headers.append('Content-Type', 'application/json');
148 |
149 | const inputElement = this.tableBody.closest('form')?.querySelector('input[name="csrfmiddlewaretoken"]') as HTMLInputElement;
150 | if (inputElement) {
151 | headers.append('X-CSRFToken', inputElement.value);
152 | }
153 | return headers;
154 | }
155 | }
156 |
157 |
158 | class ActionForm {
159 | private readonly selectElement: HTMLSelectElement;
160 | private readonly config: any;
161 | private readonly stepInput: HTMLInputElement;
162 | private readonly pageInput: HTMLInputElement;
163 |
164 | constructor(formElement: HTMLElement, config: any) {
165 | formElement.setAttribute('novalidate', 'novalidate');
166 | this.selectElement = formElement.querySelector('select[name="action"]')!;
167 | this.config = config;
168 | this.selectElement.addEventListener('change', () => this.actionChanged());
169 |
170 | this.stepInput = document.getElementById('changelist-form-step') as HTMLInputElement;
171 | this.stepInput.setAttribute('min', '1');
172 | const max = Math.max(this.config.total_pages - this.config.current_page, this.config.current_page);
173 | this.stepInput.setAttribute('max', `${max}`);
174 | this.stepInput.value = '1';
175 |
176 | this.pageInput = document.getElementById('changelist-form-page') as HTMLInputElement;
177 | this.pageInput.setAttribute('min', '1');
178 | this.pageInput.setAttribute('max', `${this.config.total_pages}`);
179 | this.pageInput.value = `${this.config.current_page}`;
180 | }
181 |
182 | private actionChanged() {
183 | this.pageInput.style.display = this.stepInput.style.display = 'none';
184 | switch (this.selectElement?.value) {
185 | case 'move_to_exact_page':
186 | this.pageInput.style.display = 'inline-block';
187 | break;
188 | case 'move_to_forward_page':
189 | this.stepInput.style.display = 'inline-block';
190 | break;
191 | case 'move_to_back_page':
192 | this.stepInput.style.display = 'inline-block';
193 | break;
194 | case 'move_to_first_page':
195 | this.pageInput.value = '1';
196 | break;
197 | case 'move_to_last_page':
198 | this.pageInput.value = `${this.config.total_pages + 1}`;
199 | break;
200 | default:
201 | break;
202 | }
203 | }
204 | }
205 |
206 |
207 | class InlineSortable {
208 | private readonly sortable: Sortable;
209 | private readonly reversed: boolean;
210 | private readonly itemSelectors: string;
211 |
212 | constructor(inlineFieldSet: HTMLFieldSetElement) {
213 | this.reversed = inlineFieldSet.classList.contains('reversed');
214 | const tBody = inlineFieldSet.querySelector('table tbody') as HTMLTableSectionElement;
215 | if (tBody) {
216 | // tabular inline
217 | this.itemSelectors = 'tr.has_original'
218 | this.sortable = Sortable.create(tBody, {
219 | animation: 150,
220 | handle: 'td.original p',
221 | draggable: 'tr',
222 | onEnd: event => this.onEnd(),
223 | onMove: event => this.onMove(event),
224 | });
225 | } else {
226 | // stacked inline
227 | this.itemSelectors = '.inline-related.has_original'
228 | this.sortable = Sortable.create(inlineFieldSet, {
229 | animation: 150,
230 | handle: 'h3',
231 | draggable: '.inline-related.has_original',
232 | onEnd: event => this.onEnd(),
233 | });
234 | }
235 | inlineFieldSet.querySelectorAll('.inline-related .sort i.move-begin').forEach(
236 | elem => elem.addEventListener('click', event => this.move(event.target, 'begin'))
237 | );
238 | inlineFieldSet.querySelectorAll('.inline-related .sort i.move-end').forEach(
239 | elem => elem.addEventListener('click', event => this.move(event.target, 'end'))
240 | );
241 | }
242 |
243 | private onEnd() {
244 | const originals = this.sortable.el.querySelectorAll(this.itemSelectors);
245 | if (this.reversed) {
246 | originals.forEach((element: Element, index: number) => {
247 | const reorderInputElement = element.querySelector('input._reorder_') as HTMLInputElement;
248 | reorderInputElement.value = `${originals.length - index}`;
249 | });
250 | } else {
251 | originals.forEach((element: Element, index: number) => {
252 | const reorderInputElement = element.querySelector('input._reorder_') as HTMLInputElement;
253 | reorderInputElement.value = `${index + 1}`;
254 | });
255 | }
256 | }
257 |
258 | private onMove(event: MoveEvent) {
259 | return event.related.classList.contains("has_original");
260 | }
261 |
262 | private move(target: EventTarget | null, direction: 'begin' | 'end') {
263 | if (!(target instanceof HTMLElement))
264 | return;
265 | const inlineRelated = target.closest(this.itemSelectors);
266 | if (!inlineRelated)
267 | return;
268 | const inlineRelatedList = this.sortable.el.querySelectorAll(this.itemSelectors);
269 | if (inlineRelatedList.length < 2)
270 | return;
271 | if (direction === 'begin') {
272 | inlineRelatedList[0].insertAdjacentElement('beforebegin', inlineRelated);
273 | } else {
274 | inlineRelatedList[inlineRelatedList.length - 1].insertAdjacentElement('afterend', inlineRelated);
275 | }
276 | this.onEnd();
277 | }
278 | }
279 |
280 |
281 | window.addEventListener('load', (event) => {
282 | const configElem = document.getElementById('admin_sortable2_config');
283 | if (configElem instanceof HTMLScriptElement && configElem.textContent) {
284 | const adminSortableConfig = JSON.parse(configElem.textContent);
285 |
286 | const table = document.getElementById('result_list');
287 | if (table instanceof HTMLTableElement) {
288 | new ListSortable(table, adminSortableConfig);
289 | }
290 |
291 | const changelistForm = document.getElementById('changelist-form');
292 | if (changelistForm) {
293 | new ActionForm(changelistForm, adminSortableConfig);
294 | }
295 | }
296 |
297 | for (let inlineFieldSet of document.querySelectorAll('fieldset.sortable')) {
298 | new InlineSortable(inlineFieldSet as HTMLFieldSetElement);
299 | }
300 | });
301 |
--------------------------------------------------------------------------------
/client/build.cjs:
--------------------------------------------------------------------------------
1 | const { build } = require('esbuild');
2 | const buildOptions = require('yargs-parser')(process.argv.slice(2), {
3 | boolean: ['debug', 'sourcemap'],
4 | });
5 |
6 | build({
7 | entryPoints: ['client/admin-sortable2.ts'],
8 | bundle: true,
9 | minify: !buildOptions.debug,
10 | sourcemap: buildOptions.sourcemap,
11 | outfile: 'adminsortable2/static/adminsortable2/js/adminsortable2' + (buildOptions.debug ? '' : '.min') + '.js',
12 | plugins: [],
13 | target: ['es2020', 'chrome84', 'firefox84', 'safari14', 'edge84']
14 | }).catch(() => process.exit(1));
15 |
--------------------------------------------------------------------------------
/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/django-admin-sortable2/be156d89f2dc8418393c8efa56895547f4b469a9/demo.gif
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | PAPER =
8 | BUILDDIR = build
9 |
10 | # User-friendly check for sphinx-build
11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
13 | endif
14 |
15 | # Internal variables.
16 | PAPEROPT_a4 = -D latex_paper_size=a4
17 | PAPEROPT_letter = -D latex_paper_size=letter
18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source
19 | # the i18n builder cannot share the environment and doctrees with the others
20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source
21 |
22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
23 |
24 | help:
25 | @echo "Please use \`make ' where is one of"
26 | @echo " html to make standalone HTML files"
27 | @echo " dirhtml to make HTML files named index.html in directories"
28 | @echo " singlehtml to make a single large HTML file"
29 | @echo " pickle to make pickle files"
30 | @echo " json to make JSON files"
31 | @echo " htmlhelp to make HTML files and a HTML help project"
32 | @echo " qthelp to make HTML files and a qthelp project"
33 | @echo " devhelp to make HTML files and a Devhelp project"
34 | @echo " epub to make an epub"
35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
36 | @echo " latexpdf to make LaTeX files and run them through pdflatex"
37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
38 | @echo " text to make text files"
39 | @echo " man to make manual pages"
40 | @echo " texinfo to make Texinfo files"
41 | @echo " info to make Texinfo files and run them through makeinfo"
42 | @echo " gettext to make PO message catalogs"
43 | @echo " changes to make an overview of all changed/added/deprecated items"
44 | @echo " xml to make Docutils-native XML files"
45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes"
46 | @echo " linkcheck to check all external links for integrity"
47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)"
48 |
49 | clean:
50 | rm -rf $(BUILDDIR)/*
51 |
52 | html:
53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
54 | @echo
55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
56 |
57 | dirhtml:
58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
59 | @echo
60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
61 |
62 | singlehtml:
63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
64 | @echo
65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
66 |
67 | pickle:
68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
69 | @echo
70 | @echo "Build finished; now you can process the pickle files."
71 |
72 | json:
73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
74 | @echo
75 | @echo "Build finished; now you can process the JSON files."
76 |
77 | htmlhelp:
78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
79 | @echo
80 | @echo "Build finished; now you can run HTML Help Workshop with the" \
81 | ".hhp project file in $(BUILDDIR)/htmlhelp."
82 |
83 | qthelp:
84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
85 | @echo
86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \
87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/adminsortable2.qhcp"
89 | @echo "To view the help file:"
90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/adminsortable2.qhc"
91 |
92 | devhelp:
93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
94 | @echo
95 | @echo "Build finished."
96 | @echo "To view the help file:"
97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/adminsortable2"
98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/adminsortable2"
99 | @echo "# devhelp"
100 |
101 | epub:
102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
103 | @echo
104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
105 |
106 | latex:
107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
108 | @echo
109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \
111 | "(use \`make latexpdf' here to do that automatically)."
112 |
113 | latexpdf:
114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
115 | @echo "Running LaTeX files through pdflatex..."
116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf
117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
118 |
119 | latexpdfja:
120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
121 | @echo "Running LaTeX files through platex and dvipdfmx..."
122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
124 |
125 | text:
126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
127 | @echo
128 | @echo "Build finished. The text files are in $(BUILDDIR)/text."
129 |
130 | man:
131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
132 | @echo
133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
134 |
135 | texinfo:
136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
137 | @echo
138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
139 | @echo "Run \`make' in that directory to run these through makeinfo" \
140 | "(use \`make info' here to do that automatically)."
141 |
142 | info:
143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
144 | @echo "Running Texinfo files through makeinfo..."
145 | make -C $(BUILDDIR)/texinfo info
146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
147 |
148 | gettext:
149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
150 | @echo
151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
152 |
153 | changes:
154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
155 | @echo
156 | @echo "The overview file is in $(BUILDDIR)/changes."
157 |
158 | linkcheck:
159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
160 | @echo
161 | @echo "Link check complete; look for any errors in the above output " \
162 | "or in $(BUILDDIR)/linkcheck/output.txt."
163 |
164 | doctest:
165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
166 | @echo "Testing of doctests in the sources finished, look at the " \
167 | "results in $(BUILDDIR)/doctest/output.txt."
168 |
169 | xml:
170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
171 | @echo
172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml."
173 |
174 | pseudoxml:
175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
176 | @echo
177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
178 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | REM Command file for Sphinx documentation
4 |
5 | if "%SPHINXBUILD%" == "" (
6 | set SPHINXBUILD=sphinx-build
7 | )
8 | set BUILDDIR=build
9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source
10 | set I18NSPHINXOPTS=%SPHINXOPTS% source
11 | if NOT "%PAPER%" == "" (
12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
14 | )
15 |
16 | if "%1" == "" goto help
17 |
18 | if "%1" == "help" (
19 | :help
20 | echo.Please use `make ^` where ^ is one of
21 | echo. html to make standalone HTML files
22 | echo. dirhtml to make HTML files named index.html in directories
23 | echo. singlehtml to make a single large HTML file
24 | echo. pickle to make pickle files
25 | echo. json to make JSON files
26 | echo. htmlhelp to make HTML files and a HTML help project
27 | echo. qthelp to make HTML files and a qthelp project
28 | echo. devhelp to make HTML files and a Devhelp project
29 | echo. epub to make an epub
30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
31 | echo. text to make text files
32 | echo. man to make manual pages
33 | echo. texinfo to make Texinfo files
34 | echo. gettext to make PO message catalogs
35 | echo. changes to make an overview over all changed/added/deprecated items
36 | echo. xml to make Docutils-native XML files
37 | echo. pseudoxml to make pseudoxml-XML files for display purposes
38 | echo. linkcheck to check all external links for integrity
39 | echo. doctest to run all doctests embedded in the documentation if enabled
40 | goto end
41 | )
42 |
43 | if "%1" == "clean" (
44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
45 | del /q /s %BUILDDIR%\*
46 | goto end
47 | )
48 |
49 |
50 | %SPHINXBUILD% 2> nul
51 | if errorlevel 9009 (
52 | echo.
53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
54 | echo.installed, then set the SPHINXBUILD environment variable to point
55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
56 | echo.may add the Sphinx directory to PATH.
57 | echo.
58 | echo.If you don't have Sphinx installed, grab it from
59 | echo.http://sphinx-doc.org/
60 | exit /b 1
61 | )
62 |
63 | if "%1" == "html" (
64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
65 | if errorlevel 1 exit /b 1
66 | echo.
67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html.
68 | goto end
69 | )
70 |
71 | if "%1" == "dirhtml" (
72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
73 | if errorlevel 1 exit /b 1
74 | echo.
75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
76 | goto end
77 | )
78 |
79 | if "%1" == "singlehtml" (
80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
81 | if errorlevel 1 exit /b 1
82 | echo.
83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
84 | goto end
85 | )
86 |
87 | if "%1" == "pickle" (
88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
89 | if errorlevel 1 exit /b 1
90 | echo.
91 | echo.Build finished; now you can process the pickle files.
92 | goto end
93 | )
94 |
95 | if "%1" == "json" (
96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
97 | if errorlevel 1 exit /b 1
98 | echo.
99 | echo.Build finished; now you can process the JSON files.
100 | goto end
101 | )
102 |
103 | if "%1" == "htmlhelp" (
104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
105 | if errorlevel 1 exit /b 1
106 | echo.
107 | echo.Build finished; now you can run HTML Help Workshop with the ^
108 | .hhp project file in %BUILDDIR%/htmlhelp.
109 | goto end
110 | )
111 |
112 | if "%1" == "qthelp" (
113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
114 | if errorlevel 1 exit /b 1
115 | echo.
116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^
117 | .qhcp project file in %BUILDDIR%/qthelp, like this:
118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\adminsortable2.qhcp
119 | echo.To view the help file:
120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\adminsortable2.ghc
121 | goto end
122 | )
123 |
124 | if "%1" == "devhelp" (
125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
126 | if errorlevel 1 exit /b 1
127 | echo.
128 | echo.Build finished.
129 | goto end
130 | )
131 |
132 | if "%1" == "epub" (
133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
134 | if errorlevel 1 exit /b 1
135 | echo.
136 | echo.Build finished. The epub file is in %BUILDDIR%/epub.
137 | goto end
138 | )
139 |
140 | if "%1" == "latex" (
141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
142 | if errorlevel 1 exit /b 1
143 | echo.
144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
145 | goto end
146 | )
147 |
148 | if "%1" == "latexpdf" (
149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
150 | cd %BUILDDIR%/latex
151 | make all-pdf
152 | cd %BUILDDIR%/..
153 | echo.
154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex.
155 | goto end
156 | )
157 |
158 | if "%1" == "latexpdfja" (
159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
160 | cd %BUILDDIR%/latex
161 | make all-pdf-ja
162 | cd %BUILDDIR%/..
163 | echo.
164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex.
165 | goto end
166 | )
167 |
168 | if "%1" == "text" (
169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
170 | if errorlevel 1 exit /b 1
171 | echo.
172 | echo.Build finished. The text files are in %BUILDDIR%/text.
173 | goto end
174 | )
175 |
176 | if "%1" == "man" (
177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
178 | if errorlevel 1 exit /b 1
179 | echo.
180 | echo.Build finished. The manual pages are in %BUILDDIR%/man.
181 | goto end
182 | )
183 |
184 | if "%1" == "texinfo" (
185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
186 | if errorlevel 1 exit /b 1
187 | echo.
188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
189 | goto end
190 | )
191 |
192 | if "%1" == "gettext" (
193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
194 | if errorlevel 1 exit /b 1
195 | echo.
196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
197 | goto end
198 | )
199 |
200 | if "%1" == "changes" (
201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
202 | if errorlevel 1 exit /b 1
203 | echo.
204 | echo.The overview file is in %BUILDDIR%/changes.
205 | goto end
206 | )
207 |
208 | if "%1" == "linkcheck" (
209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
210 | if errorlevel 1 exit /b 1
211 | echo.
212 | echo.Link check complete; look for any errors in the above output ^
213 | or in %BUILDDIR%/linkcheck/output.txt.
214 | goto end
215 | )
216 |
217 | if "%1" == "doctest" (
218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
219 | if errorlevel 1 exit /b 1
220 | echo.
221 | echo.Testing of doctests in the sources finished, look at the ^
222 | results in %BUILDDIR%/doctest/output.txt.
223 | goto end
224 | )
225 |
226 | if "%1" == "xml" (
227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml
228 | if errorlevel 1 exit /b 1
229 | echo.
230 | echo.Build finished. The XML files are in %BUILDDIR%/xml.
231 | goto end
232 | )
233 |
234 | if "%1" == "pseudoxml" (
235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml
236 | if errorlevel 1 exit /b 1
237 | echo.
238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml.
239 | goto end
240 | )
241 |
242 | :end
243 |
--------------------------------------------------------------------------------
/docs/source/_static/django-admin-sortable2.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/django-admin-sortable2/be156d89f2dc8418393c8efa56895547f4b469a9/docs/source/_static/django-admin-sortable2.gif
--------------------------------------------------------------------------------
/docs/source/_static/list-view-end.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/django-admin-sortable2/be156d89f2dc8418393c8efa56895547f4b469a9/docs/source/_static/list-view-end.png
--------------------------------------------------------------------------------
/docs/source/_static/list-view.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/django-admin-sortable2/be156d89f2dc8418393c8efa56895547f4b469a9/docs/source/_static/list-view.png
--------------------------------------------------------------------------------
/docs/source/_static/stacked-inline-view.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/django-admin-sortable2/be156d89f2dc8418393c8efa56895547f4b469a9/docs/source/_static/stacked-inline-view.png
--------------------------------------------------------------------------------
/docs/source/_static/tabular-inline-view.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/django-admin-sortable2/be156d89f2dc8418393c8efa56895547f4b469a9/docs/source/_static/tabular-inline-view.png
--------------------------------------------------------------------------------
/docs/source/conf.py:
--------------------------------------------------------------------------------
1 | # Configuration file for the Sphinx documentation builder.
2 | #
3 | # This file only contains a selection of the most common options. For a full
4 | # list see the documentation:
5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html
6 |
7 | # -- Path setup --------------------------------------------------------------
8 |
9 | # If extensions (or modules to document with autodoc) are in another directory,
10 | # add these directories to sys.path here. If the directory is relative to the
11 | # documentation root, use os.path.abspath to make it absolute, like shown here.
12 | #
13 | import datetime
14 | import os
15 | import sys
16 | sys.path.insert(0, os.path.abspath(os.path.join(os.pardir, os.pardir)))
17 | #sys.path.insert(1, os.path.abspath(os.path.join(os.pardir, os.pardir, os.pardir, 'django/docs/_ext')))
18 |
19 | from adminsortable2 import __version__ as release # noqa
20 |
21 |
22 | # -- Project information -----------------------------------------------------
23 |
24 | project = 'django-admin-sortable2'
25 | copyright = datetime.date.today().strftime(' Copyright %Y, Jacob Rief')
26 | author = 'Jacob Rief'
27 |
28 | # -- General configuration ---------------------------------------------------
29 |
30 | # Add any Sphinx extension module names here, as strings. They can be
31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
32 | # ones.
33 | extensions = [
34 | 'sphinx.ext.intersphinx',
35 | ]
36 |
37 | # Add any paths that contain templates here, relative to this directory.
38 | templates_path = ['_templates']
39 |
40 | # List of patterns, relative to source directory, that match files and
41 | # directories to ignore when looking for source files.
42 | # This pattern also affects html_static_path and html_extra_path.
43 | exclude_patterns = []
44 |
45 | # The master toctree document.
46 | master_doc = 'index'
47 |
48 | # -- Options for HTML output -------------------------------------------------
49 |
50 | # The theme to use for HTML and HTML Help pages. See the documentation for
51 | # a list of builtin themes.
52 | #
53 | html_theme = 'alabaster'
54 |
55 | # Add any paths that contain custom static files (such as style sheets) here,
56 | # relative to this directory. They are copied after the builtin static files,
57 | # so a file named "default.css" will overwrite the builtin "default.css".
58 | html_static_path = ['_static']
59 |
60 |
61 | intersphinx_mapping = {
62 | 'django': (
63 | 'https://docs.djangoproject.com/en/stable/',
64 | 'https://docs.djangoproject.com/en/stable/_objects/'
65 | ),
66 | }
67 |
--------------------------------------------------------------------------------
/docs/source/contributing.rst:
--------------------------------------------------------------------------------
1 | .. _contributing:
2 |
3 | ===========================
4 | Contributing to the Project
5 | ===========================
6 |
7 | * Please ask question on the `discussion board`_.
8 | * Ideas for new features also shall be discussed on that board as well.
9 | * The `issue tracker`_ shall *exclusively* be used to report bugs.
10 | * Except for very small fixes (typos etc.), do not open a pull request without an issue.
11 |
12 | .. _discussion board: https://github.com/jrief/django-admin-sortable2/discussions/
13 | .. _issue tracker: https://github.com/jrief/django-admin-sortable2/issues
14 |
15 |
16 | Writing Code
17 | ============
18 |
19 | Before hacking into the code, adopt your IDE to respect the projects's `.editorconfig`_ file.
20 |
21 | When installing from GitHub, you *must* build the JavaScript client using the esbuild_ TypeScript
22 | compiler. Ensure that you have a recent version of NodeJS (18+) installed. Then run the following
23 | commands:
24 |
25 | .. code-block:: shell
26 |
27 | git clone https://github.com/jrief/django-admin-sortable2.git
28 | cd django-admin-sortable2
29 | npm install --include=dev
30 | npm run build
31 |
32 | # for an unminimized version including a sourcemap, run
33 | npm run build -- --debug
34 |
35 | This then builds and bundles the JavaScript file
36 | ``adminsortable2/static/adminsortable2/js/adminsortable2.min.js`` which later on is imported by the
37 | sortable-admin mixin classes. The unminimized version can be imported as
38 | ``adminsortable2/static/adminsortable2/js/adminsortable2.js``
39 |
40 | .. _.editorconfig: https://editorconfig.org/
41 | .. _esbuild: https://esbuild.github.io/
42 |
43 |
44 | Run the Demo App
45 | ================
46 |
47 | **django-admin-sortable2** is shipped with a demo app, which shall be used as a reference when
48 | reporting bugs, proposing new features or to just get a quick first impression of this library.
49 |
50 | Follow these steps to run this demo app. Note that in addition to Python, you also need a recent
51 | version of NodeJS.
52 |
53 | .. code:: bash
54 |
55 | git clone https://github.com/jrief/django-admin-sortable2.git
56 | cd django-admin-sortable2
57 | npm install --include=dev
58 | npm run build
59 | npm run minify
60 | python -m pip install Django
61 | python -m pip install -r testapp/requirements.txt
62 |
63 | # we use the default template files and patch them, rather than using our own modified one
64 | django_version=$(python -c 'from django import VERSION; print("{0}.{1}".format(*VERSION))')
65 | mkdir adminsortable2/templates/adminsortable2/edit_inline
66 | curl --no-progress-meter --output adminsortable2/templates/adminsortable2/edit_inline/stacked-django-$django_version.html https://raw.githubusercontent.com/django/django/stable/$django_version.x/django/contrib/admin/templates/admin/edit_inline/stacked.html
67 | curl --no-progress-meter --output adminsortable2/templates/adminsortable2/edit_inline/tabular-django-$django_version.html https://raw.githubusercontent.com/django/django/stable/$django_version.x/django/contrib/admin/templates/admin/edit_inline/tabular.html
68 | patch -p0 adminsortable2/templates/adminsortable2/edit_inline/stacked-django-$django_version.html patches/stacked-django-4.0.patch
69 | patch -p0 adminsortable2/templates/adminsortable2/edit_inline/tabular-django-$django_version.html patches/tabular-django-4.0.patch
70 |
71 | cd testapp
72 | ./manage.py migrate
73 | ./manage.py loaddata fixtures/data.json
74 | ./manage.py runserver
75 |
76 | Point a browser onto http://localhost:8000/admin/, and go to **Testapp > Books**. There you
77 | can test the full set of features available in this Django app.
78 |
79 | In section **TESTAPP** there are eight entires named "Book". They all manage the same database model
80 | (ie. ``Book``) and only differ in the way their sorting is organized: Somtimes by the Django model,
81 | somtimes by the Django admin class and in both sorting directions.
82 |
83 |
84 | Reporting Bugs
85 | ==============
86 |
87 | For me it often is very difficult to comprehend why this library does not work with *your* project.
88 | Therefore wheneever you want to report a bug, **report it in a way so that I can reproduce it**.
89 |
90 | **Checkout the code, build the client and run the demo** as decribed in the previous section.
91 | Every feature offered by **django-admin-sortable2** is implemented in the demo named ``testapp``.
92 | If you can reproduce the bug there, report it. Otherwise check why your application behaves
93 | differently.
94 |
95 |
96 | Running Tests
97 | =============
98 |
99 | In version 2.0, many unit tests have been replaced by end-to-end tests using Playwright-Python_. In
100 | addition, the Django test runner has been replaced by pytest-django_.
101 |
102 | Follow these steps to run all unit- and end-to-end tests.
103 |
104 | .. code-block:: shell
105 |
106 | git clone https://github.com/jrief/django-admin-sortable2.git
107 | cd django-admin-sortable2
108 | npm install --include=dev
109 | npm run build
110 | python -m pip install Django
111 | python -m pip install -r testapp/requirements.txt
112 | python -m playwright install
113 | python -m playwright install-deps
114 |
115 | # we use the default template files and patch them, rather than using our own modified one
116 | django_version=$(python -c 'from django import VERSION; print("{0}.{1}".format(*VERSION))')
117 | mkdir adminsortable2/templates/adminsortable2/edit_inline
118 | curl --no-progress-meter --output adminsortable2/templates/adminsortable2/edit_inline/stacked-django-$django_version.html https://raw.githubusercontent.com/django/django/stable/$django_version.x/django/contrib/admin/templates/admin/edit_inline/stacked.html
119 | curl --no-progress-meter --output adminsortable2/templates/adminsortable2/edit_inline/tabular-django-$django_version.html https://raw.githubusercontent.com/django/django/stable/$django_version.x/django/contrib/admin/templates/admin/edit_inline/tabular.html
120 | patch -p0 adminsortable2/templates/adminsortable2/edit_inline/stacked-django-$django_version.html patches/stacked-django-4.0.patch
121 | patch -p0 adminsortable2/templates/adminsortable2/edit_inline/tabular-django-$django_version.html patches/tabular-django-4.0.patch
122 |
123 | python -m pytest testapp
124 |
125 | .. _Playwright-Python: https://playwright.dev/python/
126 | .. _pytest-django: https://pytest-django.readthedocs.io/en/latest/
127 |
128 |
129 | Adding new Features
130 | ===================
131 |
132 | If you want to add a new feature to **django-admin-sortable2**, please integrate a demo into the
133 | testing app (ie. ``testapp``). Doing so has two benefits:
134 |
135 | I can understand way better what it does and how that new feature works. This increases the chances
136 | that such a feature is merged.
137 |
138 | You can use that extra code to adopt the test suite.
139 |
140 | *Remember*: For UI-centric applications such as this one, where the client- and server-side are
141 | strongly entangled with each other, I prefer end-to-end tests way more rather than unit tests.
142 | Reason is, that otherwise I would have to mock the interfaces, which itself is error-prone and
143 | additional work.
144 |
145 | *Don't hide yourself*: I will not accept large pull requests from anonymous users, so please publish
146 | an email address in your GitHub's profile. Reason is that when refactoring the code, I must be
147 | able to contact the initial author of a feature not added by myself.
148 |
149 |
150 | Quoting
151 | =======
152 |
153 | Please follow these rules when quoting strings:
154 |
155 | * A string intended to be read by humans shall be quoted using double quotes: `"…"`.
156 | * An internal string, such as dictionary keys, etc. (and thus usually not intended to be read by
157 | humans), shall be quoted using single quotes: `'…'`. This makes it easier to determine if we have
158 | to extra check for wording.
159 |
160 | There is a good reason to follow this rule: Strings intended for humans, sometimes contain
161 | apostrophes, for instance `"This is John's profile"`. By using double quotes, those apostrophes must
162 | not be escaped. On the other side whenever we write HTML, we have to use double quotes for
163 | parameters, for instance `'Click here!'`. By using single quotes,
164 | those double quotes must not be escaped.
165 |
166 |
167 | Lists versus Tuples
168 | ===================
169 |
170 | Unfortunately in Django, `we developers far too often`_ intermixed lists and tuples without being
171 | aware of their intention. Therefore please follow this rule:
172 |
173 | Always use lists, if there is a theoretical possibility that someday, someone might add another
174 | item. Therefore ``list_display``, ``list_display_links``, ``fields``, etc. must always be lists.
175 |
176 | Always use tuples, if the number of items is restricted by nature, and there isn't even a
177 | theoretical possibility of being extended.
178 |
179 | Example:
180 |
181 | .. code-block:: python
182 |
183 | color = ChoiceField(
184 | label="Color",
185 | choices=[('ff0000', "Red"), ('00ff00', "Green"), ('0000ff', "Blue")],
186 | )
187 |
188 | A ``ChoiceField`` must provide a list of choices. Attribute ``choices`` must be a list because
189 | it is eligible for extension. Its inner items however must be tuples, because they can exlusively
190 | containin the choice value and a human readable label. Here we also intermix single with double
191 | quotes to distinguish strings intended to be read by the machine versus a human.
192 |
193 | .. _we developers far too often: https://groups.google.com/g/django-developers/c/h4FSYWzMJhs
194 |
--------------------------------------------------------------------------------
/docs/source/index.rst:
--------------------------------------------------------------------------------
1 | .. adminsortable2 documentation master file
2 |
3 | ======================
4 | django-admin-sortable2
5 | ======================
6 |
7 | is a generic drag-and-drop ordering package to sort objects in the list- and detail inline-views
8 | of the Django admin interface. This package offers simple mixin classes which enriches the
9 | functionality of *any* existing class derived from ``admin.ModelAdmin``, ``admin.StackedInline``
10 | or ``admin.TabularInline``. It thus makes it very easy to integrate with existing models and their
11 | model admin interfaces.
12 |
13 | Project home: https://github.com/jrief/django-admin-sortable2
14 |
15 | .. image:: _static/django-admin-sortable2.gif
16 | :width: 800
17 | :alt: django-admin-sortable2 demo
18 |
19 |
20 | Features
21 | ========
22 |
23 | Must not inherit from any special Model base class
24 | --------------------------------------------------
25 |
26 | Other plugins offering functionality to make list views for the Django admin interface sortable,
27 | offer a base class to be used instead of ``models.Model``. This class then contains a hard coded
28 | position field, additional methods, and meta directives.
29 |
30 | By using a mixin to enrich an existing class with sorting, we can integrate this Django-app into
31 | existing projects with minimal modification to the code base.
32 |
33 |
34 | Intuitive List View
35 | -------------------
36 |
37 | By adding a draggable area into one of the columns of the Django admin's list view, sorting rows
38 | becomes very intuitive. Alternatively, rows can be selected using the checkbox and sorted as a
39 | group.
40 |
41 | If rows have to be sorted accross pages, they can be selected using the checkbox and moved to any
42 | other page using an `Admin action`_.
43 |
44 | .. _Admin action: https://docs.djangoproject.com/en/stable/ref/contrib/admin/actions/
45 |
46 |
47 | Support for Stacked- and Tabular Inlines
48 | ----------------------------------------
49 |
50 | If a Django admin view uses `InlineModelAdmin objects`_, and the related model provides an
51 | ordering field, then those inline models can be sorted in the detail view.
52 |
53 | .. _InlineModelAdmin objects: https://docs.djangoproject.com/en/stable/ref/contrib/admin/#inlinemodeladmin-objects
54 |
55 |
56 | Contents:
57 | =========
58 | .. toctree::
59 |
60 | installation
61 | usage
62 | contributing
63 |
64 |
65 | License
66 | =======
67 |
68 | Copyright Jacob Rief and contributors.
69 |
70 | Licensed under the terms of the MIT license.
71 |
72 |
73 | Some Related projects
74 | =====================
75 |
76 | * https://github.com/jazzband/django-admin-sortable
77 | * https://github.com/mtigas/django-orderable
78 | * https://djangosnippets.org/snippets/2057/
79 | * https://djangosnippets.org/snippets/2306/
80 |
--------------------------------------------------------------------------------
/docs/source/installation.rst:
--------------------------------------------------------------------------------
1 | .. _installation:
2 |
3 | ============
4 | Installation
5 | ============
6 |
7 | Install **django-admin-sortable2**. The latest stable release is available on PyPI
8 |
9 | .. code-block:: bash
10 |
11 | pip install django-admin-sortable2
12 |
13 |
14 | Upgrading from version 1
15 | ========================
16 |
17 | When upgrading from version 1, check for ``StackedInline``- and ``TabularInline``-classes inheriting
18 | from ``SortableInlineAdminMixin``. If they do, check the class inheriting from ``ModelAdmin`` and
19 | using this inline-admin class. Since version 2, this class then also has to inherit from
20 | ``SortableAdminBase`` or a class derived of thereof.
21 |
22 |
23 | Configuration
24 | =============
25 |
26 | In the project's ``settings.py`` file add ``'adminsortable2'`` to the list of ``INSTALLED_APPS``:
27 |
28 | .. code-block:: python
29 |
30 | INSTALLED_APPS = [
31 | 'django.contrib.auth',
32 | 'django.contrib.contenttypes',
33 | 'django.contrib.sessions',
34 | 'django.contrib.admin',
35 | 'django.contrib.staticfiles',
36 | 'django.contrib.messages',
37 | ...
38 | 'adminsortable2',
39 | ...
40 | ]
41 |
42 | The next step is to adopt the models to make them sortable. Please check the page :ref:`usage` for
43 | details.
44 |
--------------------------------------------------------------------------------
/docs/source/usage.rst:
--------------------------------------------------------------------------------
1 | .. _usage:
2 |
3 | ====================
4 | Using Admin Sortable
5 | ====================
6 |
7 | This library tries to not interfere with existing Django models. Instead it adopts any class
8 | inheriting from :class:`~django.db.models.Model`, provided it offers a field for sorting.
9 |
10 |
11 | Prepare the Model Classes
12 | =========================
13 |
14 | Each database model which shall be sortable, requires a position value in its model description.
15 | Rather than defining a base class, which contains such a positional value in a hard coded field,
16 | this library lets you reuse existing sort fields or define a new field for the sort value.
17 |
18 | Therefore this module can be applied in situations where your model inherits from an existing
19 | abstract model which already contains any kind of position value. The only requirement is, that this
20 | position value be specified as the primary field used for sorting. This in Django is declared
21 | through the model's ``Meta`` class. Here's an example ``models.py``:
22 |
23 | .. code:: python
24 |
25 | class SortableBook(models.Model):
26 | title = models.CharField(
27 | "Title",
28 | max_length=255,
29 | )
30 |
31 | my_order = models.PositiveIntegerField(
32 | default=0,
33 | blank=False,
34 | null=False,
35 | )
36 |
37 | class Meta:
38 | ordering = ['my_order']
39 |
40 | Here the ordering field is named ``my_order``, but any valid Python variable name will work. There
41 | are some constraints though:
42 |
43 | * ``my_order`` is the first field in the ``ordering`` list (or tuple) of the model's ``Meta`` class.
44 | Alternatively the ordering can be specified inside the class inheriting from ``ModelAdmin``
45 | registered for this this model.
46 | * ``my_order`` shall be indexed for performance reasons, so add the attribute ``db_index=True`` to
47 | the field's definition.
48 | * ``my_order``'s default value must be 0. The JavaScript which performs the sorting is 1-indexed,
49 | so this will not interfere with the order of the items, even if they are already using 0-indexed
50 | ordering fields.
51 | * The ``my_order`` field must be editable, so make sure that it **does not** contain attribute
52 | ``editable=False``.
53 |
54 | The field used to store the ordering position may be any kind of numeric model field offered by
55 | Django. Use one of these models fields:
56 |
57 | * :class:`~django.db.models.BigIntegerField`
58 | * :class:`~django.db.models.IntegerField`
59 | * :class:`~django.db.models.PositiveIntegerField` (recommended)
60 | * :class:`~django.db.models.PositiveSmallIntegerField` (recommended for small sets)
61 | * :class:`~django.db.models.SmallIntegerField`
62 |
63 | In addition to the recommended fields, :class:`~django.db.models.DecimalField` or
64 | :class:`~django.db.models.FloatField` may work, but haven't been tested.
65 |
66 | .. warning:: Do not make this field unique! See below why.
67 |
68 |
69 | In Django's Admin, make the List View sortable
70 | ==============================================
71 |
72 | If a models contains an ordering field, all we need to make the Django's Admin List View sortable,
73 | is adding the mixin class :class:`adminsortable2.admin.SortableAdminMixin`. By inheriting from this
74 | mixin class together with :class:`~django.contrib.admin.ModelAdmin`, we get a list view which
75 | without any further configuration, owns the functionality to sort its items.
76 |
77 | Using the above ``SortableBook`` model, we can register a default admin interface using
78 |
79 | .. code-block:: python
80 |
81 | from django.contrib import admin
82 | from adminsortable2.admin import SortableAdminMixin
83 |
84 | from myapp.models import SortableBook
85 |
86 | @admin.register(SortableBook)
87 | class SortableBookAdmin(SortableAdminMixin, admin.ModelAdmin):
88 | pass
89 |
90 | This creates a list view with a drag area for each item. By dragging and dropping those items, one
91 | can resort the items in the database.
92 |
93 | .. image:: _static/list-view.png
94 | :width: 800
95 | :alt: Sortable List View
96 |
97 | It also is possible to move more than one item at a time. Simply select them using the action
98 | checkboxes on the left hand side and move all selected row to a new position.
99 |
100 | If the list view is subdivided into more than one page, and items shall be moved to another page,
101 | simply select them using the action checkboxes on the left hand side and from the pull down menu
102 | named **Action**, choose onto which page the selected items shall be moved.
103 |
104 | .. note:: In the list view, the ordering field is updated immediatly inside the database.
105 |
106 | In case the model does not specify a default ordering field in its ``Meta`` class, it also is
107 | possible to specify that field inside the ``ModelAdmin`` class. The above definition then can be
108 | rewritten as:
109 |
110 | .. code-block:: python
111 |
112 | @admin.register(SortableBook)
113 | class SortableBookAdmin(SortableAdminMixin, admin.ModelAdmin):
114 | ordering = ['my_order']
115 |
116 | By default, the draggable area is positioned on the first column. If it shall be placed somewhere
117 | else, add ``list_display`` to ``SortableBookAdmin`` containing the field names of the columns to be
118 | rendered in the model's list view. Redefining the above class as:
119 |
120 | .. code-block:: python
121 |
122 | @admin.register(SortableBook)
123 | class SortableBookAdmin(SortableAdminMixin, admin.ModelAdmin):
124 | list_display = ['title', 'author', 'my_order']
125 |
126 | will render the list view as:
127 |
128 | .. image:: _static/list-view-end.png
129 | :width: 800
130 | :alt: Sortable List View
131 |
132 |
133 | In Django's Admin Detail View, make Stacked- and Tabular-Inlines sortable
134 | =========================================================================
135 |
136 | If a model on the same page has a parent model, these are called inlines. Suppose we have a sortable
137 | model for chapters and want to edit the chapter together with the book's title using the same
138 | editor, then Django admin offers the classes :class:`~django.contrib.admin.StackedInline` and
139 | :class:`~django.contrib.admin.TabularInline`. To make these inline admin interfaces sortable,
140 | we simple use the mixin class :class:`adminsortable2.admin.SortableAdminMixin`.
141 |
142 | Example:
143 |
144 | .. code-block:: python
145 |
146 | ...
147 | from adminsortable2.admin import SortableStackedInline
148 |
149 | from myapp.models import Chapter
150 |
151 | class ChapterStackedInline(SortableStackedInline):
152 | model = Chapter
153 |
154 | @admin.register(SortableBook)
155 | class SortableBookAdmin(SortableAdminMixin, admin.ModelAdmin):
156 | ...
157 | inlines = [ChapterStackedInline]
158 |
159 | In case model ``Chapter`` shall be sortable, but model ``Book`` doesn't have to, rewrite the above
160 | class as:
161 |
162 | .. code-block:: python
163 |
164 | ...
165 | from adminsortable2.admin import SortableAdminBase
166 |
167 | @admin.register(Book)
168 | class SortableBookAdmin(SortableAdminBase, admin.ModelAdmin):
169 | ...
170 | inlines = [ChapterStackedInline]
171 |
172 | For stacked inlines, the editor for the book's detail view looks like:
173 |
174 | .. image:: _static/stacked-inline-view.png
175 | :width: 800
176 | :alt: Stacked Inline View
177 |
178 | .. note:: Since version 2.1, two buttons have been added to the draggable area above each inline
179 | form. They serve to move that edited item to the begin or end of the list of inlines.
180 |
181 | If we instead want to use the tabluar inline class, then modify the code from above to
182 |
183 | .. code-block:: python
184 |
185 | ...
186 | from adminsortable2.admin import SortableTabularInline
187 |
188 | from myapp.models import Chapter
189 |
190 | class ChapterTabularInline(SortableTabularInline):
191 | model = Chapter
192 |
193 | @admin.register(SortableBook)
194 | class SortableBookAdmin(SortableAdminMixin, admin.ModelAdmin):
195 | ...
196 | inlines = [ChapterTabularInline]
197 |
198 | the editor for the book's detail view then looks like:
199 |
200 | .. image:: _static/tabular-inline-view.png
201 | :width: 800
202 | :alt: Tabluar Inline View
203 |
204 | .. note:: When sorting items in the stacked or tabular inline view, these changes are not updated
205 | immediatly inside the database. Instead the parent model must explicitly be saved.
206 |
207 |
208 | Sortable Many-to-Many Relations with Sortable Inlines
209 | =====================================================
210 | Sortable many to many relations can be achieved by creating a model to act as a juction table and
211 | adding an ordering field. This model can be specified on the ``models.ManyToManyField`` ``through``
212 | parameter that tells the Django ORM to use your juction table instead of creating a default one.
213 | Otherwise, the process is conceptually similar to the above examples.
214 |
215 | For example if you wished to have buttons added to control panel able to be sorted into order via
216 | the Django Admin interface you could do the following. A key feature of this approach is the ability
217 | for the same button to be used on more than one panel.
218 |
219 | Specify a junction model and assign it to the ManyToManyField
220 | -------------------------------------------------------------
221 |
222 | ``models.py``
223 |
224 | .. code:: python
225 |
226 | from django.db.import models
227 |
228 | class Button(models.Model):
229 | """A button"""
230 | name = models.CharField(max_length=64)
231 | button_text = models.CharField(max_length=64)
232 |
233 | class Panel(models.Model):
234 | """A Panel of Buttons - this represents a control panel."""
235 | name = models.CharField(max_length=64)
236 | buttons = models.ManyToManyField(Button, through='PanelButtons')
237 |
238 | class PanelButtons(models.Model):
239 | """This is a junction table model that also stores the button order for a panel."""
240 | panel = models.ForeignKey(Panel)
241 | button = models.ForeignKey(Button)
242 | button_order = models.PositiveIntegerField(default=0)
243 |
244 | class Meta:
245 | ordering = ['button_order']
246 |
247 | Setup the Tabular Inlines to enable Buttons to be sorted in Django Admin
248 | ------------------------------------------------------------------------
249 |
250 | ``admin.py``
251 |
252 | .. code:: python
253 |
254 | from django.contrib import admin
255 | from adminsortable2.admin import SortableInlineAdminMixin, SortableAdminBase
256 | from models import Panel
257 |
258 | class ButtonTabularInline(SortableInlineAdminMixin, admin.TabularInline):
259 | # We don't use the Button model but rather the juction model specified on Panel.
260 | model = Panel.buttons.through
261 |
262 | @admin.register(Panel)
263 | class PanelAdmin(SortableAdminBase, admin.ModelAdmin):
264 | inlines = (ButtonTabularInline,)
265 |
266 |
267 | Initial data
268 | ============
269 |
270 | In case you just changed your model to contain an additional sorting field (e.g. ``my_order``),
271 | which does not yet contain any values, then you **must** set initial ordering values.
272 |
273 | **django-admin-sortable2** is shipped with a management command which can be used to prepopulate
274 | the ordering field:
275 |
276 | .. code:: python
277 |
278 | shell> ./manage.py reorder my_app.ModelOne [my_app.ModelTwo ...]
279 |
280 | If you prefer to do a one-time database migration, just after having added the ordering field
281 | to the model, then create a datamigration.
282 |
283 | .. code:: python
284 |
285 | shell> ./manage.py makemigrations myapp
286 |
287 | this creates **non** empty migration named something like ``migrations/0123_auto_20220331_001.py``.
288 |
289 | Edit the file and add a data migration:
290 |
291 | .. code:: python
292 |
293 | def reorder(apps, schema_editor):
294 | MyModel = apps.get_model("myapp", "MyModel")
295 | for order, item in enumerate(MyModel.objects.all(), 1):
296 | item.my_order = order
297 | item.save(update_fields=['my_order'])
298 |
299 | Now add ``migrations.RunPython(reorder)`` to the list of operations:
300 |
301 | .. code:: python
302 |
303 | class Migration(migrations.Migration):
304 | operations = [
305 | ....
306 | migrations.RunPython(reorder, reverse_code=migrations.RunPython.noop),
307 | ]
308 |
309 | then apply the changes to the database using:
310 |
311 | .. code:: bash
312 |
313 | shell> ./manage.py migrate myapp
314 |
315 | .. note:: If you omit to prepopulate the ordering field with unique values, after adding that field
316 | to an existing model, then attempting to reorder items in the admin interface will fail.
317 |
318 |
319 | Note on unique indices on the ordering field
320 | ============================================
321 |
322 | From a design consideration, one might be tempted to add a unique index on the ordering field. But
323 | in practice this has serious drawbacks:
324 |
325 | MySQL has a feature (or bug?) which requires to use the ``ORDER BY`` clause in bulk updates on
326 | unique fields.
327 |
328 | SQLite has the same bug which is even worse, because it does neither update all the fields in one
329 | transaction, nor does it allow to use the ``ORDER BY`` clause in bulk updates.
330 |
331 | Only PostgreSQL does it "right" in the sense, that it updates all fields in one transaction and
332 | afterwards rebuilds the unique index. Here one can not use the ``ORDER BY`` clause during updates,
333 | which from the point of view for SQL semantics, is senseless anyway.
334 |
335 | See https://code.djangoproject.com/ticket/20708 for details.
336 |
337 | Therefore I strongly advise against setting ``unique=True`` on the position field, unless you want
338 | unportable code, which only works with Postgres databases.
339 |
340 |
341 | Usage in combination with other libraries
342 | =========================================
343 |
344 | When combining **django-admin-sortable2**'s admin classes with those of other libraries, inheritance order is important.
345 |
346 | django-solo
347 | -----------
348 |
349 | When using an admin class that inherits from `django-solo `_'s ``SingletonModelAdmin`` class, the ``SortableAdminBase`` class must be specified first.
350 |
351 | This first example is incorrect, and will cause an exception of ``TypeError: getattr(): attribute name must be string`` when saving inlines within the admin class.
352 |
353 | .. code:: python
354 |
355 | @admin.register(models.Homepage)
356 | class HomepageAdmin(SingletonModelAdmin, SortableAdminBase):
357 | # ...
358 |
359 | The correct inheritance order is:
360 |
361 | .. code:: python
362 |
363 | @admin.register(models.Homepage)
364 | class HomepageAdmin(SortableAdminBase, SingletonModelAdmin):
365 | # ...
366 |
367 | Contributions of other examples to this section are welcome.
368 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "django-admin-sortable2",
3 | "private": true,
4 | "license": "MIT",
5 | "scripts": {
6 | "build": "node client/build.cjs"
7 | },
8 | "devDependencies": {
9 | "@types/sortablejs": "^1.15.8",
10 | "esbuild": "^0.21.2",
11 | "playwright": "^1.51.0",
12 | "sortablejs": "^1.15.2",
13 | "tslib": "^2.6.2",
14 | "typescript": "^4.9.5",
15 | "yargs-parser": "^21.1.1"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/parler_example/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | branch = True
3 | source =
4 | adminsortable2
5 | [report]
6 | precision = 2
7 |
--------------------------------------------------------------------------------
/parler_example/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import os
3 | import sys
4 | import traceback
5 |
6 |
7 | if __name__ == "__main__":
8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings")
9 |
10 | from django.core.management import execute_from_command_line
11 |
12 | try:
13 | execute_from_command_line(sys.argv)
14 | except Exception as exception:
15 | print(exception.__repr__())
16 | traceback.print_exc()
17 | raise exception
18 |
--------------------------------------------------------------------------------
/parler_example/parler_test_app/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/django-admin-sortable2/be156d89f2dc8418393c8efa56895547f4b469a9/parler_example/parler_test_app/__init__.py
--------------------------------------------------------------------------------
/parler_example/parler_test_app/admin.py:
--------------------------------------------------------------------------------
1 |
2 | from adminsortable2.admin import (CustomInlineFormSet, SortableAdminMixin,
3 | SortableInlineAdminMixin)
4 | from django.contrib import admin
5 | from parler.admin import TranslatableAdmin, TranslatableStackedInline
6 | from parler.forms import TranslatableBaseInlineFormSet
7 | from parler_example.parler_test_app.models import Chapter, SortableBook
8 |
9 |
10 | class TranslatableSortableInlineFormSet(CustomInlineFormSet, TranslatableBaseInlineFormSet):
11 | pass
12 |
13 |
14 | class ChapterInline(TranslatableStackedInline, SortableInlineAdminMixin, admin.StackedInline):
15 | model = Chapter
16 | extra = 1
17 |
18 | formset = TranslatableSortableInlineFormSet
19 |
20 | @property
21 | def template(self):
22 | return "adminsortable2/stacked.html"
23 |
24 |
25 | @admin.register(SortableBook)
26 | class SortableBookAdmin(SortableAdminMixin, TranslatableAdmin):
27 | list_per_page = 12
28 | list_display = ("title",)
29 | list_display_links = ("title",)
30 | inlines = (ChapterInline,)
31 |
--------------------------------------------------------------------------------
/parler_example/parler_test_app/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 1.11.9 on 2018-01-12 03:11
2 |
3 | from django.db import migrations, models
4 | import django.db.models.deletion
5 | import parler.models
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | initial = True
11 |
12 | dependencies = [
13 | ]
14 |
15 | operations = [
16 | migrations.CreateModel(
17 | name='Chapter',
18 | fields=[
19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20 | ('my_order', models.PositiveIntegerField()),
21 | ],
22 | options={
23 | 'ordering': ['my_order'],
24 | },
25 | bases=(parler.models.TranslatableModelMixin, models.Model),
26 | ),
27 | migrations.CreateModel(
28 | name='ChapterTranslation',
29 | fields=[
30 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
31 | ('language_code', models.CharField(db_index=True, max_length=15, verbose_name='Language')),
32 | ('title', models.CharField(blank=True, max_length=255, null=True, verbose_name='Title')),
33 | ('master', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='translations', to='parler_test_app.Chapter')),
34 | ],
35 | options={
36 | 'verbose_name': 'chapter Translation',
37 | 'db_table': 'parler_test_app_chapter_translation',
38 | 'db_tablespace': '',
39 | 'managed': True,
40 | 'default_permissions': (),
41 | },
42 | ),
43 | migrations.CreateModel(
44 | name='SortableBook',
45 | fields=[
46 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
47 | ('my_order', models.PositiveIntegerField(default=0)),
48 | ],
49 | options={
50 | 'ordering': ['my_order'],
51 | },
52 | bases=(parler.models.TranslatableModelMixin, models.Model),
53 | ),
54 | migrations.CreateModel(
55 | name='SortableBookTranslation',
56 | fields=[
57 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
58 | ('language_code', models.CharField(db_index=True, max_length=15, verbose_name='Language')),
59 | ('title', models.CharField(blank=True, max_length=255, null=True, verbose_name='Title')),
60 | ('master', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='translations', to='parler_test_app.SortableBook')),
61 | ],
62 | options={
63 | 'verbose_name': 'sortable book Translation',
64 | 'db_table': 'parler_test_app_sortablebook_translation',
65 | 'db_tablespace': '',
66 | 'managed': True,
67 | 'default_permissions': (),
68 | },
69 | ),
70 | migrations.AddField(
71 | model_name='chapter',
72 | name='book',
73 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='parler_test_app.SortableBook'),
74 | ),
75 | migrations.AlterUniqueTogether(
76 | name='sortablebooktranslation',
77 | unique_together=set([('language_code', 'master')]),
78 | ),
79 | migrations.AlterUniqueTogether(
80 | name='chaptertranslation',
81 | unique_together=set([('language_code', 'master')]),
82 | ),
83 | ]
84 |
--------------------------------------------------------------------------------
/parler_example/parler_test_app/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/django-admin-sortable2/be156d89f2dc8418393c8efa56895547f4b469a9/parler_example/parler_test_app/migrations/__init__.py
--------------------------------------------------------------------------------
/parler_example/parler_test_app/models.py:
--------------------------------------------------------------------------------
1 |
2 | from django.db import models
3 | from parler.models import TranslatableModel, TranslatedFields
4 |
5 |
6 | class SortableBook(TranslatableModel):
7 | translations = TranslatedFields(
8 | title = models.CharField('Title', null=True, blank=True, max_length=255)
9 | )
10 | my_order = models.PositiveIntegerField(default=0, blank=False, null=False)
11 |
12 | class Meta(object):
13 | ordering = ['my_order']
14 |
15 | def __str__(self):
16 | return self.safe_translation_getter(
17 | field="title",
18 | language_code=self.language_code,
19 | any_language=True
20 | ) or "no title"
21 |
22 |
23 | class Chapter(TranslatableModel):
24 | translations = TranslatedFields(
25 | title = models.CharField('Title', null=True, blank=True, max_length=255)
26 | )
27 | book = models.ForeignKey(SortableBook, null=True, on_delete=models.CASCADE)
28 | my_order = models.PositiveIntegerField(blank=False, null=False)
29 |
30 | class Meta(object):
31 | ordering = ['my_order']
32 |
33 | def __str__(self):
34 | return self.safe_translation_getter(
35 | field="title",
36 | language_code=self.language_code,
37 | any_language=True
38 | ) or "no title"
39 |
--------------------------------------------------------------------------------
/parler_example/settings.py:
--------------------------------------------------------------------------------
1 | # Django settings for unit test project.
2 |
3 | DEBUG = True
4 |
5 | DATABASES = {
6 | 'default': {
7 | 'ENGINE': 'django.db.backends.sqlite3',
8 | 'NAME': 'database.sqlite',
9 | },
10 | }
11 |
12 | SITE_ID = 1
13 |
14 | ROOT_URLCONF = 'parler_example.urls'
15 |
16 | SECRET_KEY = 'secret'
17 |
18 | # Absolute path to the directory that holds media.
19 | # Example: "/home/media/media.lawrence.com/"
20 | MEDIA_ROOT = ''
21 |
22 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a
23 | # trailing slash if there is a path component (optional in other cases).
24 | # Examples: "http://media.lawrence.com", "http://example.com/media/"
25 | MEDIA_URL = ''
26 |
27 | # Absolute path to the directory that holds static files.
28 | # Example: "/home/media/media.lawrence.com/static/"
29 | STATIC_ROOT = '/home/static/'
30 |
31 | # URL that handles the static files served from STATIC_ROOT.
32 | # Example: "http://media.lawrence.com/static/"
33 | STATIC_URL = '/static/'
34 |
35 |
36 | TEMPLATES = [
37 | {
38 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
39 | 'DIRS': [],
40 | 'APP_DIRS': True,
41 | 'OPTIONS': {
42 | 'context_processors': [
43 | 'django.template.context_processors.debug',
44 | 'django.template.context_processors.request',
45 | 'django.contrib.auth.context_processors.auth',
46 | 'django.contrib.messages.context_processors.messages',
47 | ],
48 | },
49 | },
50 | ]
51 |
52 | INSTALLED_APPS = (
53 | 'django.contrib.auth',
54 | 'django.contrib.contenttypes',
55 | 'django.contrib.sessions',
56 | 'django.contrib.admin',
57 | 'django.contrib.staticfiles',
58 | 'parler', # https://github.com/django-parler/django-parler
59 | 'adminsortable2',
60 | 'parler_example.parler_test_app',
61 | )
62 |
63 | MIDDLEWARE = (
64 | 'django.contrib.sessions.middleware.SessionMiddleware',
65 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
66 | 'django.contrib.messages.middleware.MessageMiddleware',
67 | )
68 |
69 | MIDDLEWARE_CLASSES = MIDDLEWARE
70 |
71 | # Explicitely set the test runner to the new 1.7 version, to silence obnoxious
72 | # 1_6.W001 check
73 | # TEST_RUNNER = 'django.test.runner.DiscoverRunner'
74 |
75 |
76 | # https://docs.djangoproject.com/en/1.11/ref/settings/#language-code
77 |
78 | # Default and fallback language:
79 | LANGUAGE_CODE = "en"
80 |
81 | # http://django-parler.readthedocs.org/en/latest/quickstart.html#configuration
82 | PARLER_LANGUAGES = {
83 | 1: [
84 | {
85 | "name": "German",
86 | "code": "de",
87 | "fallbacks": [LANGUAGE_CODE],
88 | "hide_untranslated": False,
89 | },
90 | {
91 | "name": "English",
92 | "code": "en",
93 | "fallbacks": ["de"],
94 | "hide_untranslated": False,
95 | },
96 | ],
97 | "default": {
98 | "fallbacks": [LANGUAGE_CODE],
99 | "redirect_on_fallback": False,
100 | },
101 | }
102 |
103 |
104 | # https://docs.djangoproject.com/en/1.11/ref/settings/#languages
105 | LANGUAGES = tuple([(d["code"], d["name"]) for d in PARLER_LANGUAGES[1]])
106 |
107 | # http://django-parler.readthedocs.org/en/latest/quickstart.html#configuration
108 | PARLER_DEFAULT_LANGUAGE_CODE = LANGUAGE_CODE
109 |
110 | USE_I18N = True
111 |
112 | USE_L10N = True
113 |
--------------------------------------------------------------------------------
/parler_example/urls.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.urls import path, re_path
3 | from django.views.generic import RedirectView
4 |
5 | admin.autodiscover()
6 |
7 | urlpatterns = [
8 | path('admin/', admin.site.urls),
9 | re_path(r'^.*$', RedirectView.as_view(url='/admin/')),
10 | ]
11 |
--------------------------------------------------------------------------------
/patches/stacked-django-4.0.patch:
--------------------------------------------------------------------------------
1 | --- django/contrib/admin/templates/admin/edit_inline/stacked.html 2022-06-07 18:13:55.000000000 +0200
2 | +++ adminsortable2/templates/adminsortable2/edit_inline/stacked.html 2022-06-10 09:55:47.000000000 +0200
3 | @@ -17,6 +17,7 @@
4 | {% else %}#{{ forloop.counter }}{% endif %}
5 | {% if inline_admin_form.show_url %}{% translate "View on site" %}{% endif %}
6 | {% if inline_admin_formset.formset.can_delete and inline_admin_formset.has_delete_permission and inline_admin_form.original %}{{ inline_admin_form.deletion_field.field }} {{ inline_admin_form.deletion_field.label_tag }}{% endif %}
7 | + {% if inline_admin_formset.has_change_permission and inline_admin_form.original %}{% endif %}
8 |
9 | {% if inline_admin_form.form.non_field_errors %}{{ inline_admin_form.form.non_field_errors }}{% endif %}
10 | {% for fieldset in inline_admin_form %}
11 |
--------------------------------------------------------------------------------
/patches/tabular-django-4.0.patch:
--------------------------------------------------------------------------------
1 | --- django/contrib/admin/templates/admin/edit_inline/tabular.html 2022-06-07 18:13:55.000000000 +0200
2 | +++ adminsortable2/templates/adminsortable2/edit_inline/tabular.html 2022-06-16 10:59:26.000000000 +0200
3 | @@ -36,6 +36,7 @@
4 | {% if inline_admin_form.model_admin.show_change_link and inline_admin_form.model_admin.has_registered_model %}{% if inline_admin_formset.has_change_permission %}{% translate "Change" %}{% else %}{% translate "View" %}{% endif %}{% endif %}
5 | {% endif %}
6 | {% if inline_admin_form.show_url %}{% translate "View on site" %}{% endif %}
7 | +
8 | {% endif %}
9 | {% if inline_admin_form.needs_explicit_pk_field %}{{ inline_admin_form.pk_field.field }}{% endif %}
10 | {% if inline_admin_form.fk_field %}{{ inline_admin_form.fk_field.field }}{% endif %}
11 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import io
3 | from setuptools import setup, find_namespace_packages
4 | from adminsortable2 import __version__
5 |
6 |
7 | def readfile(filename):
8 | with io.open(filename, encoding='utf-8') as fd:
9 | return fd.read()
10 |
11 |
12 | DESCRIPTION = 'Generic drag-and-drop sorting for the List, the Stacked- and the Tabular-Inlines Views in the Django Admin'
13 |
14 | CLASSIFIERS = [
15 | 'Environment :: Web Environment',
16 | 'Intended Audience :: Developers',
17 | 'License :: OSI Approved :: MIT License',
18 | 'Operating System :: OS Independent',
19 | 'Programming Language :: Python',
20 | 'Topic :: Software Development :: Libraries :: Python Modules',
21 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
22 | 'Topic :: Software Development :: Libraries :: Application Frameworks',
23 | 'Development Status :: 5 - Production/Stable',
24 | 'Programming Language :: Python :: 3.9',
25 | 'Programming Language :: Python :: 3.10',
26 | 'Programming Language :: Python :: 3.11',
27 | 'Programming Language :: Python :: 3.12',
28 | 'Programming Language :: Python :: 3.13',
29 | 'Framework :: Django',
30 | 'Framework :: Django :: 4.2',
31 | 'Framework :: Django :: 5.0',
32 | 'Framework :: Django :: 5.1',
33 | 'Framework :: Django :: 5.2',
34 | ]
35 |
36 |
37 | setup(
38 | name='django-admin-sortable2',
39 | version=__version__,
40 | author='Jacob Rief',
41 | author_email='jacob.rief@gmail.com',
42 | description=DESCRIPTION,
43 | long_description=readfile('README.md'),
44 | long_description_content_type='text/markdown',
45 | url='https://github.com/jrief/django-admin-sortable2',
46 | license='MIT',
47 | keywords=['django'],
48 | platforms=['OS Independent'],
49 | classifiers=CLASSIFIERS,
50 | install_requires=[
51 | 'Django>=4.2',
52 | ],
53 | packages=find_namespace_packages(include=['adminsortable2']),
54 | include_package_data=True,
55 | zip_safe=False,
56 | )
57 |
--------------------------------------------------------------------------------
/testapp/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/django-admin-sortable2/be156d89f2dc8418393c8efa56895547f4b469a9/testapp/__init__.py
--------------------------------------------------------------------------------
/testapp/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | from adminsortable2.admin import SortableAdminBase, SortableAdminMixin, SortableInlineAdminMixin, SortableStackedInline, SortableTabularInline
4 |
5 | from testapp.models import Author, Chapter, Chapter1, Chapter2, Book, Book1, Book2
6 |
7 |
8 | class AuthorAdmin(admin.ModelAdmin):
9 | list_display = ['name']
10 |
11 |
12 | class ChapterStackedInline(SortableStackedInline):
13 | model = Chapter1 # model is ordered
14 | extra = 1
15 |
16 |
17 | class ChapterStackedInlineReversed(SortableStackedInline):
18 | model = Chapter2 # model is reverse ordered
19 | extra = 1
20 |
21 |
22 | class ChapterStackedInlineUpOrdered(SortableInlineAdminMixin, admin.StackedInline):
23 | model = Chapter
24 | extra = 1
25 | ordering = ['my_order']
26 |
27 |
28 | class ChapterStackedInlineDownOrdered(SortableInlineAdminMixin, admin.StackedInline):
29 | model = Chapter
30 | extra = 1
31 | ordering = ['-my_order']
32 |
33 |
34 | class ChapterTabularInline(SortableTabularInline):
35 | model = Chapter
36 | extra = 1
37 | ordering = ['my_order']
38 |
39 |
40 | class SortableBookAdmin(SortableAdminMixin, admin.ModelAdmin):
41 | list_per_page = 12
42 | list_display = ['title', 'author', 'my_order']
43 |
44 |
45 | class UpOrderedSortableBookAdmin(SortableBookAdmin):
46 | inlines = [ChapterStackedInlineUpOrdered]
47 | ordering = ['my_order']
48 |
49 |
50 | class DownOrderedSortableBookAdmin(SortableBookAdmin):
51 | inlines = [ChapterStackedInlineDownOrdered]
52 | ordering = ['-my_order']
53 |
54 |
55 | class SortableBookAdminStacked(SortableBookAdmin):
56 | inlines = [ChapterStackedInline]
57 |
58 |
59 | class SortableBookAdminStackedReversed(SortableBookAdmin):
60 | inlines = [ChapterStackedInlineReversed]
61 |
62 |
63 | class SortableBookAdminTabular(SortableBookAdmin):
64 | inlines = [ChapterTabularInline]
65 | ordering = ['my_order']
66 |
67 |
68 | class UnsortedBookAdmin(SortableAdminBase, admin.ModelAdmin):
69 | """
70 | Test if SortableInlineAdminMixin works without sorted parent model:
71 | Here Chapter is sorted, but Book is not.
72 | """
73 | list_per_page = 12
74 | list_display = ['title', 'author']
75 | inlines = [ChapterStackedInline]
76 |
77 |
78 | class ImportExportMixin:
79 | """
80 | Dummy mixin class to test if a proprietary change_list_template is properly overwritten.
81 | """
82 | change_list_template = 'testapp/impexp_change_list.html'
83 |
84 |
85 | class SortableAdminExtraMixin(SortableAdminMixin, ImportExportMixin, admin.ModelAdmin):
86 | """
87 | Test if SortableAdminMixin works if admin class inherits from extra mixins overriding
88 | the change_list_template template.
89 | """
90 | list_per_page = 12
91 | ordering = ['my_order']
92 | inlines = [ChapterStackedInline]
93 |
94 |
95 | class BookAdminSite(admin.AdminSite):
96 | enable_nav_sidebar = False
97 |
98 | def register(self, model_or_iterable, admin_class=None, **options):
99 | """
100 | Register the given model(s) with the given admin class.
101 | Allows to register a model more than one time.
102 | """
103 | if not issubclass(model_or_iterable, Book):
104 | return super().register(model_or_iterable, admin_class, **options)
105 |
106 | model = model_or_iterable
107 |
108 | class Meta:
109 | proxy = True
110 |
111 | verbose_name_plural = options.pop('name', model._meta.verbose_name_plural)
112 | attrs = {'Meta': Meta, '__module__': model.__module__}
113 | infix = options.pop('infix', '')
114 | name = f'Book{infix}'
115 | model = type(name, (model,), attrs)
116 | model._meta.verbose_name_plural = verbose_name_plural
117 |
118 | assert model not in self._registry
119 | self._registry[model] = admin_class(model, self)
120 |
121 | def get_app_list(self, request, app_label=None):
122 | app_dict = self._build_app_dict(request)
123 | app_list = sorted(app_dict.values(), key=lambda x: x['name'].lower())
124 | for app in app_list:
125 | if app_label == 'testapp':
126 | app['models'].sort(key=lambda x: x['admin_url'])
127 | else:
128 | app['models'].sort(key=lambda x: x['name'])
129 | return app_list
130 |
131 |
132 | admin.site = BookAdminSite()
133 | admin.site.register(Author, AuthorAdmin)
134 | admin.site.register(Book1, SortableBookAdmin, infix=0)
135 | admin.site.register(Book1, SortableBookAdminStacked, name="Books (ordered by model, stacked inlines)", infix=1)
136 | admin.site.register(Book2, SortableBookAdminStackedReversed, name="Books (reverse ordered by model, stacked inlines)", infix=2)
137 | admin.site.register(Book, UpOrderedSortableBookAdmin, name="Books (ordered by admin, stacked inlines)", infix=3)
138 | admin.site.register(Book, DownOrderedSortableBookAdmin, name="Books (reverse ordered by admin, stacked inlines)", infix=4)
139 | admin.site.register(Book, SortableBookAdminTabular, name="Books (ordered by admin, tabular inlines)", infix=5)
140 | admin.site.register(Book, UnsortedBookAdmin, name="Unsorted Books (sorted stacked inlines)", infix=6)
141 | admin.site.register(Book, SortableAdminExtraMixin, name="Books (inheriting from external admin mixin)", infix=7)
142 |
--------------------------------------------------------------------------------
/testapp/conftest.py:
--------------------------------------------------------------------------------
1 | import os
2 | import pytest
3 |
4 | os.environ.setdefault('DJANGO_ALLOW_ASYNC_UNSAFE', 'true')
5 |
6 |
7 | @pytest.fixture(scope='function')
8 | def django_db_setup(django_db_blocker):
9 | from django.core.management import call_command
10 |
11 | with django_db_blocker.unblock():
12 | call_command('migrate', verbosity=0)
13 | call_command('loaddata', 'testapp/fixtures/data.json', verbosity=0)
14 |
--------------------------------------------------------------------------------
/testapp/fixtures/data.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "model": "testapp.author",
4 | "pk": 1,
5 | "fields": {
6 | "name": "Douglas Crockford"
7 | }
8 | },
9 | {
10 | "model": "testapp.author",
11 | "pk": 3,
12 | "fields": {
13 | "name": "Frederick P. Brooks Jr."
14 | }
15 | },
16 | {
17 | "model": "testapp.author",
18 | "pk": 4,
19 | "fields": {
20 | "name": "Erich Gamma"
21 | }
22 | },
23 | {
24 | "model": "testapp.author",
25 | "pk": 5,
26 | "fields": {
27 | "name": "Mike Cohn"
28 | }
29 | },
30 | {
31 | "model": "testapp.author",
32 | "pk": 6,
33 | "fields": {
34 | "name": "Steve McConnell"
35 | }
36 | },
37 | {
38 | "model": "testapp.author",
39 | "pk": 7,
40 | "fields": {
41 | "name": "Kent Beck"
42 | }
43 | },
44 | {
45 | "model": "testapp.author",
46 | "pk": 8,
47 | "fields": {
48 | "name": "Andrew Hunt"
49 | }
50 | },
51 | {
52 | "model": "testapp.author",
53 | "pk": 9,
54 | "fields": {
55 | "name": "Tom DeMarco"
56 | }
57 | },
58 | {
59 | "model": "testapp.author",
60 | "pk": 10,
61 | "fields": {
62 | "name": "Kenneth S. Rubin"
63 | }
64 | },
65 | {
66 | "model": "testapp.author",
67 | "pk": 11,
68 | "fields": {
69 | "name": "Robert K. Wysocki"
70 | }
71 | },
72 | {
73 | "model": "testapp.author",
74 | "pk": 12,
75 | "fields": {
76 | "name": "Gregory T. Haugan"
77 | }
78 | },
79 | {
80 | "model": "testapp.author",
81 | "pk": 13,
82 | "fields": {
83 | "name": "Terry Schmidt"
84 | }
85 | },
86 | {
87 | "model": "testapp.author",
88 | "pk": 14,
89 | "fields": {
90 | "name": "Mark Lutz"
91 | }
92 | },
93 | {
94 | "model": "testapp.author",
95 | "pk": 15,
96 | "fields": {
97 | "name": "Arun Ravindran"
98 | }
99 | },
100 | {
101 | "model": "testapp.author",
102 | "pk": 16,
103 | "fields": {
104 | "name": "Jonathan Chaffer"
105 | }
106 | },
107 | {
108 | "model": "testapp.author",
109 | "pk": 17,
110 | "fields": {
111 | "name": "Bjarne Stroustrup"
112 | }
113 | },
114 | {
115 | "model": "testapp.author",
116 | "pk": 18,
117 | "fields": {
118 | "name": "Brian W. Kernighan"
119 | }
120 | },
121 | {
122 | "model": "testapp.author",
123 | "pk": 19,
124 | "fields": {
125 | "name": "Robert C. Martin"
126 | }
127 | },
128 | {
129 | "model": "testapp.author",
130 | "pk": 20,
131 | "fields": {
132 | "name": "Daniel & Audrey Roy Greenfield"
133 | }
134 | },
135 | {
136 | "model": "testapp.author",
137 | "pk": 21,
138 | "fields": {
139 | "name": "Wes McKinney"
140 | }
141 | },
142 | {
143 | "model": "testapp.author",
144 | "pk": 22,
145 | "fields": {
146 | "name": "Matt Harrison"
147 | }
148 | },
149 | {
150 | "model": "testapp.author",
151 | "pk": 23,
152 | "fields": {
153 | "name": "Luciano Ramalho"
154 | }
155 | },
156 | {
157 | "model": "testapp.author",
158 | "pk": 24,
159 | "fields": {
160 | "name": "Alex Martelli"
161 | }
162 | },
163 | {
164 | "model": "testapp.author",
165 | "pk": 25,
166 | "fields": {
167 | "name": "William S. Vincent"
168 | }
169 | },
170 | {
171 | "model": "testapp.author",
172 | "pk": 26,
173 | "fields": {
174 | "name": "Dennis Byrne"
175 | }
176 | },
177 | {
178 | "model": "testapp.book",
179 | "pk": 1,
180 | "fields": {
181 | "title": "The Mythical Man-Month",
182 | "my_order": 7,
183 | "author": 3
184 | }
185 | },
186 | {
187 | "model": "testapp.book",
188 | "pk": 2,
189 | "fields": {
190 | "title": "Design Patterns",
191 | "my_order": 29,
192 | "author": 4
193 | }
194 | },
195 | {
196 | "model": "testapp.book",
197 | "pk": 3,
198 | "fields": {
199 | "title": "The Pragmatic Programmer",
200 | "my_order": 14,
201 | "author": 8
202 | }
203 | },
204 | {
205 | "model": "testapp.book",
206 | "pk": 4,
207 | "fields": {
208 | "title": "Code Complete",
209 | "my_order": 25,
210 | "author": 6
211 | }
212 | },
213 | {
214 | "model": "testapp.book",
215 | "pk": 5,
216 | "fields": {
217 | "title": "Test Driven Development",
218 | "my_order": 17,
219 | "author": 7
220 | }
221 | },
222 | {
223 | "model": "testapp.book",
224 | "pk": 6,
225 | "fields": {
226 | "title": "Refactoring",
227 | "my_order": 11,
228 | "author": 7
229 | }
230 | },
231 | {
232 | "model": "testapp.book",
233 | "pk": 7,
234 | "fields": {
235 | "title": "Extreme Programming Explained",
236 | "my_order": 10,
237 | "author": 7
238 | }
239 | },
240 | {
241 | "model": "testapp.book",
242 | "pk": 8,
243 | "fields": {
244 | "title": "Succeeding with Agile",
245 | "my_order": 35,
246 | "author": 5
247 | }
248 | },
249 | {
250 | "model": "testapp.book",
251 | "pk": 9,
252 | "fields": {
253 | "title": "The Deadline",
254 | "my_order": 20,
255 | "author": 9
256 | }
257 | },
258 | {
259 | "model": "testapp.book",
260 | "pk": 10,
261 | "fields": {
262 | "title": "Essential Scrum",
263 | "my_order": 12,
264 | "author": 10
265 | }
266 | },
267 | {
268 | "model": "testapp.book",
269 | "pk": 11,
270 | "fields": {
271 | "title": "Effective Project Management",
272 | "my_order": 9,
273 | "author": 11
274 | }
275 | },
276 | {
277 | "model": "testapp.book",
278 | "pk": 12,
279 | "fields": {
280 | "title": "Effective Work Breakdown Structures",
281 | "my_order": 19,
282 | "author": 12
283 | }
284 | },
285 | {
286 | "model": "testapp.book",
287 | "pk": 13,
288 | "fields": {
289 | "title": "Strategic Project Management Made Simple",
290 | "my_order": 24,
291 | "author": 13
292 | }
293 | },
294 | {
295 | "model": "testapp.book",
296 | "pk": 14,
297 | "fields": {
298 | "title": "Learning Python",
299 | "my_order": 37,
300 | "author": 14
301 | }
302 | },
303 | {
304 | "model": "testapp.book",
305 | "pk": 15,
306 | "fields": {
307 | "title": "Django Design Patterns",
308 | "my_order": 36,
309 | "author": 15
310 | }
311 | },
312 | {
313 | "model": "testapp.book",
314 | "pk": 16,
315 | "fields": {
316 | "title": "Learning jQuery",
317 | "my_order": 16,
318 | "author": 16
319 | }
320 | },
321 | {
322 | "model": "testapp.book",
323 | "pk": 17,
324 | "fields": {
325 | "title": "JavaScript: The Good Parts",
326 | "my_order": 1,
327 | "author": 1
328 | }
329 | },
330 | {
331 | "model": "testapp.book",
332 | "pk": 18,
333 | "fields": {
334 | "title": "The C++ Programming Language",
335 | "my_order": 15,
336 | "author": 17
337 | }
338 | },
339 | {
340 | "model": "testapp.book",
341 | "pk": 19,
342 | "fields": {
343 | "title": "C Programming Language",
344 | "my_order": 27,
345 | "author": 18
346 | }
347 | },
348 | {
349 | "model": "testapp.book",
350 | "pk": 20,
351 | "fields": {
352 | "title": "Clean Code",
353 | "my_order": 26,
354 | "author": 19
355 | }
356 | },
357 | {
358 | "model": "testapp.book",
359 | "pk": 21,
360 | "fields": {
361 | "title": "Peopleware",
362 | "my_order": 22,
363 | "author": 9
364 | }
365 | },
366 | {
367 | "model": "testapp.book",
368 | "pk": 22,
369 | "fields": {
370 | "title": "Slack",
371 | "my_order": 23,
372 | "author": 9
373 | }
374 | },
375 | {
376 | "model": "testapp.book",
377 | "pk": 23,
378 | "fields": {
379 | "title": "Agile Software Development",
380 | "my_order": 5,
381 | "author": 19
382 | }
383 | },
384 | {
385 | "model": "testapp.book",
386 | "pk": 24,
387 | "fields": {
388 | "title": "UML for Java Programmers",
389 | "my_order": 28,
390 | "author": 19
391 | }
392 | },
393 | {
394 | "model": "testapp.book",
395 | "pk": 25,
396 | "fields": {
397 | "title": "Designing Object Oriented C++ Applications",
398 | "my_order": 6,
399 | "author": 19
400 | }
401 | },
402 | {
403 | "model": "testapp.book",
404 | "pk": 26,
405 | "fields": {
406 | "title": "More C++ Gems",
407 | "my_order": 13,
408 | "author": 19
409 | }
410 | },
411 | {
412 | "model": "testapp.book",
413 | "pk": 27,
414 | "fields": {
415 | "title": "Pragmatic Thinking and Learning",
416 | "my_order": 18,
417 | "author": 8
418 | }
419 | },
420 | {
421 | "model": "testapp.book",
422 | "pk": 28,
423 | "fields": {
424 | "title": "Practices of an Agile Developer",
425 | "my_order": 8,
426 | "author": 8
427 | }
428 | },
429 | {
430 | "model": "testapp.book",
431 | "pk": 29,
432 | "fields": {
433 | "title": "Learn to Program with Minecraft Plugins",
434 | "my_order": 38,
435 | "author": 8
436 | }
437 | },
438 | {
439 | "model": "testapp.book",
440 | "pk": 30,
441 | "fields": {
442 | "title": "Two Scoops of Django",
443 | "my_order": 30,
444 | "author": 20
445 | }
446 | },
447 | {
448 | "model": "testapp.book",
449 | "pk": 31,
450 | "fields": {
451 | "title": "Python for Data Analysis",
452 | "my_order": 31,
453 | "author": 21
454 | }
455 | },
456 | {
457 | "model": "testapp.book",
458 | "pk": 32,
459 | "fields": {
460 | "title": "Effective Pandas",
461 | "my_order": 21,
462 | "author": 22
463 | }
464 | },
465 | {
466 | "model": "testapp.book",
467 | "pk": 33,
468 | "fields": {
469 | "title": "Fluent Python",
470 | "my_order": 32,
471 | "author": 23
472 | }
473 | },
474 | {
475 | "model": "testapp.book",
476 | "pk": 34,
477 | "fields": {
478 | "title": "Python in a Nutshell",
479 | "my_order": 34,
480 | "author": 24
481 | }
482 | },
483 | {
484 | "model": "testapp.book",
485 | "pk": 35,
486 | "fields": {
487 | "title": "Django for Beginners",
488 | "my_order": 2,
489 | "author": 25
490 | }
491 | },
492 | {
493 | "model": "testapp.book",
494 | "pk": 36,
495 | "fields": {
496 | "title": "Django for Professionals",
497 | "my_order": 4,
498 | "author": 25
499 | }
500 | },
501 | {
502 | "model": "testapp.book",
503 | "pk": 37,
504 | "fields": {
505 | "title": "Django for APIs",
506 | "my_order": 3,
507 | "author": 25
508 | }
509 | },
510 | {
511 | "model": "testapp.book",
512 | "pk": 38,
513 | "fields": {
514 | "title": "Full Stack Python Security",
515 | "my_order": 33,
516 | "author": 26
517 | }
518 | },
519 | {
520 | "model": "testapp.chapter",
521 | "pk": 1,
522 | "fields": {
523 | "title": "Good Parts",
524 | "book": 17,
525 | "my_order": 2
526 | }
527 | },
528 | {
529 | "model": "testapp.chapter",
530 | "pk": 2,
531 | "fields": {
532 | "title": "Grammar",
533 | "book": 17,
534 | "my_order": 1
535 | }
536 | },
537 | {
538 | "model": "testapp.chapter",
539 | "pk": 3,
540 | "fields": {
541 | "title": "Objects",
542 | "book": 17,
543 | "my_order": 3
544 | }
545 | },
546 | {
547 | "model": "testapp.chapter",
548 | "pk": 4,
549 | "fields": {
550 | "title": "Functions",
551 | "book": 17,
552 | "my_order": 4
553 | }
554 | },
555 | {
556 | "model": "testapp.chapter",
557 | "pk": 5,
558 | "fields": {
559 | "title": "Inheritance",
560 | "book": 17,
561 | "my_order": 5
562 | }
563 | },
564 | {
565 | "model": "testapp.chapter",
566 | "pk": 6,
567 | "fields": {
568 | "title": "Arrays",
569 | "book": 17,
570 | "my_order": 6
571 | }
572 | },
573 | {
574 | "model": "testapp.chapter",
575 | "pk": 7,
576 | "fields": {
577 | "title": "Regular Expressions",
578 | "book": 17,
579 | "my_order": 7
580 | }
581 | },
582 | {
583 | "model": "testapp.chapter",
584 | "pk": 8,
585 | "fields": {
586 | "title": "Methods",
587 | "book": 17,
588 | "my_order": 8
589 | }
590 | },
591 | {
592 | "model": "testapp.chapter",
593 | "pk": 9,
594 | "fields": {
595 | "title": "Style",
596 | "book": 17,
597 | "my_order": 9
598 | }
599 | },
600 | {
601 | "model": "testapp.chapter",
602 | "pk": 10,
603 | "fields": {
604 | "title": "Beautiful Features",
605 | "book": 17,
606 | "my_order": 10
607 | }
608 | },
609 | {
610 | "model": "testapp.chapter",
611 | "pk": 11,
612 | "fields": {
613 | "title": "Awful Parts",
614 | "book": 17,
615 | "my_order": 11
616 | }
617 | },
618 | {
619 | "model": "testapp.chapter",
620 | "pk": 13,
621 | "fields": {
622 | "title": "Initial Set Up",
623 | "book": 35,
624 | "my_order": 1
625 | }
626 | },
627 | {
628 | "model": "testapp.chapter",
629 | "pk": 14,
630 | "fields": {
631 | "title": "Hello World App",
632 | "book": 35,
633 | "my_order": 2
634 | }
635 | },
636 | {
637 | "model": "testapp.chapter",
638 | "pk": 15,
639 | "fields": {
640 | "title": "Pages App",
641 | "book": 35,
642 | "my_order": 3
643 | }
644 | },
645 | {
646 | "model": "testapp.chapter",
647 | "pk": 16,
648 | "fields": {
649 | "title": "Messsage Board App",
650 | "book": 35,
651 | "my_order": 4
652 | }
653 | },
654 | {
655 | "model": "testapp.chapter",
656 | "pk": 17,
657 | "fields": {
658 | "title": "Blog App",
659 | "book": 35,
660 | "my_order": 5
661 | }
662 | },
663 | {
664 | "model": "testapp.chapter",
665 | "pk": 18,
666 | "fields": {
667 | "title": "Forms",
668 | "book": 35,
669 | "my_order": 6
670 | }
671 | },
672 | {
673 | "model": "testapp.chapter",
674 | "pk": 19,
675 | "fields": {
676 | "title": "User Accounts",
677 | "book": 35,
678 | "my_order": 7
679 | }
680 | },
681 | {
682 | "model": "testapp.chapter",
683 | "pk": 20,
684 | "fields": {
685 | "title": "Custom User Model",
686 | "book": 35,
687 | "my_order": 8
688 | }
689 | },
690 | {
691 | "model": "testapp.chapter",
692 | "pk": 21,
693 | "fields": {
694 | "title": "User Authentication",
695 | "book": 35,
696 | "my_order": 9
697 | }
698 | },
699 | {
700 | "model": "testapp.chapter",
701 | "pk": 22,
702 | "fields": {
703 | "title": "Bootstrap",
704 | "book": 35,
705 | "my_order": 10
706 | }
707 | },
708 | {
709 | "model": "testapp.chapter",
710 | "pk": 23,
711 | "fields": {
712 | "title": "Password Change and Reset",
713 | "book": 35,
714 | "my_order": 11
715 | }
716 | },
717 | {
718 | "model": "testapp.chapter",
719 | "pk": 24,
720 | "fields": {
721 | "title": "Email",
722 | "book": 35,
723 | "my_order": 12
724 | }
725 | },
726 | {
727 | "model": "testapp.chapter",
728 | "pk": 25,
729 | "fields": {
730 | "title": "Newspaper App",
731 | "book": 35,
732 | "my_order": 13
733 | }
734 | },
735 | {
736 | "model": "testapp.chapter",
737 | "pk": 26,
738 | "fields": {
739 | "title": "Permissions and Authorization",
740 | "book": 35,
741 | "my_order": 14
742 | }
743 | },
744 | {
745 | "model": "testapp.chapter",
746 | "pk": 27,
747 | "fields": {
748 | "title": "Comments",
749 | "book": 35,
750 | "my_order": 15
751 | }
752 | },
753 | {
754 | "model": "testapp.chapter",
755 | "pk": 28,
756 | "fields": {
757 | "title": "Deployment",
758 | "book": 35,
759 | "my_order": 16
760 | }
761 | },
762 | {
763 | "model": "testapp.chapter",
764 | "pk": 29,
765 | "fields": {
766 | "title": "Docker",
767 | "book": 36,
768 | "my_order": 1
769 | }
770 | },
771 | {
772 | "model": "testapp.chapter",
773 | "pk": 30,
774 | "fields": {
775 | "title": "PostgreSQL",
776 | "book": 36,
777 | "my_order": 2
778 | }
779 | },
780 | {
781 | "model": "testapp.chapter",
782 | "pk": 31,
783 | "fields": {
784 | "title": "Bookstore Project",
785 | "book": 36,
786 | "my_order": 3
787 | }
788 | },
789 | {
790 | "model": "testapp.chapter",
791 | "pk": 32,
792 | "fields": {
793 | "title": "Pages App",
794 | "book": 36,
795 | "my_order": 4
796 | }
797 | },
798 | {
799 | "model": "testapp.chapter",
800 | "pk": 33,
801 | "fields": {
802 | "title": "User Registration",
803 | "book": 36,
804 | "my_order": 5
805 | }
806 | },
807 | {
808 | "model": "testapp.chapter",
809 | "pk": 34,
810 | "fields": {
811 | "title": "Static Assets",
812 | "book": 36,
813 | "my_order": 6
814 | }
815 | },
816 | {
817 | "model": "testapp.chapter",
818 | "pk": 35,
819 | "fields": {
820 | "title": "Advanced User Authentication",
821 | "book": 36,
822 | "my_order": 7
823 | }
824 | },
825 | {
826 | "model": "testapp.chapter",
827 | "pk": 36,
828 | "fields": {
829 | "title": "Environment Variables",
830 | "book": 36,
831 | "my_order": 8
832 | }
833 | },
834 | {
835 | "model": "testapp.chapter",
836 | "pk": 37,
837 | "fields": {
838 | "title": "Email",
839 | "book": 36,
840 | "my_order": 9
841 | }
842 | },
843 | {
844 | "model": "testapp.chapter",
845 | "pk": 38,
846 | "fields": {
847 | "title": "Books App",
848 | "book": 36,
849 | "my_order": 10
850 | }
851 | },
852 | {
853 | "model": "testapp.chapter",
854 | "pk": 39,
855 | "fields": {
856 | "title": "Reviews App",
857 | "book": 36,
858 | "my_order": 11
859 | }
860 | },
861 | {
862 | "model": "testapp.chapter",
863 | "pk": 40,
864 | "fields": {
865 | "title": "File/Image Uploads",
866 | "book": 36,
867 | "my_order": 12
868 | }
869 | },
870 | {
871 | "model": "testapp.chapter",
872 | "pk": 41,
873 | "fields": {
874 | "title": "Permissions",
875 | "book": 36,
876 | "my_order": 13
877 | }
878 | },
879 | {
880 | "model": "testapp.chapter",
881 | "pk": 42,
882 | "fields": {
883 | "title": "Search",
884 | "book": 36,
885 | "my_order": 14
886 | }
887 | },
888 | {
889 | "model": "testapp.chapter",
890 | "pk": 43,
891 | "fields": {
892 | "title": "Performance",
893 | "book": 36,
894 | "my_order": 15
895 | }
896 | },
897 | {
898 | "model": "testapp.chapter",
899 | "pk": 44,
900 | "fields": {
901 | "title": "Security",
902 | "book": 36,
903 | "my_order": 16
904 | }
905 | },
906 | {
907 | "model": "testapp.chapter",
908 | "pk": 45,
909 | "fields": {
910 | "title": "Deployment",
911 | "book": 36,
912 | "my_order": 17
913 | }
914 | },
915 | {
916 | "model": "testapp.chapter",
917 | "pk": 46,
918 | "fields": {
919 | "title": "Web APIs",
920 | "book": 37,
921 | "my_order": 1
922 | }
923 | },
924 | {
925 | "model": "testapp.chapter",
926 | "pk": 47,
927 | "fields": {
928 | "title": "Library Website and API",
929 | "book": 37,
930 | "my_order": 2
931 | }
932 | },
933 | {
934 | "model": "testapp.chapter",
935 | "pk": 48,
936 | "fields": {
937 | "title": "Todo API",
938 | "book": 37,
939 | "my_order": 3
940 | }
941 | },
942 | {
943 | "model": "testapp.chapter",
944 | "pk": 49,
945 | "fields": {
946 | "title": "Todo React Front-end",
947 | "book": 37,
948 | "my_order": 4
949 | }
950 | },
951 | {
952 | "model": "testapp.chapter",
953 | "pk": 50,
954 | "fields": {
955 | "title": "Blog API",
956 | "book": 37,
957 | "my_order": 5
958 | }
959 | },
960 | {
961 | "model": "testapp.chapter",
962 | "pk": 51,
963 | "fields": {
964 | "title": "Permissions",
965 | "book": 37,
966 | "my_order": 6
967 | }
968 | },
969 | {
970 | "model": "testapp.chapter",
971 | "pk": 52,
972 | "fields": {
973 | "title": "User Authentication",
974 | "book": 37,
975 | "my_order": 7
976 | }
977 | },
978 | {
979 | "model": "testapp.chapter",
980 | "pk": 53,
981 | "fields": {
982 | "title": "Viewsets and Routers",
983 | "book": 37,
984 | "my_order": 8
985 | }
986 | },
987 | {
988 | "model": "testapp.chapter",
989 | "pk": 54,
990 | "fields": {
991 | "title": "Schemas and Documentation",
992 | "book": 37,
993 | "my_order": 9
994 | }
995 | },
996 | {
997 | "model": "auth.user",
998 | "pk": 1,
999 | "fields": {
1000 | "password": "pbkdf2_sha256$390000$oKtXeXX5OsxQTSO3V2Np4E$fjX5OysX2l3zZEA3XR0ktnlCnv+PFnEWPaoTizHrYGg=",
1001 | "last_login": "2022-02-08T17:53:11.547",
1002 | "is_superuser": true,
1003 | "username": "admin2",
1004 | "first_name": "",
1005 | "last_name": "",
1006 | "email": "",
1007 | "is_staff": true,
1008 | "is_active": true,
1009 | "date_joined": "2014-07-18T14:22:20.881",
1010 | "groups": [],
1011 | "user_permissions": []
1012 | }
1013 | }
1014 | ]
1015 |
--------------------------------------------------------------------------------
/testapp/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 | import os
4 | import sys
5 |
6 |
7 | def main():
8 | """Run administrative tasks."""
9 | sys.path.insert(0, os.path.abspath(os.path.join(os.pardir)))
10 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings')
11 | try:
12 | from django.core.management import execute_from_command_line
13 | except ImportError as exc:
14 | raise ImportError(
15 | "Couldn't import Django. Are you sure it's installed and "
16 | "available on your PYTHONPATH environment variable? Did you "
17 | "forget to activate a virtual environment?"
18 | ) from exc
19 | execute_from_command_line(sys.argv)
20 |
21 |
22 | if __name__ == '__main__':
23 | main()
24 |
--------------------------------------------------------------------------------
/testapp/middleware.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth import get_user_model
2 | from django.utils.deprecation import MiddlewareMixin
3 |
4 |
5 | class AutoLoginMiddleware(MiddlewareMixin):
6 | def process_request(self, request):
7 | admin_user = get_user_model().objects.first()
8 | if not admin_user:
9 | admin_user = get_user_model().objects.create_user(username='admin1', password='secret', is_superuser=True, is_staff=True)
10 | request.user = admin_user
11 |
--------------------------------------------------------------------------------
/testapp/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.0.5 on 2022-06-16 17:23
2 |
3 | from django.db import migrations, models
4 | import django.db.models.deletion
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | initial = True
10 |
11 | dependencies = [
12 | ]
13 |
14 | operations = [
15 | migrations.CreateModel(
16 | name='Author',
17 | fields=[
18 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19 | ('name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Name')),
20 | ],
21 | options={
22 | 'ordering': ['name'],
23 | },
24 | ),
25 | migrations.CreateModel(
26 | name='Book',
27 | fields=[
28 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
29 | ('title', models.CharField(blank=True, max_length=255, null=True, verbose_name='Title')),
30 | ('my_order', models.PositiveIntegerField(db_index=True, default=0)),
31 | ('author', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='testapp.author')),
32 | ],
33 | options={
34 | 'verbose_name': 'Book',
35 | 'verbose_name_plural': 'Books',
36 | },
37 | ),
38 | migrations.CreateModel(
39 | name='Chapter',
40 | fields=[
41 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
42 | ('title', models.CharField(blank=True, max_length=255, null=True, verbose_name='Title')),
43 | ('my_order', models.PositiveIntegerField(db_index=True)),
44 | ('book', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='testapp.book')),
45 | ],
46 | ),
47 | migrations.CreateModel(
48 | name='Book1',
49 | fields=[
50 | ],
51 | options={
52 | 'verbose_name': 'Book',
53 | 'verbose_name_plural': 'Books (ordered by model, no inlines)',
54 | 'ordering': ['my_order'],
55 | 'proxy': True,
56 | 'indexes': [],
57 | 'constraints': [],
58 | },
59 | bases=('testapp.book',),
60 | ),
61 | migrations.CreateModel(
62 | name='Book2',
63 | fields=[
64 | ],
65 | options={
66 | 'verbose_name': 'Book',
67 | 'verbose_name_plural': 'Books (reverse ordered by model, no inlines)',
68 | 'ordering': ['-my_order'],
69 | 'proxy': True,
70 | 'indexes': [],
71 | 'constraints': [],
72 | },
73 | bases=('testapp.book',),
74 | ),
75 | migrations.CreateModel(
76 | name='Book3',
77 | fields=[
78 | ],
79 | options={
80 | 'proxy': True,
81 | 'indexes': [],
82 | 'constraints': [],
83 | },
84 | bases=('testapp.book',),
85 | ),
86 | migrations.CreateModel(
87 | name='Book4',
88 | fields=[
89 | ],
90 | options={
91 | 'proxy': True,
92 | 'indexes': [],
93 | 'constraints': [],
94 | },
95 | bases=('testapp.book',),
96 | ),
97 | migrations.CreateModel(
98 | name='Book5',
99 | fields=[
100 | ],
101 | options={
102 | 'proxy': True,
103 | 'indexes': [],
104 | 'constraints': [],
105 | },
106 | bases=('testapp.book',),
107 | ),
108 | migrations.CreateModel(
109 | name='Book6',
110 | fields=[
111 | ],
112 | options={
113 | 'proxy': True,
114 | 'indexes': [],
115 | 'constraints': [],
116 | },
117 | bases=('testapp.book',),
118 | ),
119 | migrations.CreateModel(
120 | name='Book7',
121 | fields=[
122 | ],
123 | options={
124 | 'proxy': True,
125 | 'indexes': [],
126 | 'constraints': [],
127 | },
128 | bases=('testapp.book',),
129 | ),
130 | migrations.CreateModel(
131 | name='Chapter1',
132 | fields=[
133 | ],
134 | options={
135 | 'ordering': ['my_order'],
136 | 'proxy': True,
137 | 'indexes': [],
138 | 'constraints': [],
139 | },
140 | bases=('testapp.chapter',),
141 | ),
142 | migrations.CreateModel(
143 | name='Chapter2',
144 | fields=[
145 | ],
146 | options={
147 | 'ordering': ['-my_order'],
148 | 'proxy': True,
149 | 'indexes': [],
150 | 'constraints': [],
151 | },
152 | bases=('testapp.chapter',),
153 | ),
154 | migrations.CreateModel(
155 | name='Book11',
156 | fields=[
157 | ],
158 | options={
159 | 'proxy': True,
160 | 'indexes': [],
161 | 'constraints': [],
162 | },
163 | bases=('testapp.book1',),
164 | ),
165 | migrations.CreateModel(
166 | name='Book22',
167 | fields=[
168 | ],
169 | options={
170 | 'proxy': True,
171 | 'indexes': [],
172 | 'constraints': [],
173 | },
174 | bases=('testapp.book2',),
175 | ),
176 | ]
177 |
--------------------------------------------------------------------------------
/testapp/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/django-admin-sortable2/be156d89f2dc8418393c8efa56895547f4b469a9/testapp/migrations/__init__.py
--------------------------------------------------------------------------------
/testapp/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 |
4 | class Author(models.Model):
5 | name = models.CharField(
6 | "Name",
7 | null=True,
8 | blank=True,
9 | max_length=255,
10 | )
11 |
12 | class Meta:
13 | ordering = ['name']
14 |
15 | def __str__(self):
16 | return self.name
17 |
18 |
19 | class Book(models.Model):
20 | title = models.CharField(
21 | "Title",
22 | null=True,
23 | blank=True,
24 | max_length=255,
25 | )
26 |
27 | my_order = models.PositiveIntegerField(
28 | default=0,
29 | blank=False,
30 | null=False,
31 | db_index=True,
32 | )
33 |
34 | author = models.ForeignKey(
35 | Author,
36 | null=True,
37 | on_delete=models.CASCADE,
38 | )
39 |
40 | def __str__(self):
41 | return self.title
42 |
43 | class Meta:
44 | verbose_name = "Book"
45 | verbose_name_plural = "Books"
46 |
47 |
48 | # class SortableBook2(SortableBook):
49 | # class Meta:
50 | # proxy = True
51 | # verbose_name = "Book"
52 | # verbose_name_plural = "Books (reverse ordered by admin)"
53 |
54 |
55 | class Book1(Book):
56 | class Meta:
57 | proxy = True
58 | ordering = ['my_order']
59 | verbose_name = "Book"
60 | verbose_name_plural = "Books (ordered by model, no inlines)"
61 |
62 |
63 | class Book2(Book):
64 | class Meta:
65 | proxy = True
66 | ordering = ['-my_order']
67 | verbose_name = "Book"
68 | verbose_name_plural="Books (reverse ordered by model, no inlines)"
69 |
70 | # class SortableBook5(SortableBook):
71 | # class Meta:
72 | # proxy = True
73 | # verbose_name = "Book"
74 | # verbose_name_plural = "Books (unsorted)"
75 | #
76 | #
77 | # class SortableBookA(SortableBook):
78 | # class Meta:
79 | # proxy = True
80 | # verbose_name = "Book"
81 | # verbose_name_plural = "Books (extra admin mixin)"
82 |
83 |
84 | class Chapter(models.Model):
85 | title = models.CharField(
86 | "Title",
87 | null=True,
88 | blank=True,
89 | max_length=255,
90 | )
91 |
92 | book = models.ForeignKey(
93 | Book,
94 | null=True,
95 | on_delete=models.CASCADE,
96 | )
97 |
98 | my_order = models.PositiveIntegerField(
99 | blank=False,
100 | null=False,
101 | db_index=True,
102 | default=0,
103 | )
104 |
105 | def __str__(self):
106 | return "Chapter: {0}".format(self.title)
107 |
108 |
109 | class Chapter1(Chapter):
110 | class Meta:
111 | proxy = True
112 | ordering = ['my_order']
113 |
114 |
115 | class Chapter2(Chapter):
116 | class Meta:
117 | proxy = True
118 | ordering = ['-my_order']
119 |
--------------------------------------------------------------------------------
/testapp/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | DJANGO_SETTINGS_MODULE = testapp.settings
3 | django_find_project = false
4 | addopts = --tb=native
5 |
--------------------------------------------------------------------------------
/testapp/requirements.txt:
--------------------------------------------------------------------------------
1 | asgiref==3.8.1
2 | attrs==23.2.0
3 | certifi==2024.12.14
4 | charset-normalizer==3.4.1
5 | jinja2
6 | idna==3.3
7 | iniconfig==2.0.0
8 | greenlet==3.1.1
9 | packaging==21.3
10 | playwright==1.51.0
11 | pluggy==1.5.0
12 | py==1.11.0
13 | pyee==12.1.1
14 | pyparsing==3.0.9
15 | pytest==7.4.4
16 | pytest-base-url==2.1.0
17 | pytest-django==4.10.0
18 | pytest-mock==3.14.0
19 | pytest-playwright==0.7.0
20 | python-slugify==8.0.4
21 | requests==2.32.3
22 | sqlparse==0.5.3
23 | text-unidecode==1.3
24 | tomli==2.2.1
25 | urllib3==2.3.0
26 | websockets==10.4
27 |
--------------------------------------------------------------------------------
/testapp/settings.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | BASE_DIR = Path(__file__).resolve().parent.parent
4 |
5 | SECRET_KEY = 'secret_key'
6 |
7 | DEBUG = True
8 |
9 | ALLOWED_HOSTS = []
10 |
11 | INSTALLED_APPS = [
12 | 'django.contrib.auth',
13 | 'django.contrib.contenttypes',
14 | 'django.contrib.sessions',
15 | 'django.contrib.admin',
16 | 'django.contrib.staticfiles',
17 | 'django.contrib.messages',
18 | 'adminsortable2',
19 | 'testapp',
20 | ]
21 |
22 | DATABASES = {
23 | 'default': {
24 | 'ENGINE': 'django.db.backends.sqlite3',
25 | 'NAME': Path(__file__).parent.parent / 'workdir/demo.sqlite3',
26 | 'TEST': {
27 | 'NAME': Path(__file__).parent.parent / 'workdir/test.sqlite3', # live_server requires a file rather than :memory:
28 | 'OPTIONS': {
29 | 'timeout': 20,
30 | },
31 | },
32 | }
33 | }
34 |
35 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
36 |
37 | USE_TZ = False
38 |
39 | MIDDLEWARE = [
40 | 'django.middleware.common.CommonMiddleware',
41 | 'django.contrib.sessions.middleware.SessionMiddleware',
42 | 'testapp.middleware.AutoLoginMiddleware',
43 | 'django.contrib.messages.middleware.MessageMiddleware',
44 | 'django.middleware.csrf.CsrfViewMiddleware',
45 | ]
46 |
47 | ROOT_URLCONF = 'testapp.urls'
48 |
49 | SILENCED_SYSTEM_CHECKS = ['admin.E408']
50 |
51 | # URL that handles the static files served from STATIC_ROOT.
52 | # Example: "http://media.lawrence.com/static/"
53 | STATIC_URL = '/static/'
54 |
55 | TEMPLATES = [{
56 | 'BACKEND': 'django.template.backends.jinja2.Jinja2',
57 | 'DIRS': [],
58 | 'APP_DIRS': True,
59 | 'OPTIONS': {
60 | 'context_processors': [],
61 | },
62 | }, {
63 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
64 | 'DIRS': [],
65 | 'APP_DIRS': True,
66 | 'OPTIONS': {
67 | 'context_processors': [
68 | 'django.template.context_processors.debug',
69 | 'django.template.context_processors.request',
70 | 'django.contrib.auth.context_processors.auth',
71 | 'django.contrib.messages.context_processors.messages',
72 | ],
73 | },
74 | }]
75 |
76 | WSGI_APPLICATION = 'wsgi.application'
77 |
--------------------------------------------------------------------------------
/testapp/templates/testapp/impexp_change_list.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/change_list.html" %}
2 |
3 | {% block extrahead %}
4 | {{ block.super }}
5 |
6 | {% endblock %}
7 |
--------------------------------------------------------------------------------
/testapp/test_add_sortable.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from django.test import Client
4 | from django.urls import reverse
5 |
6 | from testapp.models import Book1
7 |
8 | alex_martelli_id = 24
9 |
10 |
11 | @pytest.mark.django_db
12 | def test_changelist_template_inheritance():
13 | client = Client()
14 | response = client.get(reverse('admin:testapp_book7_changelist'))
15 | assert b"" in response.content
16 |
17 |
18 | @pytest.mark.django_db
19 | def test_add_book():
20 | form_data = {
21 | 'title': "Python Cookbook",
22 | 'author': alex_martelli_id,
23 | '_save': "Save",
24 | }
25 | num_books = Book1.objects.count()
26 | assert Book1.objects.last().my_order == num_books
27 | client = Client()
28 | response = client.post(reverse('admin:testapp_book0_add'), form_data)
29 | assert response.status_code == 302, "Unable to add book"
30 | assert Book1.objects.count() == num_books + 1
31 | assert Book1.objects.last().my_order == num_books + 1
32 |
33 |
34 | @pytest.mark.django_db
35 | def test_add_chapter():
36 | python_in_a_nutshell = Book1.objects.get(title="Python in a Nutshell")
37 | assert python_in_a_nutshell.chapter_set.count() == 0
38 | form_data = {
39 | 'title': "Python in a nutshell",
40 | 'author': alex_martelli_id,
41 | 'chapter_set-TOTAL_FORMS': 2,
42 | 'chapter_set-INITIAL_FORMS': 0,
43 | 'chapter_set-MIN_NUM_FORMS': 0,
44 | 'chapter_set-MAX_NUM_FORMS': 1000,
45 | 'chapter_set-0-title': "Getting Started with Python",
46 | 'chapter_set-0-my_order': "",
47 | 'chapter_set-0-id': "",
48 | 'chapter_set-0-book': python_in_a_nutshell.id,
49 | 'chapter_set-1-title': "Installation",
50 | 'chapter_set-1-my_order': "",
51 | 'chapter_set-1-id': "",
52 | 'chapter_set-1-book': python_in_a_nutshell.id,
53 | '_save': "Save",
54 | }
55 | client = Client()
56 | response = client.post(reverse('admin:testapp_book3_change', args=(python_in_a_nutshell.id,)), form_data)
57 | assert response.status_code == 302, "Unable to add chapter"
58 | assert python_in_a_nutshell.chapter_set.count() == 2
59 | assert python_in_a_nutshell.chapter_set.first().my_order == 1
60 | assert python_in_a_nutshell.chapter_set.last().my_order == 2
61 |
--------------------------------------------------------------------------------
/testapp/test_e2e_inline.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from time import sleep
3 | from playwright.sync_api import expect
4 |
5 | from django import VERSION as DJANGO_VERSION
6 |
7 | from testapp.models import Book, Chapter
8 |
9 |
10 | slugs = [
11 | 'book1', # ordered by model, stacked inlines
12 | 'book2', # reverse ordered by model, stacked inlines
13 | 'book3', # ordered by admin, stacked inlines
14 | 'book4', # reverse ordered by admin, stacked inlines
15 | 'book5', # ordered by admin, tabular inlines
16 | 'book6', # unsorted, sorted stacked inlines
17 | ]
18 | js_the_good_parts_id = 17
19 |
20 |
21 | def get_start_order(direction):
22 | return 1 if direction == 1 else Book.objects.get(id=js_the_good_parts_id).chapter_set.count()
23 |
24 |
25 | def get_end_order(direction):
26 | return Book.objects.get(id=js_the_good_parts_id).chapter_set.count() if direction == 1 else 1
27 |
28 |
29 | @pytest.fixture
30 | def adminpage(live_server, page, slug):
31 | url = f'{live_server.url}/admin/testapp/{slug}/{js_the_good_parts_id}/change/'
32 | page.goto(url)
33 | return page
34 |
35 |
36 | def expect_fieldset_is_ordered(inline_elem, direction):
37 | expect(inline_elem).to_be_visible()
38 | start_order = get_start_order(direction)
39 | inline_related = inline_elem.locator('div.inline-related.has_original')
40 | for counter in range(inline_related.count()):
41 | row = inline_related.nth(counter)
42 | order_field = row.locator('fieldset input._reorder_')
43 | expect(order_field).to_be_hidden()
44 | order = start_order + direction * counter
45 | expect(order_field).to_have_value(str(order))
46 |
47 |
48 | @pytest.fixture
49 | def direction(slug):
50 | return +1 if slug in ['book1', 'book3', 'book5', 'book6'] else -1
51 |
52 |
53 | @pytest.fixture
54 | def chapter(slug):
55 | if slug in ['book1', 'book6']:
56 | return '#chapter1'
57 | if slug in ['book2']:
58 | return '#chapter2'
59 | return '#chapter'
60 |
61 |
62 | @pytest.fixture
63 | def drag_selector(slug):
64 | if slug in ['book5']:
65 | return '> td > p'
66 | return '> h3'
67 |
68 |
69 | @pytest.mark.parametrize('slug', slugs)
70 | def test_drag_down(adminpage, slug, direction, chapter, drag_selector):
71 | group_locator = adminpage.locator(f'{chapter}_set-group')
72 | expect_fieldset_is_ordered(group_locator, direction)
73 | start_order = get_start_order(direction)
74 | expect(group_locator.locator(f'{chapter}_set-0 input._reorder_')).to_have_value(str(start_order))
75 | drag_kwargs = {'source_position': {'x': 190, 'y': 9}, 'target_position': {'x': 200, 'y': 10}}
76 | drag_handle = group_locator.locator(f'{chapter}_set-0 {drag_selector}')
77 | expect(drag_handle).to_be_visible()
78 | drag_handle.drag_to(group_locator.locator(f'{chapter}_set-3'), **drag_kwargs)
79 | sleep(0.3) # sortablejs needs some time to update the order
80 | expect(group_locator.locator(f'{chapter}_set-0 input._reorder_')).to_have_value(str(start_order + direction * 3))
81 | expect(group_locator.locator(f'{chapter}_set-1 input._reorder_')).to_have_value(str(start_order))
82 | expect_fieldset_is_ordered(group_locator, direction)
83 |
84 |
85 | @pytest.mark.parametrize('slug', slugs)
86 | def test_drag_up(adminpage, slug, direction, chapter, drag_selector):
87 | group_locator = adminpage.locator(f'{chapter}_set-group')
88 | expect_fieldset_is_ordered(group_locator, direction)
89 | start_order = get_start_order(direction)
90 | expect(group_locator.locator(f'{chapter}_set-5 input._reorder_')).to_have_value(str(start_order + direction * 5))
91 | drag_kwargs = {'source_position': {'x': 200, 'y': 10}, 'target_position': {'x': 200, 'y': 10}}
92 | drag_handle = group_locator.locator(f'{chapter}_set-5 {drag_selector}')
93 | drag_handle.drag_to(group_locator.locator(f'{chapter}_set-1'), **drag_kwargs)
94 | expect(group_locator.locator(f'{chapter}_set-5 input._reorder_')).to_have_value(str(start_order + direction))
95 | expect(group_locator.locator(f'{chapter}_set-1 input._reorder_')).to_have_value(str(start_order + direction * 2))
96 | expect_fieldset_is_ordered(group_locator, direction)
97 |
98 |
99 | @pytest.mark.parametrize('slug', ['book1', 'book2', 'book5'])
100 | def test_move_end(adminpage, slug, direction, chapter, drag_selector):
101 | inline_locator = adminpage.locator(f'{chapter}_set-group')
102 | expect_fieldset_is_ordered(inline_locator, direction)
103 | start_order = get_start_order(direction)
104 | end_order = get_end_order(direction)
105 | expect(inline_locator.locator(f'{chapter}_set-2 input._reorder_')).to_have_value(str(start_order + direction * 2))
106 | move_end_button = inline_locator.locator(f'{chapter}_set-2 {drag_selector} .move-end').element_handle()
107 | move_end_button.click()
108 | expect(inline_locator.locator(f'{chapter}_set-2 input._reorder_')).to_have_value(str(end_order))
109 | expect(inline_locator.locator(f'{chapter}_set-3 input._reorder_')).to_have_value(str(start_order + direction * 2))
110 | expect_fieldset_is_ordered(inline_locator, direction)
111 |
112 |
113 | @pytest.mark.parametrize('slug', ['book1', 'book2', 'book5'])
114 | def test_move_begin(adminpage, slug, direction, chapter, drag_selector):
115 | inline_locator = adminpage.locator(f'{chapter}_set-group')
116 | expect_fieldset_is_ordered(inline_locator, direction)
117 | start_order = get_start_order(direction)
118 | expect(inline_locator.locator(f'{chapter}_set-8 input._reorder_')).to_have_value(str(start_order + direction * 8))
119 | move_end_button = inline_locator.locator(f'{chapter}_set-8 {drag_selector} .move-begin')
120 | move_end_button.click()
121 | expect(inline_locator.locator(f'{chapter}_set-8 input._reorder_')).to_have_value(str(start_order))
122 | expect(inline_locator.locator(f'{chapter}_set-3 input._reorder_')).to_have_value(str(start_order + direction * 4))
123 | expect_fieldset_is_ordered(inline_locator, direction)
124 |
125 |
126 | @pytest.mark.parametrize('slug', ["book1"])
127 | def test_create(adminpage, slug, direction, chapter, drag_selector):
128 | adminpage.get_by_role("link", name="Books (ordered by model,").click()
129 | adminpage.get_by_role("link", name="Add book1").click()
130 | adminpage.locator("#id_title").fill("test")
131 | adminpage.get_by_label("Author:").select_option("8")
132 | adminpage.locator("#id_chapter1_set-0-title").click()
133 | adminpage.locator("#id_chapter1_set-0-title").fill("111")
134 | add_inline_link = "link" if DJANGO_VERSION < (5, 2) else "button"
135 | adminpage.get_by_role(add_inline_link, name="Add another Chapter1").click()
136 | adminpage.locator("#id_chapter1_set-1-title").click()
137 | adminpage.locator("#id_chapter1_set-1-title").fill("222")
138 | adminpage.get_by_role(add_inline_link, name="Add another Chapter1").click()
139 | adminpage.locator("#id_chapter1_set-2-title").click()
140 | adminpage.locator("#id_chapter1_set-2-title").fill("333")
141 | adminpage.get_by_role("button", name="Save", exact=True).click()
142 |
143 | assert Chapter.objects.get(title="111").my_order == 1
144 | assert Chapter.objects.get(title="222").my_order == 2
145 | assert Chapter.objects.get(title="333").my_order == 3
146 | assert Book.objects.get(title="test").my_order != 0
147 |
--------------------------------------------------------------------------------
/testapp/test_e2e_sortable.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from time import sleep
3 |
4 | from testapp.models import Book
5 |
6 |
7 | slugs = [
8 | ('book1', None, None),
9 | ('book1', None, 3),
10 | ('book1', None, -3),
11 | ('book1', 3, None),
12 | ('book1', 3, -3),
13 | ('book1', None, 2),
14 | ('book2', None, None),
15 | ('book2', None, 3),
16 | ('book2', None, -3),
17 | ('book3', None, None),
18 | ('book3', None, 3),
19 | ('book3', None, -3),
20 | ('book4', None, None),
21 | ]
22 |
23 |
24 | def is_table_ordered(table_elem, page=None, direction=1):
25 | if page is None:
26 | page = 0
27 | else:
28 | page -= 1
29 | if direction == 1:
30 | start_order = 12 * page + 1
31 | else:
32 | start_order = Book.objects.count() - 12 * page
33 | for counter, row in enumerate(table_elem.query_selector_all('tbody tr')):
34 | drag_handle = row.query_selector('div.drag')
35 | order = start_order + direction * counter
36 | if drag_handle.get_attribute('order') != str(order):
37 | return False
38 | return True
39 |
40 |
41 | @pytest.fixture
42 | def direction(slug, o):
43 | if o is None:
44 | return +1 if slug in ['book1', 'book3'] else -1
45 | return +1 if o > 0 else -1
46 |
47 |
48 | @pytest.fixture
49 | def page_url(live_server, slug, p, o):
50 | url = f'{live_server.url}/admin/testapp/{slug}/'
51 | query = []
52 | if p:
53 | query.append(f'p={p}')
54 | if o:
55 | query.append(f'o={o}')
56 | if query:
57 | url = f"{url}?{'&'.join(query)}"
58 | return url
59 |
60 |
61 | @pytest.fixture
62 | def update_url(live_server, slug):
63 | return f'{live_server.url}/admin/testapp/{slug}/adminsortable2_update/'
64 |
65 |
66 | @pytest.fixture
67 | def adminpage(page, page_url):
68 | page.goto(page_url)
69 | return page
70 |
71 |
72 | @pytest.mark.parametrize('slug, p, o', slugs)
73 | def test_drag_to_end(adminpage, slug, p, o, direction, update_url):
74 | table_locator = adminpage.locator('table#result_list')
75 | drag_handle = table_locator.locator('tbody tr:nth-child(5) div.drag')
76 | if o == 2:
77 | assert not is_table_ordered(table_locator.element_handle(), page=p, direction=direction)
78 | assert 'handle' not in drag_handle.get_attribute('class')
79 | return
80 | assert is_table_ordered(table_locator.element_handle(), page=p, direction=direction)
81 | drag_row_pk = drag_handle.get_attribute('pk')
82 | next_row_pk = table_locator.locator('tbody tr:nth-child(6) div.drag.handle').get_attribute('pk')
83 | with adminpage.expect_response(update_url) as response_info:
84 | drag_handle.drag_to(table_locator.locator('tbody tr:last-child'))
85 | while not (response := response_info.value):
86 | sleep(0.1)
87 | assert response.ok
88 | assert is_table_ordered(table_locator.element_handle(), page=p, direction=direction)
89 | assert drag_row_pk == table_locator.locator('tbody tr:last-child div.drag.handle').get_attribute('pk')
90 | assert next_row_pk == table_locator.locator('tbody tr:nth-child(5) div.drag.handle').get_attribute('pk')
91 |
92 |
93 | @pytest.mark.parametrize('slug, p, o', slugs)
94 | def test_drag_down(adminpage, slug, p, o, direction, update_url):
95 | if o == 2:
96 | return
97 | table_locator = adminpage.locator('table#result_list')
98 | assert is_table_ordered(table_locator.element_handle(), page=p, direction=direction)
99 | drag_handle = table_locator.locator('tbody tr:nth-child(4) div.drag.handle')
100 | drag_row_pk = drag_handle.get_attribute('pk')
101 | next_row_pk = table_locator.locator('tbody tr:nth-child(7) div.drag.handle').get_attribute('pk')
102 | with adminpage.expect_response(update_url) as response_info:
103 | drag_handle.drag_to(table_locator.locator('tbody tr:nth-child(9)'))
104 | while not (response := response_info.value):
105 | sleep(0.1)
106 | assert response.ok
107 | assert is_table_ordered(table_locator.element_handle(), page=p, direction=direction)
108 | assert drag_row_pk == table_locator.locator('tbody tr:nth-child(9) div.drag.handle').get_attribute('pk')
109 | assert next_row_pk == table_locator.locator('tbody tr:nth-child(6) div.drag.handle').get_attribute('pk')
110 |
111 |
112 | @pytest.mark.parametrize('slug, p, o', slugs)
113 | def test_drag_to_start(adminpage, slug, p, o, direction, update_url):
114 | if o == 2:
115 | return
116 | table_locator = adminpage.locator('table#result_list')
117 | assert is_table_ordered(table_locator.element_handle(), page=p, direction=direction)
118 | drag_handle = table_locator.locator('tbody tr:nth-child(5) div.drag.handle')
119 | drag_row_pk = drag_handle.get_attribute('pk')
120 | prev_row_pk = table_locator.locator('tbody tr:nth-child(4) div.drag.handle').get_attribute('pk')
121 | with adminpage.expect_response(update_url) as response_info:
122 | drag_handle.drag_to(table_locator.locator('tbody tr:first-child'))
123 | while not (response := response_info.value):
124 | sleep(0.1)
125 | assert response.ok
126 | assert is_table_ordered(table_locator.element_handle(), page=p, direction=direction)
127 | assert drag_row_pk == table_locator.locator('tbody tr:first-child div.drag.handle').get_attribute('pk')
128 | assert prev_row_pk == table_locator.locator('tbody tr:nth-child(5) div.drag.handle').get_attribute('pk')
129 |
130 |
131 | @pytest.mark.parametrize('slug, p, o', slugs)
132 | def test_drag_up(adminpage, slug, p, o, direction, update_url):
133 | if o == 2:
134 | return
135 | table_locator = adminpage.locator('table#result_list')
136 | assert is_table_ordered(table_locator.element_handle(), page=p, direction=direction)
137 | drag_handle = table_locator.locator('tbody tr:nth-child(9) div.drag.handle')
138 | drag_row_pk = drag_handle.get_attribute('pk')
139 | prev_row_pk = table_locator.locator('tbody tr:nth-child(5) div.drag.handle').get_attribute('pk')
140 | with adminpage.expect_response(update_url) as response_info:
141 | drag_handle.drag_to(table_locator.locator('tbody tr:nth-child(3)'))
142 | while not (response := response_info.value):
143 | sleep(0.1)
144 | assert response.ok
145 | assert is_table_ordered(table_locator.element_handle(), page=p, direction=direction)
146 | assert drag_row_pk == table_locator.locator('tbody tr:nth-child(3) div.drag.handle').get_attribute('pk')
147 | assert prev_row_pk == table_locator.locator('tbody tr:nth-child(6) div.drag.handle').get_attribute('pk')
148 |
149 |
150 | @pytest.mark.parametrize('slug, p, o', [
151 | ('book1', None, None),
152 | ('book1', 3, None),
153 | ('book1', 3, -3),
154 | ('book2', None, -3),
155 | ('book4', None, None),
156 | ])
157 | def test_drag_multiple(adminpage, slug, p, o, direction, update_url):
158 | table_locator = adminpage.locator('table#result_list')
159 | assert is_table_ordered(table_locator.element_handle(), page=p, direction=direction)
160 | book_primary_keys = []
161 | for n in (2, 3, 4, 10, 11):
162 | book_primary_keys.append(
163 | int(table_locator.locator(f'tbody tr:nth-child({n}) div.drag.handle').element_handle().get_attribute('pk')),
164 | )
165 | table_locator.locator(f'tbody tr:nth-child({n}) input.action-select').click()
166 | drag_handle = table_locator.locator('tbody tr:nth-child(10) div.drag.handle')
167 | with adminpage.expect_response(update_url) as response_info:
168 | drag_handle.drag_to(table_locator.locator('tbody tr:nth-child(7)'))
169 | while not (response := response_info.value):
170 | sleep(0.1)
171 | assert response.ok
172 | assert is_table_ordered(table_locator.element_handle(), page=p, direction=direction)
173 | for order, pk in enumerate(book_primary_keys, 4):
174 | handle = table_locator.locator(f'tbody tr:nth-child({order}) div.drag.handle')
175 | assert pk == int(handle.get_attribute('pk'))
176 | if direction < 0:
177 | order = Book.objects.count() - order + 1
178 | if p:
179 | order -= 12 * (p - 1)
180 | else:
181 | if p:
182 | order += 12 * (p - 1)
183 | assert order == int(handle.get_attribute('order'))
184 | assert order == Book.objects.get(pk=pk).my_order
185 |
186 |
187 | @pytest.mark.parametrize('slug, p, o', [
188 | ('book1', None, None),
189 | ('book2', None, -3),
190 | ('book4', None, None),
191 | ])
192 | def test_move_next_page(adminpage, slug, p, o, direction):
193 | table_locator = adminpage.locator('table#result_list')
194 | assert is_table_ordered(table_locator.element_handle(), page=p, direction=direction)
195 | book_attributes = []
196 | for n in range(2, 7, 2):
197 | book_attributes.append((
198 | int(table_locator.locator(f'tbody tr:nth-child({n}) div.drag.handle').element_handle().get_attribute('pk')),
199 | int(table_locator.locator(f'tbody tr:nth-child({n}) div.drag.handle').element_handle().get_attribute('order')),
200 | ))
201 | table_locator.locator(f'tbody tr:nth-child({n}) input.action-select').click()
202 | step_input_field = adminpage.query_selector('#changelist-form .actions input[name="step"]')
203 | assert step_input_field.is_hidden()
204 | adminpage.query_selector('#changelist-form .actions select[name="action"]').select_option('move_to_forward_page')
205 | assert step_input_field.is_visible()
206 | step_input_field.focus()
207 | adminpage.keyboard.press("Delete")
208 | step_input_field.type("2")
209 | with adminpage.expect_response(adminpage.url) as response_info:
210 | adminpage.query_selector('#changelist-form .actions button[type="submit"]').click()
211 | while not (response := response_info.value):
212 | sleep(0.1)
213 | assert response.status == 302
214 | assert response.url == adminpage.url
215 | assert is_table_ordered(table_locator.element_handle(), page=p, direction=direction)
216 | for index, (pk, order) in enumerate(book_attributes):
217 | book = Book.objects.get(pk=pk)
218 | if direction > 0:
219 | assert book.my_order == 25 + index
220 | else:
221 | assert book.my_order == Book.objects.count() - 24 - index
222 |
--------------------------------------------------------------------------------
/testapp/test_parse_ordering_part.py:
--------------------------------------------------------------------------------
1 | from django.db.models import F, OrderBy
2 |
3 | # noinspection PyProtectedMember
4 | from adminsortable2.admin import _parse_ordering_part as parse
5 |
6 |
7 | def test():
8 | assert parse('my_order') == ('', 'my_order')
9 | assert parse(F('my_order')) == ('', 'my_order')
10 | assert parse(F('my_order').asc()) == ('', 'my_order')
11 | assert parse(OrderBy(F('my_order'))) == ('', 'my_order')
12 | assert parse(OrderBy(F('my_order'), descending=False)) == ('', 'my_order')
13 |
14 | assert parse('-my_order') == ('-', 'my_order')
15 | assert parse(F('my_order').desc()) == ('-', 'my_order')
16 | assert parse(OrderBy(F('my_order'), descending=True)) == ('-', 'my_order')
17 |
18 | assert parse(F("foo") + F("bar")) == ('', None)
19 |
--------------------------------------------------------------------------------
/testapp/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path
2 | from django.contrib import admin
3 |
4 | admin.autodiscover()
5 |
6 | urlpatterns = [
7 | path('admin/', admin.site.urls),
8 | ]
9 |
--------------------------------------------------------------------------------
/testapp/wsgi.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from django.core.wsgi import get_wsgi_application
4 |
5 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings')
6 |
7 | application = get_wsgi_application()
8 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2019",
4 | "module": "esnext",
5 | "moduleResolution": "node",
6 | "noEmitOnError": true,
7 | "lib": ["esnext", "dom", "dom.iterable"],
8 | "strict": true,
9 | "esModuleInterop": false,
10 | "allowJs": false,
11 | "allowSyntheticDefaultImports": true,
12 | "experimentalDecorators": false,
13 | "importHelpers": true,
14 | "rootDir": "./",
15 | "declaration": true,
16 | "declarationMap": true,
17 | "incremental": true
18 | },
19 | "include": [
20 | "**/*.ts"
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------