├── .coveragerc ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── AUTHORS.rst ├── CHANGES.rst ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── MANIFEST.in ├── Makefile ├── README.rst ├── codecov.yml ├── example ├── __init__.py ├── templates │ ├── 404.html │ ├── 500.html │ └── base.html ├── testapp │ ├── __init__.py │ ├── admin.py │ ├── models.py │ ├── templates │ │ └── testapp │ │ │ └── parkingarea_form.html │ └── views.py └── urls.py ├── requirements.txt ├── runtests.py ├── setup.cfg ├── setup.py ├── sortedm2m ├── __init__.py ├── admin.py ├── compat.py ├── fields.py ├── forms.py ├── locale │ ├── cs │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── de │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── es │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── fr │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── models.py ├── operations.py ├── static │ └── sortedm2m │ │ ├── jquery-ui.min.js │ │ ├── ordered_autocomplete.css │ │ ├── ordered_autocomplete.js │ │ ├── selector-search.gif │ │ ├── widget.css │ │ └── widget.js └── templates │ └── sortedm2m │ └── sorted_checkbox_select_multiple_widget.html ├── sortedm2m_tests ├── __init__.py ├── compat.py ├── models.py ├── test_base.py ├── test_field.py ├── test_forms.py ├── test_migrations.py └── utils.py ├── test_project ├── __init__.py ├── manage.py └── settings.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source=sortedm2m 3 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | if: github.repository == 'jazzband/django-sortedm2m' 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: 3.8 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install -U pip 26 | python -m pip install -U setuptools twine wheel 27 | 28 | - name: Build package 29 | run: | 30 | python setup.py --version 31 | python setup.py sdist --format=gztar bdist_wheel 32 | twine check dist/* 33 | 34 | - name: Upload packages to Jazzband 35 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 36 | uses: pypa/gh-action-pypi-publish@master 37 | with: 38 | user: jazzband 39 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }} 40 | repository_url: https://jazzband.co/projects/django-sortedm2m/upload 41 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | python-version: ['3.10', '3.11', '3.12'] 13 | django-version: ['4.2', '5.0', '5.1', 'main'] 14 | include: 15 | - python-version: '3.8' 16 | django-version: '4.2' 17 | - python-version: '3.9' 18 | django-version: '4.2' 19 | 20 | services: 21 | postgres: 22 | image: postgres:16 23 | env: 24 | POSTGRES_USER: postgres 25 | POSTGRES_PASSWORD: postgres 26 | POSTGRES_DB: postgres 27 | ports: 28 | - 5432:5432 29 | options: >- 30 | --health-cmd pg_isready 31 | --health-interval 10s 32 | --health-timeout 5s 33 | --health-retries 5 34 | 35 | mariadb: 36 | image: mariadb:11.4 37 | env: 38 | MARIADB_USER: root 39 | MARIADB_ROOT_PASSWORD: mysql 40 | MARIADB_DATABASE: mysql 41 | options: >- 42 | --health-cmd "healthcheck.sh --connect --innodb_initialized" 43 | --health-interval 10s 44 | --health-timeout 5s 45 | --health-retries 5 46 | ports: 47 | - 3306:3306 48 | 49 | steps: 50 | - uses: actions/checkout@v3 51 | 52 | - name: Set up Python ${{ matrix.python-version }} 53 | uses: actions/setup-python@v3 54 | with: 55 | python-version: ${{ matrix.python-version }} 56 | 57 | - name: Get pip cache dir 58 | id: pip-cache 59 | run: | 60 | echo "::set-output name=dir::$(pip cache dir)" 61 | 62 | - name: Cache 63 | uses: actions/cache@v3 64 | with: 65 | path: ${{ steps.pip-cache.outputs.dir }} 66 | key: 67 | ${{ matrix.python-version }}-v1-${{ hashFiles('**/requirements.txt') }}-${{ hashFiles('**/setup.py') }}-${{ hashFiles('**/tox.ini') }} 68 | restore-keys: | 69 | ${{ matrix.python-version }}-v1- 70 | 71 | - name: Install dependencies 72 | run: | 73 | python -m pip install --upgrade pip 74 | python -m pip install --upgrade tox tox-gh-actions 75 | 76 | - name: Tox tests 77 | run: | 78 | tox -v 79 | env: 80 | DJANGO: ${{ matrix.django-version }} 81 | 82 | - name: Upload coverage 83 | uses: codecov/codecov-action@v2 84 | with: 85 | name: Python ${{ matrix.python-version }} 86 | 87 | tox_checks: 88 | name: Run tox environment ${{ matrix.tox-env }} 89 | runs-on: ubuntu-latest 90 | strategy: 91 | fail-fast: false 92 | matrix: 93 | tox-env: ['quality', 'dist-validation'] 94 | 95 | steps: 96 | - uses: actions/checkout@v3 97 | - uses: actions/setup-python@v3 98 | 99 | - name: Install dependencies 100 | run: | 101 | python -m pip install --upgrade pip 102 | python -m pip install --upgrade tox tox-gh-actions 103 | 104 | - name: Tox tests 105 | run: tox -e ${{ matrix.tox-env }} -v 106 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *.egg 4 | *.egg-info 5 | .coverage 6 | coverage.xml 7 | db.sqlite 8 | pep8.txt 9 | dist/ 10 | .idea/ 11 | .mypy_cache/ 12 | .tox/ 13 | .vagrant/ 14 | **/.DS_Store 15 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.4.0 6 | hooks: 7 | - id: check-merge-conflict 8 | - id: requirements-txt-fixer 9 | - id: trailing-whitespace 10 | types: [python] 11 | - id: check-case-conflict 12 | - id: check-yaml 13 | - id: debug-statements 14 | - id: check-added-large-files 15 | - id: debug-statements 16 | - repo: https://github.com/pycqa/isort 17 | rev: "5.12.0" 18 | hooks: 19 | - id: isort 20 | args: ["--profile", "black"] 21 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Author 2 | ------ 3 | 4 | * Gregor Müllegger 5 | 6 | Contributors 7 | ------------ 8 | 9 | * Alex Mannhold 10 | * Antti Kaihola 11 | * Asif Saif Uddin 12 | * Ben Thompson 13 | * Camilo Nova 14 | * Chris Church 15 | * Christian Kohlstedde 16 | * Clinton Blackburn 17 | * Conrad Kramer 18 | * David Evans 19 | * Dayne May 20 | * Edward Betts 21 | * Federico Capoano 22 | * Flavio Curella 23 | * Florian Ilgenfritz 24 | * Frankie Dintino 25 | * Jannis Leidel 26 | * Jasper Maes 27 | * Joaquin Perez 28 | * Jonathan Liuti 29 | * Jonny 30 | * Maarten Draijer 31 | * MadEng84 32 | * Marcin Ossowski 33 | * Michal 34 | * Mike Knoop 35 | * Mystic-Mirage 36 | * Nicolas Trésegnie 37 | * Patryk Hes 38 | * Quantum 39 | * Richard Barran 40 | * Richard Mitchell 41 | * Rohith Asrk 42 | * Roland Geider 43 | * Ruben Diaz 44 | * Rubén Díaz 45 | * Ryszard Knop 46 | * Rémy HUBSCHER 47 | * Scott Kyle 48 | * Sean O'Connor 49 | * Tipuch 50 | * Vadim Sikora 51 | * cuchac 52 | * erm0l0v 53 | * hstanev 54 | * jonny5532 55 | * smcoll 56 | * xavier 57 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 4.0.0 5 | ----- 6 | * `#216`_: Dropped support for outdated versions of Django and Python 7 | * `#215`_: Added support for Django 5.1 8 | 9 | .. _#215: https://github.com/jazzband/django-sortedm2m/pull/215 10 | .. _#216: https://github.com/jazzband/django-sortedm2m/pull/215 11 | 12 | 3.1.1 13 | ----- 14 | * `#191`_: Fixed JS bug for popup in Django admin 15 | 16 | .. _#191: https://github.com/jazzband/django-sortedm2m/pull/191 17 | 18 | 3.1.0 19 | ----- 20 | * `#178`_: Bug fixes 21 | * `#178`_: Fixed Pylint error 22 | * `#175`_: Fixed jQuery UI error 23 | * `#183`_: Migrated to GitHub Actions for CI 24 | * `#184`_: Added support for Django 3.2 25 | 26 | .. _#170: https://github.com/jazzband/django-sortedm2m/pull/170 27 | .. _#178: https://github.com/jazzband/django-sortedm2m/pull/178 28 | .. _#175: https://github.com/jazzband/django-sortedm2m/pull/175 29 | .. _#183: https://github.com/jazzband/django-sortedm2m/pull/183 30 | .. _#184: https://github.com/jazzband/django-sortedm2m/pull/184 31 | 32 | 3.0.2 33 | ----- 34 | * `#168`_: Restored `admin/js/jquery.init.js` 35 | 36 | .. _#168: https://github.com/jazzband/django-sortedm2m/pull/168 37 | 38 | 3.0.1 39 | ----- 40 | * `#164`_: Added all locales to distributable 41 | * `#162`_: Added missing files to MANIFEST.in, and removed .DS_Store files 42 | * `#150`_: Added German and Spanish translations 43 | * `#149`_: Removed `admin/js/jquery.init.js` from `SortedCheckboxSelectMultiple` 44 | 45 | .. _#164: https://github.com/jazzband/django-sortedm2m/pull/164 46 | .. _#162: https://github.com/jazzband/django-sortedm2m/pull/162 47 | .. _#150: https://github.com/jazzband/django-sortedm2m/pull/150 48 | .. _#149: https://github.com/jazzband/django-sortedm2m/pull/149 49 | 50 | 3.0.0 51 | ----- 52 | * `#147`_: Dropped support for Django 2.0 53 | * `#152`_: Dropped support for Django 1.10 54 | * `#152`_: Add support for Python 3.8 55 | * `#152`_: Add support for Django 3.0 56 | 57 | .. _#147: https://github.com/jazzband/django-sortedm2m/issues/147 58 | .. _#152: https://github.com/jazzband/django-sortedm2m/issues/152 59 | 60 | 2.0.0 61 | ----- 62 | * `#135`_: Updated README with Jazzband details, and added CONTRIBUTING.md 63 | * `#136`_: Dropped support for Python 2.6 and 3.3, and Django < 1.11 64 | * `#130`_: Added support for Python 3.7 and Django 2.0 to 2.2 65 | * `#130`_: Add support of custom through models (only for Django >= 2.2) 66 | * `#138`_: Added coverage reporting 67 | 68 | .. _#130: https://github.com/jazzband/django-sortedm2m/issues/130 69 | .. _#135: https://github.com/jazzband/django-sortedm2m/pull/135 70 | .. _#136: https://github.com/jazzband/django-sortedm2m/pull/136 71 | .. _#138: https://github.com/jazzband/django-sortedm2m/pull/138 72 | 73 | 1.5.0 74 | ----- 75 | 76 | * `#101`_: Add support for a custom base class for the many to many intermediate 77 | class. See the README for documentation. Thank you Rohith Asrk for the patch. 78 | * `#87`_: Fix ``AlterSortedManyToManyField`` operation to support custom set 79 | ``_sort_field_name``. 80 | 81 | .. _#101: https://github.com/jazzband/django-sortedm2m/pull/101 82 | .. _#87: https://github.com/jazzband/django-sortedm2m/issues/87 83 | 84 | 1.4.0 85 | ----- 86 | 87 | * `#104`_: Add compatibility for Django 1.10 and 1.11! 88 | Thank you Frankie Dintino for the patch. 89 | * `#94`_: Add french translation files. Mainly for strings in the admin. 90 | Thanks to ppython for the patch. 91 | * `#93`_: Prevent users from accidentally importing and using 92 | ``ManyToManyField`` instead of ``SortedManyToManyField`` from ``sortedm2m``. 93 | Thanks Dayne May for the patch. 94 | 95 | .. _#104: https://github.com/jazzband/django-sortedm2m/pull/104 96 | .. _#94: https://github.com/jazzband/django-sortedm2m/pull/94 97 | .. _#93: https://github.com/jazzband/django-sortedm2m/pull/93 98 | 99 | 1.3.3 100 | ----- 101 | 102 | * `#91`_ & `#92`_: Fix admin widget, when used with Django 1.10. The add a new 103 | item opup was not closing. Thanks to Tipuch for the patch. 104 | 105 | .. _#91: https://github.com/jazzband/django-sortedm2m/issues/91 106 | .. _#92: https://github.com/jazzband/django-sortedm2m/pull/92 107 | 108 | 1.3.2 109 | ----- 110 | 111 | * `#80`_ & `#83`_: Fix ``SortedMultipleChoiceField.clean`` if the validated 112 | value is ``None``. Thanks to Alex Mannhold for the patch. 113 | 114 | .. _#80: https://github.com/jazzband/django-sortedm2m/issues/80 115 | .. _#83: https://github.com/jazzband/django-sortedm2m/pull/83 116 | 117 | 1.3.1 118 | ----- 119 | 120 | * `#57`_ & `#81`_: Fix add related object popup error prevents operation when 121 | no related objects already exist. Thanks to Vadim Sikora for the fix. 122 | 123 | .. _#57: https://github.com/jazzband/django-sortedm2m/issue/57 124 | .. _#81: https://github.com/jazzband/django-sortedm2m/pull/81 125 | 126 | 1.3.0 127 | ----- 128 | 129 | * `#79`_: Use `.sortedm2m-item` selector in the widget's JavaScript code to 130 | target the list items. This was previously `ul.sortedm2m li`. This improves 131 | compatibility other markup that does not want to use `ul`/`li` tags. Thanks 132 | to Michal Dabski for the patch. 133 | 134 | **Note:** If you use custom markup with the JavaScript code, you need to make 135 | sure that the items now have the `sortedm2m-item` class name. 136 | 137 | * `#76`_: Add support for to_field_name to SortedMultipleChoiceField. Thanks 138 | to Conrad Kramer for the patch. 139 | 140 | .. _#76: https://github.com/jazzband/django-sortedm2m/pull/76 141 | .. _#79: https://github.com/jazzband/django-sortedm2m/pull/79 142 | 143 | 1.2.2 144 | ----- 145 | 146 | * `#75`_: Fix "add another" admin popup. It didn't refresh the list of items in Django 147 | 1.8+. Thanks to Vadim Sikora for the patch. 148 | 149 | .. _#75: https://github.com/jazzband/django-sortedm2m/pull/75 150 | 151 | 1.2.1 152 | ----- 153 | 154 | * *skipped* 155 | 156 | 1.2.0 157 | ----- 158 | 159 | * Dropping Python 3.2 support. It has reached end of life in February 2016. 160 | 161 | 1.1.2 162 | ----- 163 | 164 | * `#71`_: Don't break collectstatic for some cases. Therefore we removed the 165 | STATIC_URL prefix from the form media definition in 166 | ``SortedCheckboxSelectMultiple``. Thanks to Kirill Ermolov for the 167 | patch. 168 | 169 | .. _#71: https://github.com/jazzband/django-sortedm2m/issues/71 170 | 171 | 1.1.1 172 | ----- 173 | 174 | * `#70`_: CSS fix for Django 1.9 new admin design. Thanks to Maarten Draijer 175 | for the patch. 176 | 177 | .. _#70: https://github.com/jazzband/django-sortedm2m/pull/70 178 | 179 | 1.1.0 180 | ----- 181 | 182 | * `#59`_, `#65`_, `#68`_: Django 1.9 support. Thanks to Scott Kyle and Jasper Maes for 183 | patches. 184 | * `#67`_: Support for disabling migrations for some models, that can be 185 | decided by Django's DB router (with the ``allow_migrate_model`` method). 186 | Thanks to @hstanev for the patch. 187 | 188 | .. _#59: https://github.com/jazzband/django-sortedm2m/pull/59 189 | .. _#65: https://github.com/jazzband/django-sortedm2m/pull/65 190 | .. _#67: https://github.com/jazzband/django-sortedm2m/pull/67 191 | .. _#68: https://github.com/jazzband/django-sortedm2m/pull/68 192 | 193 | 1.0.2 194 | ----- 195 | 196 | * `#56`_: Fix bug where order is wrong after adding objects. That had to do 197 | with using the ``count`` of the m2m objects for the next ``sort_value`` 198 | value. We now use the corret ``Max`` aggregation to make sure that newly 199 | added objects will be in order. Thanks to Scott Kyle for the report and 200 | patch. 201 | 202 | .. _#56: https://github.com/jazzband/django-sortedm2m/pull/56 203 | 204 | 1.0.1 205 | ----- 206 | 207 | * Performance fix for sorted m2m admin widget. See `#54`_ for details. Thanks 208 | to Jonathan Liuti for fixing this. 209 | 210 | .. _#54: https://github.com/jazzband/django-sortedm2m/pull/54 211 | 212 | 1.0.0 213 | ----- 214 | 215 | * Hooray, we officially declare **django-sortedm2m** to be stable and 216 | promise to be backwards compatible to new releases (we already doing good 217 | since since the beginning in that regard). 218 | * Django 1.8 support for ``AlterSortedManyToManyField`` operation. Thanks to 219 | Nicolas Trésegnie for starting the implementation. 220 | 221 | 0.10.0 222 | ------ 223 | 224 | * The creation of the sortedm2m intermediate model and database table is now 225 | fully done inside of the ``SortedManyToManyField`` class. That makes it much 226 | easier to modify the creation of this when creating a custom subclass of this 227 | field. See `#49`_ for an example usecase. 228 | * Adding support for the custom field arguments like ``sorted`` and 229 | ``sort_value_field_name`` in Django 1.7 migrations. Thanks to Christian 230 | Kohlstedde for the patch. 231 | 232 | .. _#49: https://github.com/jazzband/django-sortedm2m/issues/49 233 | 234 | 0.9.5 235 | ----- 236 | 237 | * Fixing ``setup.py`` when run on a system that does not use UTF-8 as default 238 | encoding. See `#48`_ for details. Thanks to Richard Mitchell for the patch. 239 | 240 | .. _#48: https://github.com/jazzband/django-sortedm2m/pull/48 241 | 242 | 0.9.4 243 | ----- 244 | 245 | * Fix: ``SortedMultipleChoiceField`` did not properly report changes of the 246 | data to ``Form.changed_data``. Thanks to @smcoll for the patch. 247 | 248 | 0.9.3 249 | ----- 250 | 251 | * Fix: ``AlterSortedManyToManyField`` operation failed for postgres databases. 252 | * Testing against MySQL databases. 253 | 254 | 0.9.2 255 | ----- 256 | 257 | * Fix: ``AlterSortedManyToManyField`` operation failed for many to many fields 258 | which already contained some data. 259 | 260 | 0.9.1 261 | ----- 262 | 263 | * Fix: When using the sortable admin widget, deselecting an item in the list 264 | had not effect. Thank you to madEng84 for the report and patch! 265 | 266 | 0.9.0 267 | ----- 268 | 269 | * Adding ``AlterSortedManyToManyField`` migration operation that allows you to 270 | migrate from ``ManyToManyField`` to ``SortedManyToManyField`` and vice 271 | versa. Thanks to Joaquín Pérez for the patch! 272 | * Fix: Supporting migrations in Django 1.7.4. 273 | * Fix: The admin widget is not broken anymore for dynamically added inline 274 | forms. Thanks to Rubén Díaz for the patch! 275 | 276 | 0.8.1 277 | ----- 278 | 279 | * Adding support for Django 1.7 migrations. Thanks to Patryk Hes and Richard 280 | Barran for their reports. 281 | * Adding czech translations. Thanks to @cuchac for the pull request. 282 | 283 | 0.8.0 284 | ----- 285 | 286 | * Adding support for Django 1.7 and dropping support for Django 1.4. 287 | 288 | 0.7.0 289 | ----- 290 | 291 | * Adding support for ``prefetch_related()``. Thanks to Marcin Ossowski for 292 | the idea and patch. 293 | 294 | 0.6.1 295 | ----- 296 | 297 | * Correct escaping of *for* attribute in label for the sortedm2m widget. Thanks 298 | to Mystic-Mirage for the report and fix. 299 | 300 | 0.6.0 301 | ----- 302 | 303 | * Python 3 support! 304 | * Better widget. Thanks to Mike Knoop for the initial patch. 305 | 306 | 0.5.0 307 | ----- 308 | 309 | * Django 1.5 support. Thanks to Antti Kaihola for the patches. 310 | * Dropping Django 1.3 support. Please use django-sortedm2m<0.5 if you need to 311 | use Django 1.3. 312 | * Adding support for a ``sort_value_field_name`` argument in 313 | ``SortedManyToManyField``. Thanks to Trey Hunner for the idea. 314 | 315 | 0.4.0 316 | ----- 317 | 318 | * Django 1.4 support. Thanks to Flavio Curella for the patch. 319 | * south support is only enabled if south is actually in your INSTALLED_APPS 320 | setting. Thanks to tcmb for the report and Florian Ilgenfritz for the patch. 321 | 322 | 0.3.3 323 | ----- 324 | 325 | * South support (via monkeypatching, but anyway... it's there!). Thanks to 326 | Chris Church for the patch. South migrations won't pick up a changed 327 | ``sorted`` argument though. 328 | 329 | 0.3.2 330 | ----- 331 | 332 | * Use already included jQuery version in global scope and don't override with 333 | django's version. Thank you to Hendrik van der Linde for reporting this 334 | issue. 335 | 336 | 0.3.1 337 | ----- 338 | 339 | * Fixed packaging error. 340 | 341 | 0.3.0 342 | ----- 343 | 344 | * Heavy internal refactorings. These were necessary to solve a problem with 345 | ``SortedManyToManyField`` and a reference to ``'self'``. 346 | 347 | 0.2.5 348 | ----- 349 | 350 | * Forgot to exclude debug print/console.log statements from code. Sorry. 351 | 352 | 0.2.4 353 | ----- 354 | 355 | * Fixing problems with ``SortedCheckboxSelectMultiple`` widget, especially in 356 | admin where a "create and add another item" popup is available. 357 | 358 | 0.2.3 359 | ----- 360 | 361 | * Fixing issue with primary keys instead of model instances for ``.add()`` and 362 | ``.remove()`` methods in ``SortedRelatedManager``. 363 | 364 | 0.2.2 365 | ----- 366 | 367 | * Fixing validation error for ``SortedCheckboxSelectMultiple``. It caused 368 | errors if only one value was passed. 369 | 370 | 0.2.1 371 | ----- 372 | 373 | * Removed unnecessary reference of jquery ui css file in 374 | ``SortedCheckboxSelectMultiple``. Thanks to Klaas van Schelven and Yuwei Yu 375 | for the hint. 376 | 377 | 0.2.0 378 | ----- 379 | 380 | * Added a widget for use in admin. 381 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | As contributors and maintainers of the Jazzband projects, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating documentation, 6 | submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in the Jazzband a harassment-free experience 9 | for everyone, regardless of the level of experience, gender, gender identity and 10 | expression, sexual orientation, disability, personal appearance, body size, race, 11 | ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | - The use of sexualized language or imagery 16 | - Personal attacks 17 | - Trolling or insulting/derogatory comments 18 | - Public or private harassment 19 | - Publishing other's private information, such as physical or electronic addresses, 20 | without explicit permission 21 | - Other unethical or unprofessional conduct 22 | 23 | The Jazzband roadies have the right and responsibility to remove, edit, or reject 24 | comments, commits, code, wiki edits, issues, and other contributions that are not 25 | aligned to this Code of Conduct, or to ban temporarily or permanently any contributor 26 | for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 27 | 28 | By adopting this Code of Conduct, the roadies commit themselves to fairly and 29 | consistently applying these principles to every aspect of managing the jazzband 30 | projects. Roadies who do not follow or enforce the Code of Conduct may be permanently 31 | removed from the Jazzband roadies. 32 | 33 | This code of conduct applies both within project spaces and in public spaces when an 34 | individual is representing the project or its community. 35 | 36 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by 37 | contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and 38 | investigated and will result in a response that is deemed necessary and appropriate to 39 | the circumstances. Roadies are obligated to maintain confidentiality with regard to the 40 | reporter of an incident. 41 | 42 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 43 | 1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] 44 | 45 | [homepage]: https://contributor-covenant.org 46 | [version]: https://contributor-covenant.org/version/1/3/0/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | [![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/) 2 | 3 | This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines). 4 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010, Gregor Müllegger 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | * Neither the name of the nor the names of its contributors 13 | may be used to endorse or promote products derived from this software 14 | without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | 27 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGES.rst 2 | include LICENSE.txt 3 | include MANIFEST.in 4 | include README.rst 5 | include AUTHORS.rst 6 | recursive-include sortedm2m/static * 7 | recursive-include sortedm2m/templates * 8 | recursive-include sortedm2m/locale * 9 | include runtests.py 10 | include tox.ini 11 | recursive-include example *.html 12 | recursive-include example *.py 13 | recursive-include requirements *.txt 14 | recursive-include sortedm2m_tests *.py 15 | recursive-include test_project *.py 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: quality requirements 2 | 3 | CHECK_DIRS?=example sortedm2m sortedm2m_tests test_project *.py 4 | 5 | quality: ## Run isort, pycodestyle, and Pylint 6 | isort --check-only $(CHECK_DIRS) 7 | pycodestyle $(CHECK_DIRS) 8 | DJANGO_SETTINGS_MODULE=test_project.settings pylint --errors-only --load-plugins pylint_django $(CHECK_DIRS) 9 | 10 | requirements: ## Install requirements for development 11 | pip install -r requirements.txt 12 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | django-sortedm2m 3 | ================ 4 | 5 | .. image:: https://jazzband.co/static/img/badge.svg 6 | :target: https://jazzband.co/ 7 | :alt: Jazzband 8 | 9 | .. image:: https://img.shields.io/pypi/v/django-sortedm2m.svg 10 | :target: https://pypi.python.org/pypi/django-sortedm2m 11 | :alt: PyPI Release 12 | 13 | .. image:: https://github.com/jazzband/django-sortedm2m/actions/workflows/test.yml/badge.svg?branch=master 14 | :target: https://github.com/jazzband/django-sortedm2m/actions?query=branch%3Amaster 15 | :alt: Build Status 16 | 17 | .. image:: https://codecov.io/gh/jazzband/django-sortedm2m/branch/master/graph/badge.svg 18 | :target: https://codecov.io/gh/jazzband/django-sortedm2m 19 | :alt: Code coverage 20 | 21 | ``sortedm2m`` is a drop-in replacement for django's own ``ManyToManyField``. 22 | The provided ``SortedManyToManyField`` behaves like the original one but 23 | remembers the order of added relations. 24 | 25 | Use Cases 26 | ========= 27 | 28 | Imagine that you have a gallery model and a photo model. Usually you want a 29 | relation between these models so you can add multiple photos to one gallery 30 | but also want to be able to have the same photo on many galleries. 31 | 32 | This is where you usually can use many to many relation. The downside is that 33 | django's default implementation doesn't provide a way to order the photos in 34 | the gallery. So you only have a random ordering which is not suitable in most 35 | cases. 36 | 37 | You can work around this limitation by using the ``SortedManyToManyField`` 38 | provided by this package as drop in replacement for django's 39 | ``ManyToManyField``. 40 | 41 | Requirements 42 | ============ 43 | 44 | **django-sortedm2m** runs on Python 3.6+ and multiple Django versions. 45 | See the ``.github/workflows/test.yml`` configuration for the tested Django versions. 46 | 47 | Usage 48 | ===== 49 | 50 | Use ``SortedManyToManyField`` like ``ManyToManyField`` in your models: 51 | 52 | .. code-block:: python 53 | 54 | from django.db import models 55 | from sortedm2m.fields import SortedManyToManyField 56 | 57 | class Photo(models.Model): 58 | name = models.CharField(max_length=50) 59 | image = models.ImageField(upload_to='...') 60 | 61 | class Gallery(models.Model): 62 | name = models.CharField(max_length=50) 63 | photos = SortedManyToManyField(Photo) 64 | 65 | If you use the relation in your code like the following, it will remember the 66 | order in which you have added photos to the gallery. : 67 | 68 | .. code-block:: python 69 | 70 | gallery = Gallery.objects.create(name='Photos ordered by name') 71 | for photo in Photo.objects.order_by('name'): 72 | gallery.photos.add(photo) 73 | 74 | ``SortedManyToManyField`` 75 | ------------------------- 76 | 77 | You can use the following arguments to modify the default behavior: 78 | 79 | ``sorted`` 80 | ~~~~~~~~~~ 81 | 82 | **Default:** ``True`` 83 | 84 | You can set the ``sorted`` to ``False`` which will force the 85 | ``SortedManyToManyField`` in behaving like Django's original 86 | ``ManyToManyField``. No ordering will be performed on relation nor will the 87 | intermediate table have a database field for storing ordering information. 88 | 89 | ``sort_value_field_name`` 90 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 91 | 92 | **Default:** ``'sort_value'`` 93 | 94 | Specifies how the field is called in the intermediate database table by which 95 | the relationship is ordered. You can change its name if you have a legacy 96 | database that you need to integrate into your application. 97 | 98 | ``base_class`` 99 | ~~~~~~~~~~~~~~ 100 | 101 | **Default:** ``None`` 102 | 103 | You can set the ``base_class``, which is the base class of the through model of 104 | the sortedm2m relationship between models to an abstract base class containing 105 | a ``__str__`` method to improve the string representations of sortedm2m 106 | relationships. 107 | 108 | .. note:: 109 | 110 | You also could use it to add additional fields to the through model. But 111 | please beware: These fields will not be created or modified by an 112 | automatically created migration. You will need to take care of migrations 113 | yourself. In most cases when you want to add another field, consider 114 | *not* using sortedm2m but use a ordinary Django ManyToManyField and 115 | specify `your own through model`_. 116 | 117 | .. _your own through model: https://docs.djangoproject.com/en/1.11/ref/models/fields/#django.db.models.ManyToManyField.through 118 | 119 | Migrating a ``ManyToManyField`` to be a ``SortedManyToManyField`` 120 | ================================================================= 121 | 122 | If you are using Django's migration framework and want to change a 123 | ``ManyToManyField`` to be a ``SortedManyToManyField`` (or the other way 124 | around), you will find that a migration created by Django's ``makemigrations`` 125 | will not work as expected. 126 | 127 | In order to migrate a ``ManyToManyField`` to a ``SortedManyToManyField``, you 128 | change the field in your models to be a ``SortedManyToManyField`` as 129 | appropriate and create a new migration with ``manage.py makemigrations``. 130 | Before applying it, edit the migration file and change in the ``operations`` 131 | list ``migrations.AlterField`` to ``AlterSortedManyToManyField`` (import it 132 | from ``sortedm2m.operations``). This operation will take care of changing the 133 | intermediate tables, add the ordering field and fill in default values. 134 | 135 | Admin 136 | ===== 137 | 138 | ``SortedManyToManyField`` provides a custom widget which can be used to sort 139 | the selected items. It renders a list of checkboxes that can be sorted by 140 | drag'n'drop. 141 | 142 | To use the widget in the admin you need to add ``sortedm2m`` to your 143 | INSTALLED_APPS settings, like: 144 | 145 | .. code-block:: python 146 | 147 | INSTALLED_APPS = ( 148 | 'django.contrib.auth', 149 | 'django.contrib.contenttypes', 150 | 'django.contrib.sessions', 151 | 'django.contrib.sites', 152 | 'django.contrib.messages', 153 | 'django.contrib.staticfiles', 154 | 'django.contrib.admin', 155 | 156 | 'sortedm2m', 157 | 158 | '...', 159 | ) 160 | 161 | Otherwise it will not find the css and js files needed to sort by drag'n'drop. 162 | 163 | Finally, make sure *not* to have the model listed in any ``filter_horizontal`` 164 | or ``filter_vertical`` tuples inside of your ``ModelAdmin`` definitions. 165 | 166 | If you did it right, you'll wind up with something like this: 167 | 168 | .. image:: http://i.imgur.com/HjIW7MI.jpg 169 | 170 | It's also possible to use the ``SortedManyToManyField`` with admin's 171 | ``raw_id_fields`` option in the ``ModelAdmin`` definition. Add the name of the 172 | ``SortedManyToManyField`` to this list to get a simple text input field. The 173 | order in which the ids are entered into the input box is used to sort the 174 | items of the sorted m2m relation. 175 | 176 | Example: 177 | 178 | .. code-block:: python 179 | 180 | from django.contrib import admin 181 | 182 | class GalleryAdmin(admin.ModelAdmin): 183 | raw_id_fields = ('photos',) 184 | 185 | Contribute 186 | ========== 187 | This is a `Jazzband `_ project. By contributing you agree to abide by the 188 | `Contributor Code of Conduct `_ and follow the 189 | `guidelines `_. 190 | 191 | You can find the latest development version on Github_. Get there and fork it, file bugs or send well wishes. 192 | 193 | .. _github: http://github.com/jazzband/django-sortedm2m 194 | 195 | Running the tests 196 | ----------------- 197 | 198 | I recommend to use ``tox`` to run the tests for all relevant python versions 199 | all at once. Therefore install ``tox`` with ``pip install tox``, then type in 200 | the root directory of the ``django-sortedm2m`` checkout:: 201 | 202 | tox 203 | 204 | The tests are run against SQLite, then against PostgreSQL, then against mySQL - 205 | so you need to install PostgreSQL and mySQL on your dev environment, and should 206 | have a role/user ``sortedm2m`` set up for both PostgreSQL and mySQL. 207 | 208 | Code Quality 209 | ------------ 210 | This project uses `isort `_, `pycodestyle `_, 211 | and `pylint `_ to manage validate code quality. These validations can be run with the 212 | following command:: 213 | 214 | tox -e quality 215 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: off 2 | coverage: 3 | status: 4 | patch: 5 | default: 6 | target: 95 7 | project: 8 | default: 9 | target: 83 10 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-sortedm2m/6c9285dfb8b42ee3ae94cbe2c94fe6288408eb0c/example/__init__.py -------------------------------------------------------------------------------- /example/templates/404.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-sortedm2m/6c9285dfb8b42ee3ae94cbe2c94fe6288408eb0c/example/templates/404.html -------------------------------------------------------------------------------- /example/templates/500.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-sortedm2m/6c9285dfb8b42ee3ae94cbe2c94fe6288408eb0c/example/templates/500.html -------------------------------------------------------------------------------- /example/templates/base.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-sortedm2m/6c9285dfb8b42ee3ae94cbe2c94fe6288408eb0c/example/templates/base.html -------------------------------------------------------------------------------- /example/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-sortedm2m/6c9285dfb8b42ee3ae94cbe2c94fe6288408eb0c/example/testapp/__init__.py -------------------------------------------------------------------------------- /example/testapp/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from example.testapp.models import Car, ParkingArea 4 | 5 | 6 | class ParkingAreaAdmin(admin.ModelAdmin): 7 | fieldsets = ( 8 | ('bla', { 9 | 'classes': ('wide',), 10 | 'fields': ( 11 | 'name', 12 | 'cars', 13 | ), 14 | }), 15 | ) 16 | 17 | 18 | admin.site.register(Car) 19 | admin.site.register(ParkingArea, ParkingAreaAdmin) 20 | -------------------------------------------------------------------------------- /example/testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.urls import reverse 3 | 4 | from sortedm2m.fields import SortedManyToManyField 5 | 6 | 7 | class Car(models.Model): 8 | plate = models.CharField(max_length=50) 9 | 10 | def __str__(self): 11 | return self.plate 12 | 13 | 14 | class BaseCarThrough: 15 | def __str__(self): 16 | return str(self.car) + " in " + str(self.parkingarea) # pylint: disable=no-member 17 | 18 | 19 | class ParkingArea(models.Model): 20 | name = models.CharField(max_length=50) 21 | cars = SortedManyToManyField(Car, base_class=BaseCarThrough) 22 | 23 | def __str__(self): 24 | return self.name 25 | 26 | def get_absolute_url(self): 27 | return reverse('parkingarea', (self.pk,)) 28 | -------------------------------------------------------------------------------- /example/testapp/templates/testapp/parkingarea_form.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Parkingarea 6 | 7 | {{ form.media }} 8 | 9 | 10 |

