├── .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 | [![Build Status](https://github.com/jrief/django-admin-sortable2/actions/workflows/tests.yml/badge.svg)](https://github.com/jrief/django-admin-sortable2/actions/workflows/tests.yml) 7 | [![PyPI version](https://img.shields.io/pypi/v/django-admin-sortable2.svg)](https://pypi.python.org/pypi/django-admin-sortable2) 8 | [![Python versions](https://img.shields.io/pypi/pyversions/django-admin-sortable2.svg)](https://pypi.python.org/pypi/django-admin-sortable2) 9 | [![Django versions](https://img.shields.io/pypi/djversions/django-admin-sortable2)](https://pypi.python.org/pypi/django-admin-sortable2) 10 | [![Downloads](https://img.shields.io/pypi/dm/django-admin-sortable2.svg)](https://img.shields.io/pypi/dm/django-admin-sortable2.svg) 11 | [![Software license](https://img.shields.io/pypi/l/django-admin-sortable2.svg)](https://github.com/jrief/django-admin-sortable2/blob/master/LICENSE) 12 | 13 | Check the demo: 14 | 15 | ![Demo](https://raw.githubusercontent.com/jrief/django-admin-sortable2/master/docs/source/_static/django-admin-sortable2.gif) 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 | [![Twitter Follow](https://img.shields.io/twitter/follow/jacobrief.svg?style=social&label=Jacob+Rief)](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 | --------------------------------------------------------------------------------