Parkingarea {{ object.name }}

11 | 12 |
{% csrf_token %} 13 | {{ form.as_p }} 14 | 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /example/testapp/views.py: -------------------------------------------------------------------------------- 1 | from django.views.generic import UpdateView 2 | 3 | from .models import ParkingArea 4 | 5 | 6 | class ParkingAreaUpdate(UpdateView): 7 | model = ParkingArea 8 | 9 | 10 | parkingarea_update = ParkingAreaUpdate.as_view() 11 | -------------------------------------------------------------------------------- /example/urls.py: -------------------------------------------------------------------------------- 1 | import django.views.static 2 | from django.conf import settings 3 | from django.contrib import admin 4 | from django.http import HttpResponse 5 | from django.urls import include, re_path 6 | 7 | import example.testapp.views 8 | 9 | admin.autodiscover() 10 | 11 | 12 | def handle404(request, exception): 13 | return HttpResponse("404") 14 | 15 | 16 | def handle500(request): 17 | return HttpResponse("500") 18 | 19 | 20 | handler404 = "example.urls.handle404" 21 | handler500 = "example.urls.handle500" 22 | 23 | 24 | if django.VERSION < (1, 9): 25 | urlpatterns = [re_path(r"^admin/", include(admin.site.urls), name="admin")] 26 | else: 27 | urlpatterns = [re_path(r"^admin/", admin.site.urls, name="admin")] 28 | 29 | urlpatterns += [ 30 | re_path( 31 | r"^media/(.*)$", 32 | django.views.static.serve, 33 | {"document_root": settings.MEDIA_ROOT}, 34 | ), 35 | re_path( 36 | r"^parkingarea/(?P\d+)/$", 37 | example.testapp.views.parkingarea_update, 38 | name="parkingarea", 39 | ), 40 | re_path(r"^", include("django.contrib.staticfiles.urls")), 41 | ] 42 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | coverage==7.6.1 2 | isort==5.13.2 3 | pycodestyle==2.12.1 4 | pylint-django==2.5.5 5 | setuptools 6 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | import warnings 5 | 6 | import django 7 | from django.core.management import execute_from_command_line 8 | 9 | parent = os.path.dirname(os.path.abspath(__file__)) 10 | sys.path.insert(0, parent) 11 | 12 | DEFAULT_TEST_APPS = [ 13 | 'sortedm2m_tests', 14 | ] 15 | 16 | 17 | def runtests(*args): 18 | warnings.filterwarnings("ignore", module="distutils") 19 | 20 | test_apps = list(args or DEFAULT_TEST_APPS) 21 | print([sys.argv[0], 'test', '--verbosity=1'] + test_apps) 22 | execute_from_command_line([sys.argv[0], 'test', '--verbosity=1'] + test_apps) 23 | 24 | 25 | if __name__ == '__main__': 26 | runtests(*sys.argv[1:]) 27 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pycodestyle] 2 | max_line_length=120 3 | exclude=migrations 4 | 5 | [isort] 6 | line_length=120 7 | multi_line_output=5 8 | known_first_party=sortedm2m 9 | 10 | [bdist_wheel] 11 | universal = 1 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import codecs 3 | import os 4 | import re 5 | 6 | from setuptools import setup 7 | 8 | 9 | def get_version(package): 10 | """ 11 | Return package version as listed in `__version__` in `init.py`. 12 | """ 13 | matches = re.search( 14 | r"__version__[\s]+=[\s]+['\"](?P[^'\"]+)['\"]", 15 | open(os.path.join(package, '__init__.py')).read(), 16 | re.M 17 | ) 18 | 19 | return matches.group(1) if matches else None 20 | 21 | 22 | def read(filename): 23 | return codecs.open(os.path.join(os.path.dirname(__file__), filename), 24 | encoding='utf8').read() 25 | 26 | 27 | long_description = '\n\n'.join(( 28 | read('README.rst'), 29 | read('CHANGES.rst'), 30 | )) 31 | 32 | setup( 33 | name='django-sortedm2m', 34 | version=get_version('sortedm2m'), 35 | url='https://github.com/jazzband/django-sortedm2m', 36 | license='BSD', 37 | description="Drop-in replacement for Django's many to many field with sorted relations.", 38 | long_description=long_description, 39 | author=u'Gregor Müllegger', 40 | author_email='gregor@muellegger.de', 41 | packages=['sortedm2m'], 42 | include_package_data=True, 43 | zip_safe=False, 44 | classifiers=[ 45 | 'Development Status :: 5 - Production/Stable', 46 | 'Environment :: Web Environment', 47 | 'Framework :: Django', 48 | 'Framework :: Django :: 4.2', 49 | 'Framework :: Django :: 5.0', 50 | 'Framework :: Django :: 5.1', 51 | 'Intended Audience :: Developers', 52 | 'License :: OSI Approved :: BSD License', 53 | 'Natural Language :: English', 54 | 'Operating System :: OS Independent', 55 | 'Programming Language :: Python', 56 | 'Programming Language :: Python :: 3.7', 57 | 'Programming Language :: Python :: 3.8', 58 | 'Programming Language :: Python :: 3.9', 59 | 'Programming Language :: Python :: 3.10', 60 | 'Programming Language :: Python :: 3.11', 61 | 'Programming Language :: Python :: 3.12', 62 | ], 63 | ) 64 | -------------------------------------------------------------------------------- /sortedm2m/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '4.0.0' 2 | -------------------------------------------------------------------------------- /sortedm2m/admin.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.conf import settings 3 | from django.utils import translation 4 | from django.contrib.admin.widgets import AutocompleteSelectMultiple 5 | 6 | class OrderedAutocomplete(AutocompleteSelectMultiple): 7 | def optgroups(self, name, value, attr=None): 8 | """Return selected options based on the ModelChoiceIterator.""" 9 | default = (None, [], 0) 10 | groups = [default] 11 | has_selected = False 12 | # Use a list instead of a set to keep around the order returned 13 | # by SortedManyToManyField 14 | selected_choices = [ 15 | str(v) for v in value 16 | if str(v) not in self.choices.field.empty_values 17 | ] 18 | if not self.is_required and not self.allow_multiple_selected: 19 | default[1].append(self.create_option(name, "", "", False, 0)) 20 | remote_model_opts = self.field.remote_field.model._meta 21 | to_field_name = getattr( 22 | self.field.remote_field, "field_name", remote_model_opts.pk.attname 23 | ) 24 | to_field_name = remote_model_opts.get_field(to_field_name).attname 25 | choices = ( 26 | (getattr(obj, to_field_name), self.choices.field.label_from_instance(obj)) 27 | for obj in self.choices.queryset.using(self.db).filter( 28 | **{"%s__in" % to_field_name: selected_choices} 29 | ) 30 | ) 31 | # Sort choices according to what is returned by SortedManyToManyField 32 | choices = list(choices) 33 | choices.sort(key=lambda x: selected_choices.index(str(x[0]))) 34 | for option_value, option_label in choices: 35 | selected = str(option_value) in value and ( 36 | has_selected is False or self.allow_multiple_selected 37 | ) 38 | has_selected |= selected 39 | index = len(default[1]) 40 | subgroup = default[1] 41 | subgroup.append( 42 | self.create_option( 43 | name, option_value, option_label, selected_choices, index 44 | ) 45 | ) 46 | return groups 47 | 48 | class Media: 49 | extra = "" if settings.DEBUG else ".min" 50 | lang = translation.get_language() 51 | js = ( 52 | "admin/js/vendor/jquery/jquery%s.js" % extra, 53 | "admin/js/vendor/select2/select2.full%s.js" % extra, 54 | ) + ( 55 | "admin/js/vendor/select2/i18n/%s.js" % lang, 56 | ) + ( 57 | 'sortedm2m/jquery-ui.min.js', 58 | "admin/js/jquery.init.js", 59 | "sortedm2m/ordered_autocomplete.js" 60 | ) 61 | css = { 62 | "screen": ( 63 | "admin/css/vendor/select2/select2%s.css" % extra, 64 | "admin/css/autocomplete.css", 65 | "sortedm2m/ordered_autocomplete.css", 66 | ) 67 | } 68 | 69 | 70 | class SortedM2MAutocompleteMixin: 71 | 72 | def formfield_for_manytomany(self, db_field, request=None, **kwargs): 73 | using = kwargs.get("using") 74 | if db_field.name in self.sorted_autocomplete_fields: 75 | kwargs['widget'] = OrderedAutocomplete( 76 | db_field, 77 | self.admin_site, 78 | using=using 79 | ) 80 | if 'queryset' not in kwargs: 81 | queryset = self.get_field_queryset(using, db_field, request) 82 | if queryset is not None: 83 | kwargs['queryset'] = queryset 84 | 85 | form_field = db_field.formfield(**kwargs) 86 | return form_field 87 | 88 | return super().formfield_for_manytomany(db_field, request, **kwargs) 89 | -------------------------------------------------------------------------------- /sortedm2m/compat.py: -------------------------------------------------------------------------------- 1 | def get_field(model, field_name): 2 | return model._meta.get_field(field_name) 3 | 4 | 5 | def get_apps_from_state(migration_state): 6 | return migration_state.apps 7 | 8 | 9 | def get_rel(f): 10 | return f.remote_field 11 | -------------------------------------------------------------------------------- /sortedm2m/fields.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | from django.db import models, router, transaction 4 | from django.db.models import Max, Model, signals 5 | from django.db.models.fields.related import ManyToManyField as _ManyToManyField 6 | from django.db.models.fields.related import lazy_related_operation, resolve_relation 7 | from django.db.models.fields.related_descriptors import ManyToManyDescriptor, create_forward_many_to_many_manager 8 | from django.db.models.utils import make_model_tuple, resolve_callables 9 | from django.utils.encoding import force_str 10 | from django.utils.functional import cached_property 11 | from django.utils.translation import gettext_lazy as _ 12 | 13 | from .forms import SortedMultipleChoiceField 14 | 15 | SORT_VALUE_FIELD_NAME = 'sort_value' 16 | 17 | RECURSIVE_RELATIONSHIP_CONSTANT = 'self' 18 | 19 | 20 | def create_sorted_many_related_manager(superclass, rel, *args, **kwargs): 21 | RelatedManager = create_forward_many_to_many_manager( 22 | superclass, rel, *args, **kwargs) 23 | 24 | class SortedRelatedManager(RelatedManager): 25 | def _apply_rel_ordering(self, queryset): 26 | return queryset.extra(order_by=['%s.%s' % ( 27 | self.through._meta.db_table, 28 | self.through._sort_field_name, # pylint: disable=protected-access 29 | )]) 30 | 31 | def get_queryset(self): 32 | # We use ``extra`` method here because we have no other access to 33 | # the extra sorting field of the intermediary model. The fields 34 | # are hidden for joins because we set ``auto_created`` on the 35 | # intermediary's meta options. 36 | try: 37 | # pylint: disable=protected-access 38 | return self.instance._prefetched_objects_cache[self.prefetch_cache_name] 39 | except (AttributeError, KeyError): 40 | queryset = super().get_queryset() 41 | return self._apply_rel_ordering(queryset) 42 | 43 | def get_prefetch_querysets(self, instances, queryset=None): 44 | # Apply the same ordering for prefetch ones 45 | result = super().get_prefetch_querysets(instances, queryset) 46 | return (self._apply_rel_ordering(result[0]),) + result[1:] 47 | 48 | def get_prefetch_queryset(self, instances, queryset=None): 49 | # Apply the same ordering for prefetch ones 50 | result = super().get_prefetch_queryset(instances, queryset) 51 | return (self._apply_rel_ordering(result[0]),) + result[1:] 52 | 53 | 54 | def set(self, objs, *, clear=False, through_defaults=None): 55 | # Force evaluation of `objs` in case it's a queryset whose value 56 | # could be affected by `manager.clear()`. Refs #19816. 57 | # objs = tuple(objs) 58 | 59 | db = router.db_for_write(self.through, instance=self.instance) 60 | with transaction.atomic(using=db, savepoint=False): 61 | old_ids = list( 62 | self.using(db).values_list( 63 | self.target_field.target_field.attname, flat=True 64 | ) 65 | ) 66 | try: 67 | new_ids = [obj.pk for obj in objs] 68 | except AttributeError: 69 | new_ids = objs 70 | if old_ids != new_ids or clear: 71 | self.clear() 72 | self.add(*objs, through_defaults=through_defaults) 73 | 74 | set.alters_data = True 75 | 76 | def _add_items( 77 | self, source_field_name, target_field_name, *objs, through_defaults=None 78 | ): 79 | # source_field_name: the PK fieldname in join table for the source object 80 | # target_field_name: the PK fieldname in join table for the target object 81 | # *objs - objects to add. Either object instances, or primary keys 82 | # of object instances. 83 | if not objs: 84 | return 85 | 86 | through_defaults = dict(resolve_callables(through_defaults or {})) 87 | 88 | # Django uses a set here, we need to use a list to keep the 89 | # correct ordering. 90 | new_ids = [] 91 | for obj in objs: 92 | if isinstance(obj, self.model): 93 | if not router.allow_relation(obj, self.instance): 94 | raise ValueError( 95 | 'Cannot add "%r": instance is on database "%s", value is on database "%s"' % 96 | (obj, self.instance._state.db, obj._state.db) # pylint: disable=protected-access 97 | ) 98 | 99 | fk_val = self.through._meta.get_field(target_field_name).get_foreign_related_value(obj)[0] 100 | 101 | if fk_val is None: 102 | raise ValueError( 103 | 'Cannot add "%r": the value for field "%s" is None' % 104 | (obj, target_field_name) 105 | ) 106 | 107 | new_ids.append(fk_val) 108 | elif isinstance(obj, Model): 109 | raise TypeError( 110 | "'%s' instance expected, got %r" % 111 | (self.model._meta.object_name, obj) 112 | ) 113 | else: 114 | new_ids.append(obj) 115 | 116 | db = router.db_for_write(self.through, instance=self.instance) 117 | manager = self.through._default_manager.using(db) # pylint: disable=protected-access 118 | vals = (self.through._default_manager.using(db) # pylint: disable=protected-access 119 | .values_list(target_field_name, flat=True) 120 | .filter(**{ 121 | source_field_name: self.related_val[0], 122 | '%s__in' % target_field_name: new_ids, 123 | })) 124 | 125 | # make set.difference_update() keeping ordering 126 | new_ids_set = set(new_ids) 127 | new_ids_set.difference_update(vals) 128 | 129 | new_ids = list(filter(lambda _id: _id in new_ids_set, new_ids)) 130 | 131 | # Add the ones that aren't there already 132 | with transaction.atomic(using=db, savepoint=False): 133 | if self.reverse or source_field_name == self.source_field_name: 134 | # Don't send the signal when we are inserting the 135 | # duplicate data row for symmetrical reverse entries. 136 | signals.m2m_changed.send( 137 | sender=self.through, action='pre_add', 138 | instance=self.instance, reverse=self.reverse, 139 | model=self.model, pk_set=new_ids_set, using=db, 140 | ) 141 | 142 | rel_source_fk = self.related_val[0] 143 | rel_through = self.through 144 | sort_field_name = rel_through._sort_field_name # pylint: disable=protected-access 145 | 146 | # Use the max of all indices as start index... 147 | # maybe an autoincrement field should do the job more efficiently ? 148 | source_queryset = manager.filter(**{'%s_id' % source_field_name: rel_source_fk}) 149 | sort_value_max = source_queryset.aggregate(max=Max(sort_field_name))['max'] or 0 150 | 151 | bulk_data = [ 152 | dict(through_defaults, **{ 153 | '%s_id' % source_field_name: rel_source_fk, 154 | '%s_id' % target_field_name: obj_id, 155 | sort_field_name: obj_idx, 156 | }) 157 | for obj_idx, obj_id in enumerate(new_ids, sort_value_max + 1) 158 | ] 159 | 160 | manager.bulk_create([rel_through(**data) for data in bulk_data]) 161 | 162 | if self.reverse or source_field_name == self.source_field_name: 163 | # Don't send the signal when we are inserting the 164 | # duplicate data row for symmetrical reverse entries. 165 | signals.m2m_changed.send( 166 | sender=self.through, action='post_add', 167 | instance=self.instance, reverse=self.reverse, 168 | model=self.model, pk_set=new_ids_set, using=db, 169 | ) 170 | 171 | return SortedRelatedManager 172 | 173 | 174 | class SortedManyToManyDescriptor(ManyToManyDescriptor): 175 | 176 | @cached_property 177 | def related_manager_cls(self): 178 | related_model = self.rel.model 179 | return create_sorted_many_related_manager( 180 | related_model._default_manager.__class__, 181 | self.rel, 182 | # This is the new `reverse` argument (which ironically should 183 | # be False) 184 | reverse=False, 185 | ) 186 | 187 | 188 | class SortedManyToManyField(_ManyToManyField): 189 | """ 190 | Providing a many to many relation that remembers the order of related 191 | objects. 192 | 193 | Accept a boolean ``sorted`` attribute which specifies if relation is 194 | ordered or not. Default is set to ``True``. If ``sorted`` is set to 195 | ``False`` the field will behave exactly like django's ``ManyToManyField``. 196 | 197 | Accept a class ``base_class`` attribute which specifies the base class of 198 | the intermediate model. It allows to customize the intermediate model. 199 | """ 200 | 201 | def __init__(self, to, sorted=True, base_class=None, **kwargs): # pylint: disable=redefined-builtin 202 | self.sorted = sorted 203 | self.sort_value_field_name = kwargs.pop( 204 | 'sort_value_field_name', 205 | SORT_VALUE_FIELD_NAME) 206 | 207 | # Base class of through model 208 | self.base_class = base_class 209 | 210 | super().__init__(to, **kwargs) 211 | if self.sorted: 212 | self.help_text = kwargs.get('help_text', None) 213 | 214 | def deconstruct(self): 215 | # We have to persist custom added options in the ``kwargs`` 216 | # dictionary. For readability only non-default values are stored. 217 | name, path, args, kwargs = super().deconstruct() 218 | if self.sort_value_field_name is not SORT_VALUE_FIELD_NAME: 219 | kwargs['sort_value_field_name'] = self.sort_value_field_name 220 | if self.sorted is not True: 221 | kwargs['sorted'] = self.sorted 222 | return name, path, args, kwargs 223 | 224 | def check(self, **kwargs): 225 | return ( 226 | super().check(**kwargs) + 227 | self._check_through_sortedm2m() 228 | ) 229 | 230 | def _check_through_sortedm2m(self): 231 | # Check if the custom through model of a SortedManyToManyField as a 232 | # valid '_sort_field_name' attribute 233 | if self.sorted and self.remote_field.through: 234 | assert hasattr(self.remote_field.through, '_sort_field_name'), ( 235 | "The model is used as an intermediate model by " 236 | "'%s' but has no defined '_sort_field_name' attribute" % self.remote_field.through 237 | ) 238 | return [] 239 | 240 | # pylint: disable=inconsistent-return-statements 241 | def contribute_to_class(self, cls, name, **kwargs): 242 | if not self.sorted: 243 | return super().contribute_to_class(cls, name, **kwargs) 244 | 245 | # To support multiple relations to self, it's useful to have a non-None 246 | # related name on symmetrical relations for internal reasons. The 247 | # concept doesn't make a lot of sense externally ("you want me to 248 | # specify *what* on my non-reversible relation?!"), so we set it up 249 | # automatically. The funky name reduces the chance of an accidental 250 | # clash. 251 | if self.remote_field.symmetrical and ( 252 | self.remote_field.model == RECURSIVE_RELATIONSHIP_CONSTANT 253 | or self.remote_field.model == cls._meta.object_name 254 | ): 255 | self.remote_field.related_name = "%s_rel_+" % name 256 | elif self.remote_field.hidden: 257 | # If the backwards relation is disabled, replace the original 258 | # related_name with one generated from the m2m field name. Django 259 | # still uses backwards relations internally and we need to avoid 260 | # clashes between multiple m2m fields with related_name == '+'. 261 | self.remote_field.related_name = "_%s_%s_%s_+" % ( 262 | cls._meta.app_label, 263 | cls.__name__.lower(), 264 | name, 265 | ) 266 | 267 | # call super of the _ManyToManyField!!! 268 | super(_ManyToManyField, self).contribute_to_class(cls, name, **kwargs) 269 | 270 | # The intermediate m2m model is not auto created if: 271 | # 1) There is a manually specified intermediate, or 272 | # 2) The class owning the m2m field is abstract. 273 | # 3) The class owning the m2m field has been swapped out. 274 | if not cls._meta.abstract: 275 | if self.remote_field.through: 276 | 277 | def resolve_through_model(_, model, field): 278 | field.remote_field.through = model 279 | 280 | lazy_related_operation( 281 | resolve_through_model, cls, self.remote_field.through, field=self 282 | ) 283 | elif not cls._meta.swapped: 284 | self.remote_field.through = self.create_intermediate_model(cls) 285 | 286 | # Add the descriptor for the m2m relation 287 | setattr(cls, self.name, SortedManyToManyDescriptor(self.remote_field)) 288 | 289 | # Set up the accessor for the m2m table name for the relation 290 | self.m2m_db_table = partial(self._get_m2m_db_table, cls._meta) # pylint: disable=attribute-defined-outside-init 291 | 292 | def get_internal_type(self): 293 | return 'ManyToManyField' 294 | 295 | def formfield(self, **kwargs): # pylint: disable=arguments-differ 296 | defaults = {} 297 | if self.sorted: 298 | defaults['form_class'] = SortedMultipleChoiceField 299 | defaults.update(kwargs) 300 | return super().formfield(**defaults) 301 | 302 | def create_intermediate_model(self, klass): 303 | base_classes = (self.base_class, models.Model) if self.base_class else (models.Model,) 304 | 305 | return create_sortable_many_to_many_intermediary_model( 306 | self, klass, self.sort_value_field_name, 307 | base_classes=base_classes) 308 | 309 | 310 | def create_sortable_many_to_many_intermediary_model(field, klass, sort_field_name, base_classes=None): 311 | def set_managed(model, related, through): 312 | through._meta.managed = model._meta.managed or related._meta.managed 313 | 314 | to_model = resolve_relation(klass, field.remote_field.model) 315 | name = '%s_%s' % (klass._meta.object_name, field.name) 316 | lazy_related_operation(set_managed, klass, to_model, name) 317 | base_classes = base_classes if base_classes else (models.Model,) 318 | 319 | # TODO : use autoincrement here ? 320 | sort_field = models.IntegerField(default=0) 321 | 322 | to = make_model_tuple(to_model)[1] 323 | from_ = klass._meta.model_name 324 | if to == from_: 325 | to = 'to_%s' % to 326 | from_ = 'from_%s' % from_ 327 | 328 | meta = type('Meta', (), { 329 | 'db_table': field._get_m2m_db_table(klass._meta), # pylint: disable=protected-access 330 | 'auto_created': klass, 331 | 'app_label': klass._meta.app_label, 332 | 'db_tablespace': klass._meta.db_tablespace, 333 | 'unique_together': (from_, to), 334 | 'ordering': (sort_field_name,), 335 | 'verbose_name': _('%(from)s-%(to)s relationship') % {'from': from_, 'to': to}, 336 | 'verbose_name_plural': _('%(from)s-%(to)s relationships') % {'from': from_, 'to': to}, 337 | 'apps': field.model._meta.apps, 338 | }) 339 | 340 | # Construct and return the new class. 341 | return type(force_str(name), base_classes, { 342 | 'Meta': meta, 343 | '__module__': klass.__module__, 344 | from_: models.ForeignKey( 345 | klass, 346 | related_name='%s+' % name, 347 | db_tablespace=field.db_tablespace, 348 | db_constraint=field.remote_field.db_constraint, 349 | on_delete=models.CASCADE, 350 | ), 351 | to: models.ForeignKey( 352 | to_model, 353 | related_name='%s+' % name, 354 | db_tablespace=field.db_tablespace, 355 | db_constraint=field.remote_field.db_constraint, 356 | on_delete=models.CASCADE, 357 | ), 358 | # Sort fields 359 | sort_field_name: sort_field, 360 | '_sort_field_name': sort_field_name, 361 | }) 362 | -------------------------------------------------------------------------------- /sortedm2m/forms.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | from django import forms 4 | from django.template.loader import render_to_string 5 | from django.utils.encoding import force_str 6 | from django.utils.html import conditional_escape 7 | from django.utils.safestring import mark_safe 8 | 9 | 10 | class SortedCheckboxSelectMultiple(forms.CheckboxSelectMultiple): 11 | class Media: 12 | js = ( 13 | 'admin/js/jquery.init.js', 14 | 'sortedm2m/widget.js', 15 | 'sortedm2m/jquery-ui.min.js', 16 | ) 17 | css = {'screen': ( 18 | 'sortedm2m/widget.css', 19 | )} 20 | 21 | def build_attrs(self, attrs=None, **kwargs): # pylint: disable=arguments-differ 22 | attrs = dict(attrs or {}, **kwargs) 23 | attrs = super().build_attrs(attrs) 24 | classes = attrs.setdefault('class', '').split() 25 | classes.append('sortedm2m') 26 | attrs['class'] = ' '.join(classes) 27 | return attrs 28 | 29 | def render(self, name, value, attrs=None, choices=(), renderer=None): # pylint: disable=arguments-differ 30 | if value is None: 31 | value = [] 32 | has_id = attrs and 'id' in attrs 33 | final_attrs = self.build_attrs(attrs, name=name) 34 | 35 | # Normalize to strings 36 | str_values = [force_str(v) for v in value] 37 | 38 | selected = [] 39 | unselected = [] 40 | 41 | for i, (option_value, option_label) in enumerate(chain(self.choices, choices)): 42 | # If an ID attribute was given, add a numeric index as a suffix, 43 | # so that the checkboxes don't all have the same ID attribute. 44 | if has_id: 45 | final_attrs = dict(final_attrs, id='%s_%s' % (attrs['id'], i)) 46 | label_for = ' for="%s"' % conditional_escape(final_attrs['id']) 47 | else: 48 | label_for = '' 49 | 50 | cb = forms.CheckboxInput(final_attrs, check_test=lambda value: value in str_values) 51 | option_value = force_str(option_value) 52 | rendered_cb = cb.render(name, option_value) 53 | option_label = conditional_escape(force_str(option_label)) 54 | item = { 55 | 'label_for': label_for, 56 | 'rendered_cb': rendered_cb, 57 | 'option_label': option_label, 58 | 'option_value': option_value 59 | } 60 | if option_value in str_values: 61 | selected.append(item) 62 | else: 63 | unselected.append(item) 64 | 65 | # Reorder `selected` array according str_values which is a set of `option_value`s in the 66 | # order they should be shown on screen 67 | ordered = [] 68 | for s in str_values: 69 | for select in selected: 70 | if s == select['option_value']: 71 | ordered.append(select) 72 | selected = ordered 73 | 74 | html = render_to_string( 75 | 'sortedm2m/sorted_checkbox_select_multiple_widget.html', 76 | {'selected': selected, 'unselected': unselected}) 77 | return mark_safe(html) 78 | 79 | def value_from_datadict(self, data, files, name): 80 | value = data.get(name, None) 81 | if isinstance(value, (str,)): 82 | return [v for v in value.split(',') if v] 83 | return value 84 | 85 | 86 | class SortedMultipleChoiceField(forms.ModelMultipleChoiceField): 87 | def clean(self, value): 88 | queryset = super().clean(value) 89 | if value is None or not hasattr(queryset, '__iter__'): 90 | return queryset 91 | key = self.to_field_name or 'pk' 92 | objects = dict((force_str(getattr(o, key)), o) for o in queryset) 93 | return [objects[force_str(val)] for val in value] 94 | 95 | def has_changed(self, initial, data): 96 | if initial is None: 97 | initial = [] 98 | if data is None: 99 | data = [] 100 | if len(initial) != len(data): 101 | return True 102 | initial_list = [force_str(value) for value in self.prepare_value(initial)] 103 | data_list = [force_str(value) for value in data] 104 | return data_list != initial_list 105 | 106 | class SortedCheckboxMultipleChoiceField(SortedMultipleChoiceField): 107 | widget = SortedCheckboxSelectMultiple 108 | 109 | 110 | -------------------------------------------------------------------------------- /sortedm2m/locale/cs/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-sortedm2m/6c9285dfb8b42ee3ae94cbe2c94fe6288408eb0c/sortedm2m/locale/cs/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /sortedm2m/locale/cs/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: PACKAGE VERSION\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2014-06-23 11:45+0200\n" 11 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language-Team: LANGUAGE \n" 14 | "Language: \n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" 19 | 20 | #: templates/sortedm2m/sorted_checkbox_select_multiple_widget.html:5 21 | msgid "Type into this box to filter down the list." 22 | msgstr "Psaním filtrujete položky." 23 | 24 | #: templates/sortedm2m/sorted_checkbox_select_multiple_widget.html:6 25 | msgid "Filter" 26 | msgstr "Filtr" 27 | 28 | #: templates/sortedm2m/sorted_checkbox_select_multiple_widget.html:20 29 | msgid "Choose items and order by drag & drop." 30 | msgstr "Vyberte položky označením a seřaďte je přetažením." 31 | -------------------------------------------------------------------------------- /sortedm2m/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-sortedm2m/6c9285dfb8b42ee3ae94cbe2c94fe6288408eb0c/sortedm2m/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /sortedm2m/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: 2017-01-19 16:20-0500\n" 11 | "PO-Revision-Date: 2019-11-26 15:54+0100\n" 12 | "Language: de\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | "Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" 17 | "Last-Translator: \n" 18 | "Language-Team: \n" 19 | "X-Generator: Poedit 2.2.4\n" 20 | 21 | #: templates/sortedm2m/sorted_checkbox_select_multiple_widget.html:5 22 | msgid "Type into this box to filter down the list." 23 | msgstr "Tippen Sie in die Box um die Liste zu filtern." 24 | 25 | #: templates/sortedm2m/sorted_checkbox_select_multiple_widget.html:6 26 | msgid "Filter" 27 | msgstr "Filtern" 28 | 29 | #: templates/sortedm2m/sorted_checkbox_select_multiple_widget.html:20 30 | msgid "Choose items and order by drag & drop." 31 | msgstr "Wählen Sie die Elemente und sortieren Sie diese per Drag & Drop." 32 | -------------------------------------------------------------------------------- /sortedm2m/locale/es/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-sortedm2m/6c9285dfb8b42ee3ae94cbe2c94fe6288408eb0c/sortedm2m/locale/es/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /sortedm2m/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 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2017-01-19 16:20-0500\n" 11 | "PO-Revision-Date: 2019-11-26 16:03+0100\n" 12 | "Language: es\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | "Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" 17 | "Last-Translator: \n" 18 | "Language-Team: \n" 19 | "X-Generator: Poedit 2.2.4\n" 20 | 21 | #: templates/sortedm2m/sorted_checkbox_select_multiple_widget.html:5 22 | msgid "Type into this box to filter down the list." 23 | msgstr "Escribe aquí para filtrar los elementos de las lista." 24 | 25 | #: templates/sortedm2m/sorted_checkbox_select_multiple_widget.html:6 26 | msgid "Filter" 27 | msgstr "Filtrar" 28 | 29 | #: templates/sortedm2m/sorted_checkbox_select_multiple_widget.html:20 30 | msgid "Choose items and order by drag & drop." 31 | msgstr "Elige los elementos y cambia el orden arrastrándolos y soltándolos en una nueva posición." 32 | -------------------------------------------------------------------------------- /sortedm2m/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-sortedm2m/6c9285dfb8b42ee3ae94cbe2c94fe6288408eb0c/sortedm2m/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /sortedm2m/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 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PACKAGE VERSION\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2017-01-19 16:20-0500\n" 11 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language-Team: LANGUAGE \n" 14 | "Language: \n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" 19 | 20 | #: templates/sortedm2m/sorted_checkbox_select_multiple_widget.html:5 21 | msgid "Type into this box to filter down the list." 22 | msgstr "Renseigner ce champ pour filtrer la liste." 23 | 24 | #: templates/sortedm2m/sorted_checkbox_select_multiple_widget.html:6 25 | msgid "Filter" 26 | msgstr "Filtrer" 27 | 28 | #: templates/sortedm2m/sorted_checkbox_select_multiple_widget.html:20 29 | msgid "Choose items and order by drag & drop." 30 | msgstr "Choisir les éléments puis effectuer le tri en faisant un glisser-déposer." 31 | -------------------------------------------------------------------------------- /sortedm2m/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-sortedm2m/6c9285dfb8b42ee3ae94cbe2c94fe6288408eb0c/sortedm2m/models.py -------------------------------------------------------------------------------- /sortedm2m/operations.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.migrations.operations import AlterField 3 | 4 | from .compat import get_apps_from_state, get_field, get_rel 5 | 6 | 7 | class AlterSortedManyToManyField(AlterField): 8 | """A migration operation to transform a ManyToManyField into a 9 | SortedManyToManyField and vice versa.""" 10 | 11 | def database_forwards(self, app_label, schema_editor, from_state, to_state): 12 | to_apps = get_apps_from_state(to_state) 13 | to_model = to_apps.get_model(app_label, self.model_name) 14 | if self.allow_migrate_model(schema_editor.connection.alias, to_model): 15 | to_field = get_field(to_model, self.name) 16 | 17 | from_apps = get_apps_from_state(from_state) 18 | from_model = from_apps.get_model(app_label, self.model_name) 19 | from_field = get_field(from_model, self.name) 20 | 21 | to_m2m_model = get_rel(to_field).through 22 | from_m2m_model = get_rel(from_field).through 23 | 24 | # M2M -> SortedM2M 25 | if getattr(to_field, 'sorted', False): 26 | self.add_sort_value_field(schema_editor, to_m2m_model) 27 | # SortedM2M -> M2M 28 | elif getattr(from_field, 'sorted', False): 29 | self.remove_sort_value_field(schema_editor, from_m2m_model) 30 | else: 31 | raise TypeError( 32 | '{operation} should only be used when changing a ' 33 | 'SortedManyToManyField into a ManyToManyField or a ' 34 | 'ManyToManyField into a SortedManyToManyField.' 35 | .format(operation=self.__class__.__name__)) 36 | 37 | def database_backwards(self, app_label, schema_editor, from_state, to_state): 38 | from_apps = get_apps_from_state(from_state) 39 | from_model = from_apps.get_model(app_label, self.model_name) 40 | from_field = get_field(from_model, self.name) 41 | 42 | to_apps = get_apps_from_state(to_state) 43 | to_model = to_apps.get_model(app_label, self.model_name) 44 | 45 | if self.allow_migrate_model(schema_editor.connection.alias, to_model): 46 | to_field = get_field(to_model, self.name) 47 | from_m2m_model = get_rel(from_field).through 48 | to_m2m_model = get_rel(to_field).through 49 | 50 | # The `to_state` is the OLDER state. 51 | 52 | # M2M -> SortedM2M (backwards) 53 | if getattr(to_field, 'sorted', False): 54 | self.add_sort_value_field(schema_editor, to_m2m_model) 55 | # SortedM2M -> M2M (backwards) 56 | elif getattr(from_field, 'sorted', False): 57 | self.remove_sort_value_field(schema_editor, from_m2m_model) 58 | else: 59 | raise TypeError( 60 | '{operation} should only be used when changing a ' 61 | 'SortedManyToManyField into a ManyToManyField or a ' 62 | 'ManyToManyField into a SortedManyToManyField.' 63 | .format(operation=self.__class__.__name__)) 64 | 65 | def add_sort_value_field(self, schema_editor, model): 66 | field = self.make_sort_by_field(model) 67 | schema_editor.add_field(model, field) 68 | 69 | @staticmethod 70 | def remove_sort_value_field(schema_editor, model): 71 | field = get_field(model, model._sort_field_name) # pylint: disable=protected-access 72 | schema_editor.remove_field(model, field) 73 | 74 | @staticmethod 75 | def make_sort_by_field(model): 76 | field_name = model._sort_field_name # pylint: disable=protected-access 77 | field = models.IntegerField(name=field_name, default=0) 78 | field.set_attributes_from_name(field_name) 79 | return field 80 | -------------------------------------------------------------------------------- /sortedm2m/static/sortedm2m/ordered_autocomplete.css: -------------------------------------------------------------------------------- 1 | .grp-change-form .select2-selection__rendered.ui-sortable .ui-sortable-helper { 2 | width: auto !important; 3 | height: auto !important; 4 | } -------------------------------------------------------------------------------- /sortedm2m/static/sortedm2m/ordered_autocomplete.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | { 3 | const $ = django.jQuery; 4 | 5 | $.fn.djangoAdminSelect2 = function() { 6 | $.each(this, function(i, element) { 7 | $(element).select2({ 8 | ajax: { 9 | data: (params) => { 10 | return { 11 | term: params.term, 12 | page: params.page, 13 | app_label: element.dataset.appLabel, 14 | model_name: element.dataset.modelName, 15 | field_name: element.dataset.fieldName 16 | }; 17 | } 18 | } 19 | }); 20 | let select = $('select#' + element.getAttribute('id')); 21 | let children = select.next().children().children().children(); 22 | children.sortable({ 23 | // containment: 'parent', 24 | stop: function (event, ui) { 25 | ui.item.parent().children('[title]').each(function () { 26 | let title = $(this).attr('title'); 27 | let original = $('option:contains(' + title + ')', select).first(); 28 | original.detach(); 29 | select.append(original) 30 | }); 31 | select.change(); 32 | } 33 | }); 34 | }); 35 | return this; 36 | }; 37 | 38 | $(function() { 39 | // Initialize all autocomplete widgets except the one in the template 40 | // form used when a new formset is added. 41 | $('.admin-autocomplete').not('[name*=__prefix__]').djangoAdminSelect2(); 42 | }); 43 | 44 | document.addEventListener('formset:added', (event) => { 45 | $(event.target).find('.admin-autocomplete').djangoAdminSelect2(); 46 | }); 47 | } -------------------------------------------------------------------------------- /sortedm2m/static/sortedm2m/selector-search.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-sortedm2m/6c9285dfb8b42ee3ae94cbe2c94fe6288408eb0c/sortedm2m/static/sortedm2m/selector-search.gif -------------------------------------------------------------------------------- /sortedm2m/static/sortedm2m/widget.css: -------------------------------------------------------------------------------- 1 | .sortedm2m-container { 2 | margin-right: 10px; 3 | width: 570px; 4 | } 5 | 6 | .sortedm2m-container p.selector-filter { 7 | width: 570px; 8 | padding: 0; 9 | margin: 0; 10 | } 11 | 12 | .sortedm2m-container p.selector-filter input { 13 | width: 532px; 14 | margin: 0 0 0 4px; 15 | } 16 | 17 | ul.sortedm2m { 18 | display: block; 19 | width: 554px; 20 | min-height: 200px; 21 | max-height: 400px; 22 | overflow-x: hidden; 23 | overflow-y: auto; 24 | margin: 0; 25 | padding: 6px 8px; 26 | list-style-type: none; 27 | text-align: left; 28 | } 29 | 30 | ul.sortedm2m li { 31 | list-style-type: none; 32 | text-align: left; 33 | width: 550px; 34 | overflow: hidden; 35 | text-overflow: ellipsis; 36 | white-space: pre; 37 | } 38 | 39 | .sortedm2m-item, .sortedm2m label { 40 | cursor: move; 41 | } 42 | 43 | /* required to work properly in django admin */ 44 | body.change-form .sortedm2m-container { 45 | float: left; 46 | } 47 | .module ul.sortedm2m { 48 | margin: 0; 49 | padding: 10px 0; 50 | } 51 | 52 | .hide { 53 | display: none; 54 | } 55 | -------------------------------------------------------------------------------- /sortedm2m/static/sortedm2m/widget.js: -------------------------------------------------------------------------------- 1 | if (typeof jQuery === 'undefined') { 2 | var jQuery = django.jQuery; 3 | } 4 | 5 | (function ($) { 6 | $(function () { 7 | $('.sortedm2m-container').find('.sortedm2m-items').addClass('hide'); 8 | function prepareUl(ul) { 9 | ul.addClass('sortedm2m'); 10 | var checkboxes = ul.find('input[type=checkbox]'); 11 | var id; 12 | var name; 13 | 14 | if (checkboxes.length) { 15 | id = checkboxes.first().attr('id').match(/^(.*)_\d+$/)[1]; 16 | name = checkboxes.first().attr('name'); 17 | checkboxes.removeAttr('name'); 18 | } else { 19 | var label, labelFor; 20 | var currentElement = ul; 21 | 22 | // Look for a