├── .github ├── ISSUE_TEMPLATE │ └── sweep-template.yml ├── lock.yml └── workflows │ ├── pypi.yml │ └── test.yml ├── .gitignore ├── .landscape.yaml ├── .venv └── .existo ├── LICENSE.md ├── MANIFEST.in ├── Pipfile ├── Pipfile.lock ├── README.rst ├── easy ├── __init__.py ├── admin │ ├── __init__.py │ ├── decorators.py │ ├── field.py │ └── mixin.py ├── helper.py ├── models.py ├── tests.py └── util.py ├── manage.py ├── requirements.txt ├── runtests.py ├── screenshot ├── more.png └── related.png ├── setup.py ├── sweep.yaml ├── test_app ├── __init__.py ├── admin.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20240827_1315.py │ └── __init__.py ├── models.py └── templates │ └── test.html ├── test_project ├── __init__.py ├── asgi.py ├── settings.py ├── urls.py └── wsgi.py └── tox.ini /.github/ISSUE_TEMPLATE/sweep-template.yml: -------------------------------------------------------------------------------- 1 | name: Sweep Issue 2 | title: 'Sweep: ' 3 | description: For small bugs, features, refactors, and tests to be handled by Sweep, an AI-powered junior developer. 4 | labels: sweep 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Details 10 | description: Tell Sweep where and what to edit and provide enough context for a new developer to the codebase 11 | placeholder: | 12 | Unit Tests: Write unit tests for . Test each function in the file. Make sure to test edge cases. 13 | Bugs: The bug might be in . Here are the logs: ... 14 | Features: the new endpoint should use the ... class from because it contains ... logic. 15 | Refactors: We are migrating this function to ... version because ... -------------------------------------------------------------------------------- /.github/lock.yml: -------------------------------------------------------------------------------- 1 | # Configuration for lock-threads - https://github.com/dessant/lock-threads 2 | 3 | # Number of days of inactivity before a closed issue or pull request is locked 4 | daysUntilLock: 365 5 | 6 | # Issues and pull requests with these labels will not be locked. Set to `[]` to disable 7 | exemptLabels: [] 8 | 9 | # Label to add before locking, such as `outdated`. Set to `false` to disable 10 | lockLabel: false 11 | 12 | # Comment to post before locking. Set to `false` to disable 13 | lockComment: > 14 | This thread has been automatically locked since there has not been 15 | any recent activity after it was closed. Please open a new issue for 16 | related bugs. 17 | 18 | # Assign `resolved` as the reason for locking. Set to `false` to disable 19 | setLockReason: true 20 | 21 | # Limit to only `issues` or `pulls` 22 | # only: issues 23 | 24 | # Optionally, specify configuration settings just for `issues` or `pulls` 25 | # issues: 26 | # exemptLabels: 27 | # - help-wanted 28 | # lockLabel: outdated 29 | 30 | # pulls: 31 | # daysUntilLock: 30 32 | 33 | # Repository to extend settings from 34 | # _extends: repo 35 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: Package Publish on PyPi 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | build: 10 | name: Build Package 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | - name: Set up Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: '3.x' 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install setuptools wheel twine 23 | - name: Build package 24 | run: | 25 | python setup.py sdist bdist_wheel 26 | - name: Save artifact 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: dist 30 | path: './dist' 31 | 32 | pypi-publish: 33 | name: Upload release to PyPI 34 | runs-on: ubuntu-latest 35 | needs: build 36 | environment: 37 | name: pypi 38 | url: https://pypi.org/p/django-admin-easy 39 | permissions: 40 | id-token: write 41 | steps: 42 | - name: Download artifact 43 | uses: actions/download-artifact@v4 44 | with: 45 | name: dist 46 | path: './dist' 47 | - name: Publish package distributions to PyPI 48 | uses: pypa/gh-action-pypi-publish@release/v1 49 | with: 50 | password: ${{ secrets.PYPI_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | python -m pip install tox tox-gh-actions 24 | - name: Test with tox 25 | run: tox -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | *.DS_Store 3 | # Packages 4 | *.egg 5 | *.egg-info 6 | dist 7 | build 8 | eggs 9 | parts 10 | bin 11 | var 12 | sdist 13 | develop-eggs 14 | .installed.cfg 15 | 16 | # Installer logs 17 | pip-log.txt 18 | 19 | # Unit test / coverage reports 20 | .coverage 21 | .tox 22 | 23 | #Translations 24 | *.mo 25 | 26 | #Mr Developer 27 | .mr.developer.cfg 28 | 29 | #pycharm 30 | .idea 31 | 32 | #sqlite3 33 | *.db 34 | *.sqlite3 35 | .venv 36 | -------------------------------------------------------------------------------- /.landscape.yaml: -------------------------------------------------------------------------------- 1 | autodetect: true 2 | max-line-length: 120 3 | 4 | requirements: 5 | - requirements.txt 6 | 7 | uses: 8 | - django 9 | 10 | ignore-paths: 11 | - docs 12 | - screenshot 13 | - test_app 14 | - test_project 15 | 16 | pylint: 17 | disable: 18 | - R0913 19 | 20 | python-targets: 21 | - 3 22 | -------------------------------------------------------------------------------- /.venv/.existo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ebertti/django-admin-easy/ae967deb6bf8aaea346440eeac59136b5672e546/.venv/.existo -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Ezequiel Bertti 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.python.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [requires] 7 | python_version = "3.10" 8 | 9 | [packages] 10 | Django = "*" 11 | model-bakery = "*" 12 | Sphinx = "*" 13 | Pillow = "*" 14 | tox = "*" 15 | coverage = "*" 16 | django-debug-toolbar = "*" 17 | 18 | [dev-packages] 19 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "129f781e7c70aa3cd1d3852eaafd2ca262755f0baf4890ff02c99c49df3f9cd8" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.10" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.python.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "alabaster": { 20 | "hashes": [ 21 | "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", 22 | "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b" 23 | ], 24 | "markers": "python_version >= '3.10'", 25 | "version": "==1.0.0" 26 | }, 27 | "asgiref": { 28 | "hashes": [ 29 | "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", 30 | "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590" 31 | ], 32 | "markers": "python_version >= '3.8'", 33 | "version": "==3.8.1" 34 | }, 35 | "babel": { 36 | "hashes": [ 37 | "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b", 38 | "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316" 39 | ], 40 | "markers": "python_version >= '3.8'", 41 | "version": "==2.16.0" 42 | }, 43 | "cachetools": { 44 | "hashes": [ 45 | "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292", 46 | "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a" 47 | ], 48 | "markers": "python_version >= '3.7'", 49 | "version": "==5.5.0" 50 | }, 51 | "certifi": { 52 | "hashes": [ 53 | "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b", 54 | "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90" 55 | ], 56 | "markers": "python_version >= '3.6'", 57 | "version": "==2024.7.4" 58 | }, 59 | "chardet": { 60 | "hashes": [ 61 | "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", 62 | "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970" 63 | ], 64 | "markers": "python_version >= '3.7'", 65 | "version": "==5.2.0" 66 | }, 67 | "charset-normalizer": { 68 | "hashes": [ 69 | "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", 70 | "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", 71 | "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", 72 | "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", 73 | "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", 74 | "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", 75 | "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", 76 | "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", 77 | "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", 78 | "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", 79 | "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", 80 | "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", 81 | "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", 82 | "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", 83 | "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", 84 | "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", 85 | "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", 86 | "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", 87 | "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", 88 | "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", 89 | "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", 90 | "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", 91 | "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", 92 | "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", 93 | "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", 94 | "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", 95 | "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", 96 | "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", 97 | "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", 98 | "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", 99 | "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", 100 | "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", 101 | "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", 102 | "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", 103 | "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", 104 | "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", 105 | "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", 106 | "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", 107 | "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", 108 | "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", 109 | "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", 110 | "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", 111 | "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", 112 | "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", 113 | "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", 114 | "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", 115 | "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", 116 | "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", 117 | "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", 118 | "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", 119 | "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", 120 | "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", 121 | "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", 122 | "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", 123 | "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", 124 | "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", 125 | "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", 126 | "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", 127 | "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", 128 | "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", 129 | "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", 130 | "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", 131 | "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", 132 | "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", 133 | "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", 134 | "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", 135 | "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", 136 | "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", 137 | "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", 138 | "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", 139 | "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", 140 | "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", 141 | "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", 142 | "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", 143 | "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", 144 | "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", 145 | "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", 146 | "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", 147 | "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", 148 | "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", 149 | "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", 150 | "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", 151 | "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", 152 | "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", 153 | "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", 154 | "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", 155 | "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", 156 | "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", 157 | "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", 158 | "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" 159 | ], 160 | "markers": "python_full_version >= '3.7.0'", 161 | "version": "==3.3.2" 162 | }, 163 | "colorama": { 164 | "hashes": [ 165 | "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", 166 | "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" 167 | ], 168 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", 169 | "version": "==0.4.6" 170 | }, 171 | "coverage": { 172 | "hashes": [ 173 | "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca", 174 | "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", 175 | "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", 176 | "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989", 177 | "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c", 178 | "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", 179 | "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", 180 | "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f", 181 | "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", 182 | "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", 183 | "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", 184 | "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb", 185 | "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", 186 | "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0", 187 | "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", 188 | "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8", 189 | "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", 190 | "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a", 191 | "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962", 192 | "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8", 193 | "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391", 194 | "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc", 195 | "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2", 196 | "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", 197 | "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", 198 | "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0", 199 | "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c", 200 | "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", 201 | "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004", 202 | "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", 203 | "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232", 204 | "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93", 205 | "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129", 206 | "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", 207 | "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de", 208 | "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6", 209 | "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23", 210 | "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569", 211 | "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", 212 | "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778", 213 | "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", 214 | "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36", 215 | "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a", 216 | "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6", 217 | "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34", 218 | "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", 219 | "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106", 220 | "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9", 221 | "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", 222 | "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b", 223 | "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255", 224 | "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16", 225 | "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3", 226 | "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", 227 | "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb", 228 | "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657", 229 | "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", 230 | "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", 231 | "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36", 232 | "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", 233 | "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e", 234 | "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", 235 | "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7", 236 | "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5", 237 | "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02", 238 | "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c", 239 | "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df", 240 | "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3", 241 | "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a", 242 | "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959", 243 | "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", 244 | "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc" 245 | ], 246 | "index": "pypi", 247 | "version": "==7.6.1" 248 | }, 249 | "distlib": { 250 | "hashes": [ 251 | "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784", 252 | "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64" 253 | ], 254 | "version": "==0.3.8" 255 | }, 256 | "django": { 257 | "hashes": [ 258 | "sha256:848a5980e8efb76eea70872fb0e4bc5e371619c70fffbe48e3e1b50b2c09455d", 259 | "sha256:d3b811bf5371a26def053d7ee42a9df1267ef7622323fe70a601936725aa4557" 260 | ], 261 | "index": "pypi", 262 | "version": "==5.1" 263 | }, 264 | "django-debug-toolbar": { 265 | "hashes": [ 266 | "sha256:36e421cb908c2f0675e07f9f41e3d1d8618dc386392ec82d23bcfcd5d29c7044", 267 | "sha256:3beb671c9ec44ffb817fad2780667f172bd1c067dbcabad6268ce39a81335f45" 268 | ], 269 | "index": "pypi", 270 | "version": "==4.4.6" 271 | }, 272 | "docutils": { 273 | "hashes": [ 274 | "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", 275 | "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2" 276 | ], 277 | "markers": "python_version >= '3.9'", 278 | "version": "==0.21.2" 279 | }, 280 | "filelock": { 281 | "hashes": [ 282 | "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb", 283 | "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7" 284 | ], 285 | "markers": "python_version >= '3.8'", 286 | "version": "==3.15.4" 287 | }, 288 | "idna": { 289 | "hashes": [ 290 | "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac", 291 | "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603" 292 | ], 293 | "markers": "python_version >= '3.6'", 294 | "version": "==3.8" 295 | }, 296 | "imagesize": { 297 | "hashes": [ 298 | "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", 299 | "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a" 300 | ], 301 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 302 | "version": "==1.4.1" 303 | }, 304 | "jinja2": { 305 | "hashes": [ 306 | "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", 307 | "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d" 308 | ], 309 | "markers": "python_version >= '3.7'", 310 | "version": "==3.1.4" 311 | }, 312 | "markupsafe": { 313 | "hashes": [ 314 | "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf", 315 | "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff", 316 | "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", 317 | "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3", 318 | "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532", 319 | "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f", 320 | "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", 321 | "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df", 322 | "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4", 323 | "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", 324 | "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", 325 | "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", 326 | "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8", 327 | "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371", 328 | "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2", 329 | "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465", 330 | "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52", 331 | "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6", 332 | "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", 333 | "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad", 334 | "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", 335 | "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0", 336 | "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029", 337 | "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", 338 | "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a", 339 | "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", 340 | "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5", 341 | "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", 342 | "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf", 343 | "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9", 344 | "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", 345 | "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", 346 | "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3", 347 | "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", 348 | "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46", 349 | "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc", 350 | "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a", 351 | "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", 352 | "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900", 353 | "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", 354 | "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea", 355 | "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", 356 | "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5", 357 | "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e", 358 | "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", 359 | "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f", 360 | "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50", 361 | "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", 362 | "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", 363 | "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4", 364 | "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff", 365 | "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2", 366 | "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46", 367 | "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", 368 | "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf", 369 | "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", 370 | "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5", 371 | "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab", 372 | "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd", 373 | "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68" 374 | ], 375 | "markers": "python_version >= '3.7'", 376 | "version": "==2.1.5" 377 | }, 378 | "model-bakery": { 379 | "hashes": [ 380 | "sha256:09ecbbf124d32614339581b642c82ac4a73147442f598c7bad23eece24187e5c", 381 | "sha256:37cece544a33f8899ed8f0488cd6a9d2b0b6925e7b478a4ff2786dece8c63745" 382 | ], 383 | "index": "pypi", 384 | "version": "==1.19.5" 385 | }, 386 | "packaging": { 387 | "hashes": [ 388 | "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", 389 | "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124" 390 | ], 391 | "markers": "python_version >= '3.8'", 392 | "version": "==24.1" 393 | }, 394 | "pillow": { 395 | "hashes": [ 396 | "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885", 397 | "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea", 398 | "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df", 399 | "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5", 400 | "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c", 401 | "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d", 402 | "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd", 403 | "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06", 404 | "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908", 405 | "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a", 406 | "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be", 407 | "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0", 408 | "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b", 409 | "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80", 410 | "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a", 411 | "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e", 412 | "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9", 413 | "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696", 414 | "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b", 415 | "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309", 416 | "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e", 417 | "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab", 418 | "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d", 419 | "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060", 420 | "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d", 421 | "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d", 422 | "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4", 423 | "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3", 424 | "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6", 425 | "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb", 426 | "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94", 427 | "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b", 428 | "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496", 429 | "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0", 430 | "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319", 431 | "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b", 432 | "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856", 433 | "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef", 434 | "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680", 435 | "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b", 436 | "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42", 437 | "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e", 438 | "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597", 439 | "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a", 440 | "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8", 441 | "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3", 442 | "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736", 443 | "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da", 444 | "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126", 445 | "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd", 446 | "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5", 447 | "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b", 448 | "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026", 449 | "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b", 450 | "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc", 451 | "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46", 452 | "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2", 453 | "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c", 454 | "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe", 455 | "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984", 456 | "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a", 457 | "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70", 458 | "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca", 459 | "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b", 460 | "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91", 461 | "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3", 462 | "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84", 463 | "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1", 464 | "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5", 465 | "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be", 466 | "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f", 467 | "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc", 468 | "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9", 469 | "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e", 470 | "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141", 471 | "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef", 472 | "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22", 473 | "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27", 474 | "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e", 475 | "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1" 476 | ], 477 | "index": "pypi", 478 | "version": "==10.4.0" 479 | }, 480 | "platformdirs": { 481 | "hashes": [ 482 | "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee", 483 | "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3" 484 | ], 485 | "markers": "python_version >= '3.8'", 486 | "version": "==4.2.2" 487 | }, 488 | "pluggy": { 489 | "hashes": [ 490 | "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", 491 | "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669" 492 | ], 493 | "markers": "python_version >= '3.8'", 494 | "version": "==1.5.0" 495 | }, 496 | "pygments": { 497 | "hashes": [ 498 | "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", 499 | "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a" 500 | ], 501 | "markers": "python_version >= '3.8'", 502 | "version": "==2.18.0" 503 | }, 504 | "pyproject-api": { 505 | "hashes": [ 506 | "sha256:2dc1654062c2b27733d8fd4cdda672b22fe8741ef1dde8e3a998a9547b071eeb", 507 | "sha256:7ebc6cd10710f89f4cf2a2731710a98abce37ebff19427116ff2174c9236a827" 508 | ], 509 | "markers": "python_version >= '3.8'", 510 | "version": "==1.7.1" 511 | }, 512 | "requests": { 513 | "hashes": [ 514 | "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", 515 | "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" 516 | ], 517 | "markers": "python_version >= '3.8'", 518 | "version": "==2.32.3" 519 | }, 520 | "snowballstemmer": { 521 | "hashes": [ 522 | "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", 523 | "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a" 524 | ], 525 | "version": "==2.2.0" 526 | }, 527 | "sphinx": { 528 | "hashes": [ 529 | "sha256:0cce1ddcc4fd3532cf1dd283bc7d886758362c5c1de6598696579ce96d8ffa5b", 530 | "sha256:56173572ae6c1b9a38911786e206a110c9749116745873feae4f9ce88e59391d" 531 | ], 532 | "index": "pypi", 533 | "version": "==8.0.2" 534 | }, 535 | "sphinxcontrib-applehelp": { 536 | "hashes": [ 537 | "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", 538 | "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5" 539 | ], 540 | "markers": "python_version >= '3.9'", 541 | "version": "==2.0.0" 542 | }, 543 | "sphinxcontrib-devhelp": { 544 | "hashes": [ 545 | "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", 546 | "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2" 547 | ], 548 | "markers": "python_version >= '3.9'", 549 | "version": "==2.0.0" 550 | }, 551 | "sphinxcontrib-htmlhelp": { 552 | "hashes": [ 553 | "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", 554 | "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9" 555 | ], 556 | "markers": "python_version >= '3.9'", 557 | "version": "==2.1.0" 558 | }, 559 | "sphinxcontrib-jsmath": { 560 | "hashes": [ 561 | "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", 562 | "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" 563 | ], 564 | "markers": "python_version >= '3.5'", 565 | "version": "==1.0.1" 566 | }, 567 | "sphinxcontrib-qthelp": { 568 | "hashes": [ 569 | "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", 570 | "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb" 571 | ], 572 | "markers": "python_version >= '3.9'", 573 | "version": "==2.0.0" 574 | }, 575 | "sphinxcontrib-serializinghtml": { 576 | "hashes": [ 577 | "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", 578 | "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d" 579 | ], 580 | "markers": "python_version >= '3.9'", 581 | "version": "==2.0.0" 582 | }, 583 | "sqlparse": { 584 | "hashes": [ 585 | "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4", 586 | "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e" 587 | ], 588 | "markers": "python_version >= '3.8'", 589 | "version": "==0.5.1" 590 | }, 591 | "tomli": { 592 | "hashes": [ 593 | "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", 594 | "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" 595 | ], 596 | "markers": "python_version < '3.11'", 597 | "version": "==2.0.1" 598 | }, 599 | "tox": { 600 | "hashes": [ 601 | "sha256:0a457400cf70615dc0627eb70d293e80cd95d8ce174bb40ac011011f0c03a249", 602 | "sha256:5dfa1cab9f146becd6e351333a82f9e0ade374451630ba65ee54584624c27b58" 603 | ], 604 | "index": "pypi", 605 | "version": "==4.18.0" 606 | }, 607 | "typing-extensions": { 608 | "hashes": [ 609 | "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", 610 | "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" 611 | ], 612 | "markers": "python_version < '3.11'", 613 | "version": "==4.12.2" 614 | }, 615 | "urllib3": { 616 | "hashes": [ 617 | "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472", 618 | "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168" 619 | ], 620 | "markers": "python_version >= '3.8'", 621 | "version": "==2.2.2" 622 | }, 623 | "virtualenv": { 624 | "hashes": [ 625 | "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a", 626 | "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589" 627 | ], 628 | "markers": "python_version >= '3.7'", 629 | "version": "==20.26.3" 630 | } 631 | }, 632 | "develop": {} 633 | } 634 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-admin-easy 2 | ================= 3 | 4 | Collection of admin fields, decorators and mixin to help to create computed or custom fields more friendly and easy way 5 | 6 | 7 | 8 | .. image:: https://img.shields.io/badge/django-2.x%203.x%204.x%205.0%205.1-brightgreen.svg 9 | :target: http://pypi.python.org/pypi/django-admin-easy 10 | 11 | .. image:: https://img.shields.io/pypi/v/django-admin-easy.svg?style=flat 12 | :target: http://pypi.python.org/pypi/django-admin-easy 13 | 14 | .. image:: https://img.shields.io/pypi/pyversions/django-admin-easy.svg?maxAge=2592000 15 | :target: http://pypi.python.org/pypi/django-admin-easy 16 | 17 | .. image:: https://img.shields.io/pypi/format/django-admin-easy.svg?maxAge=2592000 18 | :target: http://pypi.python.org/pypi/django-admin-easy 19 | 20 | .. image:: https://img.shields.io/pypi/status/django-admin-easy.svg?maxAge=2592000 21 | :target: http://pypi.python.org/pypi/django-admin-easy 22 | 23 | .. image:: https://github.com/ebertti/django-admin-easy/actions/workflows/test.yml/badge.svg?maxAge=2592000 24 | :target: https://github.com/ebertti/django-admin-easy/actions/workflows/test.yml 25 | 26 | .. image:: https://img.shields.io/coveralls/ebertti/django-admin-easy/master.svg?maxAge=2592000 27 | :target: https://coveralls.io/r/ebertti/django-admin-easy?branch=master 28 | 29 | 30 | Installation 31 | ------------ 32 | 33 | 1. Requirements: **Django > 3** and **Python > 3** 34 | 35 | ``pip install django-admin-easy==0.8.0`` 36 | 37 | 38 | * For **Django < 1.8** or **Python 2.x** 39 | 40 | ``pip install django-admin-easy==0.4.1`` 41 | 42 | * For **Django < 2** 43 | 44 | ``pip install django-admin-easy==0.7.0`` 45 | 46 | 47 | How it Works 48 | ------------ 49 | 50 | When you want to display a field on Django Admin, and this field doesn't exist in your Model 51 | or you need to compute some information, like a Image or Link, you will need to create a method on your ModelAdminClass like this: 52 | 53 | .. code-block:: python 54 | 55 | from django.contrib import admin 56 | 57 | class YourAdmin(admin.ModelAdmin): 58 | fields = ('sum_method', 'some_img', 'is_true') 59 | 60 | def sum_method(self, obj): 61 | sum_result = obj.field1 + obj.field2 + obj.field3 62 | return '%s' % sum_result 63 | sum_method.short_description = 'Sum' 64 | sum_method.admin_order_field = 'field1' 65 | sum_method.allow_tags = True 66 | 67 | def some_img(self, obj): 68 | return '' % obj.image 69 | some_img.short_description = 'image' 70 | some_img.admin_order_field = 'id' 71 | some_img.allow_tags = True 72 | 73 | def is_true(self, obj): 74 | return obj.value > 0 75 | is_true.short_description = 'Positive' 76 | is_true.admin_order_field = 'value' 77 | is_true.boolean = True 78 | 79 | It takes too much lines! =D 80 | 81 | With **django-admin-easy** you can easily create this field with less lines: 82 | 83 | .. code-block:: python 84 | 85 | from django.contrib import admin 86 | import easy 87 | 88 | class YourAdmin(admin.ModelAdmin): 89 | fields = ('sum_method', 'some_img', 'is_true') 90 | 91 | sum_method = easy.SimpleAdminField(lambda obj: '%s' % (obj.field1 + obj.field2 + obj.field3), 'Sum', 'field1', True) 92 | some_img = easy.ImageAdminField('image', 'id') 93 | is_true = easy.BooleanAdminField('Positive', 'value') 94 | 95 | If you still prefer using a custom method, you can use our decorators, like this: 96 | 97 | .. code-block:: python 98 | 99 | from django.contrib import admin 100 | import easy 101 | 102 | class YourAdmin(admin.ModelAdmin): 103 | fields = ('sum_method', 'some_img', 'is_true') 104 | 105 | @easy.smart(short_description='Sum', admin_order_field='field1', allow_tags=True ) 106 | def sum_method(self, obj): 107 | sum_result = obj.field1 + obj.field2 + obj.field3 108 | return '%s' % sum_result 109 | 110 | @easy.short(desc='image', order='id', tags=True) 111 | def some_img(self, obj): 112 | return '' % obj.image 113 | 114 | @easy.short(desc='Positive', order='value', bool=True) 115 | def is_true(self, obj): 116 | return obj.value > 0 117 | 118 | Another Decorators 119 | ------------------ 120 | 121 | In all of this extra decorators, you can use `short` or `smart` arguments to complement field information. 122 | 123 | * **Allow HTML tags** 124 | 125 | .. code-block:: python 126 | 127 | @easy.with_tags() 128 | def some_field_with_html(self, obj): 129 | return '{}'.format(obj.value) 130 | # output some as: mark_safe("something") 131 | 132 | 133 | if value is `5`, will display: 134 | 135 | **5** and not `5` on admin page. 136 | 137 | * **Cached field** 138 | 139 | If you, for some reason, need to cache a custom field on admin 140 | 141 | .. code-block:: python 142 | 143 | @easy.cache(10)# in secondd, default is 60 144 | def some_field_with_html(self, obj): 145 | return obj.related.some_hard_word() 146 | 147 | If you change something on your model, or some related object, you can clean this cache using this easy way: 148 | 149 | .. code-block:: python 150 | 151 | import easy 152 | # wherever you want 153 | easy.cache_clear(my_model_instance) 154 | 155 | # or 156 | class MyModel(models.Model): 157 | # ... fields 158 | 159 | def save(*args, **kwargs): 160 | easy.cache_clear(self) 161 | super(MyModel, self).save(*args, **kwargs) 162 | 163 | 164 | * **Django template filter** 165 | 166 | Can be used with all template filters on your project. 167 | 168 | .. code-block:: python 169 | 170 | # builtin template filter like {{ value|title }} 171 | @easy.filter('title') 172 | def some_field_with_html(self, obj): 173 | return 'ezequiel bertti' 174 | # output: "Ezequiel Bertti" 175 | 176 | # like {% load i10n %} and {{ value|localize }} 177 | @easy.filter('localize', 'l10n') 178 | def some_field_with_html(self, obj): 179 | return 10000 180 | # output: "10.000" 181 | 182 | # like {{ value|date:'y-m-d' }} 183 | @easy.filter('date', 'default', 'y-m-d') 184 | def some_field_with_html(self, obj): 185 | return datetime(2016, 06, 28) 186 | # output: "16-06-28" 187 | 188 | * **Django utils functions** 189 | 190 | Tested with: 191 | 192 | .. code-block:: python 193 | 194 | @easy.utils('html.escape') 195 | @easy.utils('html.conditional_escape') 196 | @easy.utils('html.strip_tags') 197 | @easy.utils('safestring.mark_safe') 198 | @easy.utils('safestring.mark_for_escaping') 199 | @easy.utils('text.slugify') 200 | @easy.utils('translation.gettext') 201 | @easy.utils('translation.ugettext') 202 | @easy.utils('translation.gettext_lazy') 203 | @easy.utils('translation.ugettext_lazy') 204 | @easy.utils('translation.gettext_noop') 205 | @easy.utils('translation.ugettext_noop') 206 | def your_method(self, obj): 207 | return obj.value 208 | 209 | More Examples 210 | ------------- 211 | 212 | .. code-block:: python 213 | 214 | from django.contrib import admin 215 | import easy 216 | 217 | class YourAdmin(admin.ModelAdmin): 218 | list_fields = ('id', 'custom1', 'custom2', 'custom3' ... 'customN') 219 | 220 | actions = ('simples_action',) 221 | 222 | @easy.action('My Little Simple Magic Action') 223 | def simple_action(self, request, queryset): 224 | return queryset.update(magic=True) 225 | 226 | # actoin only for user that has change permission on this model 227 | @easy.action('Another Simple Magic Action', 'change') 228 | def simple_action(self, request, queryset): 229 | return queryset.update(magic=True) 230 | 231 | 232 | # render a value of field, method, property or your model or related model 233 | simple1 = easy.SimpleAdminField('model_field') 234 | simple2 = easy.SimpleAdminField('method_of_model') 235 | simple3 = easy.SimpleAdminField('related.attribute_or_method') 236 | simple4 = easy.SimpleAdminField('related_set.count', 'count') 237 | simple5 = easy.SimpleAdminField(lambda x: x.method(), 'show', 'order_by') 238 | 239 | # render boolean fields 240 | bool1 = easy.BooleanAdminField(lambda x: x.value > 10, 'high') 241 | 242 | # render with string format fields 243 | format1 = easy.FormatAdminField('{o.model_field} - {o.date_field:Y%-%m}', 'column name') 244 | 245 | # render foreignkey with link to change_form in admin 246 | fk1 = easy.ForeignKeyAdminField('related') 247 | 248 | # render foreignkey with link to change_form in admin and related_id content as text 249 | fk2 = easy.ForeignKeyAdminField('related', 'related_id') 250 | 251 | # render foreignkey_id, like raw_id_fields, with link to change_form in admin and related_id content as text 252 | # without extra queries or select_related to prevent extra n-1 queries 253 | raw1 = easy.RawIdAdminField('related') 254 | 255 | # render template 256 | template1 = easy.TemplateAdminField('test.html', 'shorty description', 'order_field') 257 | 258 | # render to change_list of another model with a filter on query 259 | link1 = easy.LinkChangeListAdminField('app_label', 'model_name', 'attribute_to_text', 260 | {'field_name':'dynamic_value_model'}) 261 | 262 | link2 = easy.LinkChangeListAdminField('app_label', 'model_name', 'attribute_to_text', 263 | {'field_name':'dynamic_value_model'}, 264 | {'another_field': 'static_value'}) 265 | 266 | # render link to generic content type fields 267 | # don't forget to use select_related with content-type to avoid N+1 queries like example below 268 | generic = easy.GenericForeignKeyAdminField('generic') 269 | 270 | def get_queryset(self, request): 271 | qs = super().get_queryset(request) 272 | 273 | return qs.select_related('content_type') 274 | 275 | # or enable cache 276 | generic = easy.GenericForeignKeyAdminField('generic', cache_content_type=True) 277 | 278 | # display image of some model 279 | image1 = easy.ImageAdminField('image', {'image_attrs':'attr_value'}) 280 | 281 | # use django template filter on a field 282 | filter1 = easy.FilterAdminField('model_field', 'upper') 283 | filter2 = easy.FilterAdminField('date_field', 'date', 'django', 'y-m-d') 284 | filter3 = easy.FilterAdminField('float_field', 'localize', 'l18n') 285 | 286 | @easy.smart(short_description='Field Description 12', admin_order_field='model_field') 287 | def custom12(self, obj): 288 | return obj.something_cool() 289 | 290 | @easy.short(desc='Field Description 1', order='model_field', tags=True) 291 | def decorator1(self, obj): 292 | return '' + obj.model_field + '' 293 | 294 | @easy.short(desc='Field Description 2', order='model_field', bool=True) 295 | def decorator2(self, obj): 296 | return obj.model_field > 10 297 | 298 | 299 | If you want to use on admin form to show some information, 300 | don't forget to add your custom field on ``readonly_fields`` attribute of your admin class 301 | 302 | .. code-block:: python 303 | 304 | from django.contrib import admin 305 | import easy 306 | 307 | class YourAdmin(admin.ModelAdmin): 308 | fields = ('custom1', 'custom2', 'custom3' ... 'customN') 309 | readonly_fields = ('custom1', 'custom2', 'custom3' ... 'customN') 310 | 311 | custom1 = easy.ForeignKeyAdminField('related') 312 | # ... 313 | 314 | Another way to use is directly on ``list_fields`` declaration: 315 | 316 | .. code-block:: python 317 | 318 | from django.contrib import admin 319 | import easy 320 | 321 | class YourAdmin(admin.ModelAdmin): 322 | list_fields = ( 323 | easy.TemplateAdminField('test.html', 'shorty description', 'order_field'), 324 | easy.ImageAdminField('image', {'image_attrs':'attr_value'}), 325 | # ... 326 | ) 327 | 328 | # ... 329 | 330 | Mixin 331 | ----- 332 | 333 | To help you to create a custom view on django admin, we create the MixinEasyViews for your Admin Classes 334 | 335 | .. code-block:: python 336 | 337 | from django.contrib import admin 338 | import easy 339 | 340 | class MyModelAdmin(easy.MixinEasyViews, admin.ModelAdmin): 341 | # ... 342 | 343 | def easy_view_jump(self, request, pk=None): 344 | # do something here 345 | return HttpResponse('something') 346 | 347 | To call this view, you can use this reverse: 348 | 349 | .. code-block:: python 350 | 351 | from django.core.urlresolvers import reverse 352 | 353 | # to do something with one object of a model 354 | reverse('admin:myapp_mymodel_easy', args=(obj.pk, 'jump')) 355 | 356 | # or to do something with a model 357 | reverse('admin:myapp_mymodel_easy', args=('jump',)) 358 | 359 | Or one HTML template 360 | 361 | .. code-block:: html 362 | 363 | # 364 | {% url 'admin:myapp_mymodel_easy' obj.pk 'jump' %} 365 | 366 | # 367 | {% url 'admin:myapp_mymodel_easy' 'jump' %} 368 | 369 | Utilities 370 | --------- 371 | 372 | * Response for admin actions 373 | 374 | Return for the change list and show some message for the user keeping or not the filters. 375 | 376 | .. code-block:: python 377 | 378 | from django.contrib import admin 379 | from django.contrib import messages 380 | import easy 381 | 382 | class YourAdmin(admin.ModelAdmin): 383 | # ... 384 | actions = ('simples_action',) 385 | 386 | def simples_action(self, request, queryset): 387 | 388 | success = queryset.do_something() 389 | if success: 390 | return easy.action_response(request, 'Some success message for user', keep_querystring=False) 391 | else: 392 | return easy.action_response(request, 'Some error for user', messages.ERROR) 393 | 394 | # or just redirect to changelist with filters 395 | return easy.action_response() 396 | 397 | So easy, no? 398 | 399 | Screenshot 400 | ---------- 401 | 402 | Using example of poll of django tutorial 403 | 404 | .. image:: https://raw.githubusercontent.com/ebertti/django-admin-easy/master/screenshot/more.png 405 | 406 | .. image:: https://raw.githubusercontent.com/ebertti/django-admin-easy/master/screenshot/related.png 407 | 408 | Please help us 409 | -------------- 410 | This project is still under development. Feedback and suggestions are very welcome and I encourage you to use the `Issues list `_ on Github to provide that feedback. 411 | 412 | .. image:: https://img.shields.io/github/issues/ebertti/django-admin-easy.svg 413 | :target: https://github.com/ebertti/django-admin-easy/issues 414 | 415 | .. image:: https://img.shields.io/waffle/label/ebertti/django-admin-easy/in%20progress.svg?maxAge=2592000 416 | :target: https://waffle.io/ebertti/django-admin-easy 417 | 418 | .. image:: https://img.shields.io/github/forks/ebertti/django-admin-easy.svg 419 | :target: https://github.com/ebertti/django-admin-easy/network 420 | 421 | .. image:: https://img.shields.io/github/stars/ebertti/django-admin-easy.svg 422 | :target: https://github.com/ebertti/django-admin-easy/stargazers 423 | 424 | Authors 425 | ------- 426 | The django-admin-easy was originally created by Ezequiel Bertti `@ebertti `_ October 2014. 427 | 428 | Changelog 429 | --------- 430 | * 0.8.0 431 | 432 | Add new field GenericForeignKeyAdminField 433 | Add Support for Django 5.0 and 5.1 434 | Add Support for Python 3.12 435 | Drop support for Django < 2.0 436 | Add typing and docstring (thanks `codeium `_) 437 | 438 | * 0.7.0 439 | 440 | Add support for Django 4.0, 4.1 and 4.2 441 | Add support for Python 3.10 and 3.11 442 | Add Github Actions for testing 443 | Add job to realease on pypi 444 | Thanks @Lex98 445 | 446 | * 0.6.1 447 | 448 | Add support for Django 3.2 and Python 3.9 449 | 450 | * 0.6 451 | 452 | Add RawIdAdminField 453 | 454 | * 0.5.1 455 | 456 | Add permission on action decorator 457 | 458 | * 0.4.1 459 | 460 | Django 2.0 461 | 462 | * 0.4 463 | 464 | Django 1.11 465 | Create module utils with action_response 466 | 467 | * 0.3.2 468 | 469 | Add params_static to LinkChangeListAdminField 470 | 471 | * 0.3.1 472 | 473 | Add FormatAdminField 474 | 475 | * 0.3 476 | 477 | Add import from `__future__` on all files 478 | Django 1.10 479 | More decorators 480 | More admin fields 481 | 482 | * 0.2.2 483 | 484 | Add MixinEasyViews 485 | 486 | * 0.2.1 487 | 488 | Fix for Django 1.7 from `@kevgathuku `_ 489 | 490 | 491 | Star History 492 | ------------ 493 | 494 | .. image:: https://api.star-history.com/svg?repos=ebertti/django-admin-easy&type=Date 495 | :target: https://star-history.com/#ebertti/django-admin-easy&Date 496 | -------------------------------------------------------------------------------- /easy/__init__.py: -------------------------------------------------------------------------------- 1 | from .admin import * # noqa 2 | from .admin.field import ( # noqa 3 | BaseAdminField, BooleanAdminField, ExternalLinkAdminField, ForeignKeyAdminField, GenericForeignKeyAdminField, 4 | RawIdAdminField, ImageAdminField, LinkChangeListAdminField, SimpleAdminField, TemplateAdminField, 5 | ModelImageField, FilterAdminField, CacheAdminField, FormatAdminField 6 | ) 7 | from .admin.decorators import action, short, smart, with_tags, utils, filter, cache, clear_cache # noqa 8 | from .admin.mixin import MixinEasyViews # noqa 9 | from .util import action_response # noqa 10 | -------------------------------------------------------------------------------- /easy/admin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ebertti/django-admin-easy/ae967deb6bf8aaea346440eeac59136b5672e546/easy/admin/__init__.py -------------------------------------------------------------------------------- /easy/admin/decorators.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import asdict 4 | from functools import wraps 5 | from typing import Optional, Callable, Union, List 6 | 7 | from django import utils as django_utils 8 | from django.core.cache import cache as django_cache 9 | from django.utils.safestring import mark_safe 10 | 11 | from easy import helper 12 | 13 | Model: "django.db.models.Model" 14 | 15 | def smart(**kwargs): 16 | """ 17 | Simple decorator to get custom fields on admin class, using this you will use less line codes 18 | 19 | :param short_description: description of custom field (Optional[str]) 20 | :type short_description: str 21 | :param admin_order_field: field to order on click (Optional[str]) 22 | :type admin_order_field: str 23 | :param allow_tags: allow html tags (Optional[bool]) 24 | :type allow_tags: bool 25 | :param boolean: if field boolean (Optional[bool]) 26 | :type boolean: bool 27 | :param empty_value_display: Default value when field is null (Optional[str]) 28 | :type empty_value_display: str 29 | 30 | :return: method decorated (Callable) 31 | """ 32 | 33 | def decorator(func): 34 | for key, value in kwargs.items(): 35 | setattr(func, key, value) 36 | return func 37 | 38 | return decorator 39 | 40 | FUNCTION_MAP = { 41 | 'desc': 'short_description', 42 | 'order': 'admin_order_field', 43 | 'bool': 'boolean', 44 | 'tags': 'allow_tags', 45 | 'empty': 'empty_value_display' 46 | } 47 | 48 | 49 | def short(**kwargs: Union[str, bool]) -> Callable: 50 | """ 51 | Short decorator to set some attrs on admin method. 52 | 53 | :param kwargs: key-value pairs to set on method. 54 | :return: method decorated (Callable) 55 | """ 56 | 57 | def decorator(func: Callable) : 58 | for key, value in kwargs.items(): 59 | if key in FUNCTION_MAP: 60 | setattr(func, FUNCTION_MAP[key], value) 61 | else: 62 | setattr(func, key, value) 63 | 64 | if getattr(func, 'allow_tags', False): 65 | @wraps(func) 66 | def wrapper(*args, **kwargs): 67 | return mark_safe(func(*args, **kwargs)) 68 | return wrapper 69 | return func 70 | 71 | return decorator 72 | 73 | 74 | def action(short_description: str, permission: Optional[Union[str, List[str]]] = None) -> Callable: 75 | """ 76 | Action decorator to set some attrs on admin method. 77 | 78 | :param short_description: description of custom field (str) 79 | :param permission: permission to use. (Optional[Union[str, List[str]]]) 80 | :return: method decorated (Callable) 81 | """ 82 | 83 | def decorator(func: Callable) -> Callable: 84 | func.short_description = short_description 85 | if permission: 86 | if isinstance(permission, str): 87 | func.allowed_permissions = (permission,) 88 | else: 89 | func.allowed_permissions = permission 90 | return func 91 | 92 | return decorator 93 | 94 | 95 | def utils(django_utils_function: str) -> Callable[[Callable], Callable]: 96 | """ 97 | Util decorator to apply a django.utils function on the method result. 98 | 99 | :param django_utils_function: name of the function to apply (str) 100 | :return: function decorated (Callable[[Callable], Callable]) 101 | """ 102 | 103 | def decorator(func: Callable): 104 | util_function = helper.deep_getattribute(django_utils, django_utils_function) 105 | if isinstance(util_function, helper.Nothing): 106 | raise Exception('Function {} not exist on django.utils module.'.format(django_utils_function)) 107 | 108 | @wraps(func) 109 | def wrapper(*args, **kwargs): 110 | return util_function(func(*args, **kwargs)) 111 | 112 | return wrapper 113 | 114 | return decorator 115 | 116 | 117 | def filter(django_builtin_filter: str, load: Optional[str] = None, *extra: Union[str, List[str]]) -> Callable[[Callable], Callable]: 118 | """ 119 | Filter decorator to apply a django builtin filter on the method result. 120 | 121 | :param django_builtin_filter: name of the filter to apply (str) 122 | :param load: library to be loaded like load in templatetag. (Optional[str]) 123 | :param extra: extra arguments to pass to the filter. (Union[str, List[str]]) 124 | :return: method decorated (Callable[[Callable], Callable]) 125 | """ 126 | 127 | def decorator(func: Callable) -> Callable: 128 | filter_method = helper.get_django_filter(django_builtin_filter, load) 129 | 130 | @wraps(func) 131 | def wrapper(*args, **kwargs): 132 | value = func(*args, **kwargs) 133 | return filter_method(value, *extra) 134 | 135 | return wrapper 136 | return decorator 137 | 138 | 139 | def with_tags(): 140 | """ 141 | Decorator to mark result of method as safe and allow tags. 142 | """ 143 | 144 | def decorator(func): 145 | @wraps(func) 146 | def wrapper(*args, **kwargs): 147 | return mark_safe(func(*args, **kwargs)) 148 | return wrapper 149 | return decorator 150 | 151 | 152 | def cache(seconds: int = 60): 153 | """ 154 | Cache decorator to cache the result of a method. 155 | 156 | :param seconds: The cache time in seconds. (int) 157 | :return: The cached method 158 | """ 159 | 160 | def decorator(func: Callable) -> Callable: 161 | @wraps(func) 162 | def wrapper(admin, model): 163 | cache_method_key = helper.cache_method_key(model, func.__name__) 164 | value = django_cache.get(cache_method_key) 165 | if not value: 166 | value = func(admin, model) 167 | cache_object_key = helper.cache_object_key(model) 168 | obj_methods_caches = django_cache.get(cache_object_key) or '' 169 | django_cache.set_many({ 170 | cache_method_key: value, 171 | cache_object_key: obj_methods_caches + '|' + cache_method_key 172 | }, seconds) 173 | return value 174 | 175 | return wrapper 176 | return decorator 177 | 178 | 179 | def clear_cache(model: Model) -> None: 180 | """ 181 | Clear cache for specific model. 182 | 183 | :param model: The model to clear cache for. 184 | :type model: django.db.models.Model 185 | """ 186 | cache_object_key = helper.cache_object_key(model) 187 | obj_methods_caches = django_cache.get(cache_object_key) 188 | methods_key = obj_methods_caches.split('|') if obj_methods_caches else [] 189 | django_cache.delete_many(methods_key) 190 | -------------------------------------------------------------------------------- /easy/admin/field.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union, List, Any, Dict 2 | 3 | from django.contrib.admin.templatetags.admin_urls import admin_urlname 4 | from django.db.models import Model, ImageField as ModelImageField, ForeignKey 5 | from django.conf import settings 6 | from django.forms.utils import flatatt 7 | from django.urls import reverse 8 | from django.utils.html import conditional_escape 9 | from django.template.loader import render_to_string 10 | from django.utils.http import urlencode 11 | from django.utils.safestring import mark_safe 12 | from .decorators import django_cache 13 | 14 | from easy import helper 15 | 16 | 17 | class BaseAdminField(object): 18 | 19 | def __init__( 20 | self, 21 | short_description: str, 22 | admin_order_field: Optional[str] = None, 23 | allow_tags: bool = False 24 | ) -> None: 25 | """ 26 | Base Admin Field to be extended 27 | Args: 28 | short_description (str): The short description of the admin field. 29 | admin_order_field (Optional[str]): The field to order by when clicked in the admin. 30 | allow_tags (bool): Whether to allow HTML tags in the rendered field. 31 | """ 32 | self.short_description = short_description 33 | if admin_order_field: 34 | self.admin_order_field = admin_order_field 35 | if allow_tags: 36 | self.allow_tags = allow_tags 37 | 38 | def render(self, obj): 39 | raise NotImplementedError() 40 | 41 | def __call__(self, obj): 42 | if getattr(self, 'allow_tags', False): 43 | return mark_safe(self.render(obj)) 44 | return self.render(obj) 45 | 46 | 47 | class SimpleAdminField(BaseAdminField): 48 | 49 | def __init__( 50 | self, 51 | attr: str, 52 | short_description: Optional[str] = None, 53 | admin_order_field: Optional[str] = None, 54 | allow_tags: bool = False, 55 | default: Optional[str] = None 56 | ) -> None: 57 | """ 58 | Admin field that renders the value of the specified attribute. 59 | 60 | Args: 61 | attr (str): The attribute to render. If a callable, the callable will be 62 | called with the object as the only argument and its return value will be rendered. 63 | short_description (Optional[str]): The short description of the field. 64 | admin_order_field (Optional[str]): The field to order by when clicked in the admin. If not specified, the 65 | attribute name will be used. 66 | allow_tags (bool): Whether to allow HTML tags in the rendered field. 67 | default (Optional[str]): The default value to render if the attribute is None. 68 | If a callable, the callable will be called with no arguments and its return value will be rendered. 69 | """ 70 | self.attr = attr 71 | self.default = default 72 | 73 | if callable(attr): 74 | assert short_description 75 | else: 76 | admin_order_field = admin_order_field or attr.replace('.', '__') 77 | 78 | short_description = short_description or attr.split('.')[-1] 79 | 80 | super(SimpleAdminField, self).__init__(short_description, admin_order_field, allow_tags) 81 | 82 | def render(self, obj): 83 | return helper.call_or_get(obj, self.attr, self.default) 84 | 85 | 86 | class BooleanAdminField(SimpleAdminField): 87 | 88 | def __init__( 89 | self, 90 | attr: str, 91 | short_description: Optional[str] = None, 92 | admin_order_field: Optional[str] = None 93 | ) -> None: 94 | """ 95 | Admin field that renders a boolean icon for the value. 96 | 97 | Args: 98 | attr (str): The attribute to render. 99 | short_description (Optional[str]): The short description of the field. 100 | admin_order_field (Optional[str]): The field to order by when clicked in the admin. 101 | """ 102 | self.boolean = True 103 | super(BooleanAdminField, self).__init__(attr, short_description, admin_order_field, False, False) 104 | 105 | def render(self, obj): 106 | return bool(super(BooleanAdminField, self).render(obj)) 107 | 108 | 109 | class ForeignKeyAdminField(SimpleAdminField): 110 | 111 | def __init__( 112 | self, 113 | attr: str, 114 | display: Optional[str] = None, 115 | short_description: Optional[str] = None, 116 | admin_order_field: Optional[str] = None, 117 | default: Optional[str] = None, 118 | ) -> None: 119 | """ 120 | Admin field for displaying foreign key with link to change related object. 121 | 122 | Args: 123 | attr (str): The foreign key attribute to display. 124 | display (Optional[str]): The attribute to display instead of the foreign key 125 | attribute. Defaults to None. 126 | short_description (Optional[str]): The short description of the field. 127 | admin_order_field (Optional[str]): The field to order by when clicked in the admin. 128 | default (Optional[str]): The default value to display if the foreign key attribute 129 | is None. Defaults to None. 130 | """ 131 | self.display = display 132 | super().__init__(attr, short_description, admin_order_field, True, default) 133 | 134 | def render(self, obj): 135 | ref = helper.call_or_get(obj, self.attr, self.default) 136 | display = None 137 | if self.display: 138 | display = helper.call_or_get(obj, self.display, self.default) 139 | 140 | display = display or ref 141 | if isinstance(ref, Model): 142 | return '%s' % ( 143 | reverse( 144 | admin_urlname(ref._meta, 'change'), 145 | args=(ref.pk,) 146 | ), 147 | conditional_escape(display or ref) 148 | ) 149 | 150 | return self.default 151 | 152 | 153 | class RawIdAdminField(SimpleAdminField): 154 | 155 | def __init__( 156 | self, 157 | attr: str, 158 | short_description: Optional[str] = None, 159 | admin_order_field: Optional[str] = None, 160 | default: Optional[str] = None, 161 | ) -> None: 162 | """ 163 | Admin field for displaying raw id of foreign key. 164 | 165 | Args: 166 | attr (str): The foreign key attribute to display. 167 | short_description (Optional[str]): The short description of the field. 168 | admin_order_field (Optional[str]): The field to order by when clicked in the admin. 169 | default (Optional[str]): The default value to display if the foreign key attribute 170 | is None. Defaults to None. 171 | """ 172 | super(RawIdAdminField, self).__init__(attr, short_description, admin_order_field, True, default) 173 | 174 | def render(self, obj): 175 | field = obj._meta.get_field(self.attr) 176 | 177 | if isinstance(field, ForeignKey): 178 | meta = field.related_model._meta 179 | id = getattr(obj, field.attname) 180 | return '%s' % ( 181 | reverse( 182 | admin_urlname(meta, 'change'), 183 | args=(id,) 184 | ), 185 | conditional_escape(id) 186 | ) 187 | 188 | return self.default 189 | 190 | class GenericForeignKeyAdminField(SimpleAdminField): 191 | 192 | def __init__( 193 | self, 194 | attr: str, 195 | short_description: Optional[str] = None, 196 | admin_order_field: Optional[str] = None, 197 | default: Optional[str] = None, 198 | cache_content_type: bool = False, 199 | related_attr: Optional[str] = None 200 | ) -> None: 201 | """ 202 | Admin field for displaying generic foreign key with link to change related object. 203 | 204 | Args: 205 | attr (str): The foreign key attribute to display. 206 | short_description (Optional[str]): The short description of the field. 207 | admin_order_field (Optional[str]): The field to order by when clicked in the admin. 208 | default (Optional[str]): The default value to display if the foreign key attribute 209 | is None. Defaults to None. 210 | cache_content_type (bool): Whether to cache the content type. Defaults to False. 211 | related_attr (Optional[str]): The attribute to display instead of the foreign key 212 | attribute of related object. Defaults to None. 213 | """ 214 | self.cache_content_type = cache_content_type 215 | self.related_attr = related_attr 216 | super(GenericForeignKeyAdminField, self).__init__( 217 | attr, 218 | short_description, 219 | admin_order_field, 220 | True, 221 | default 222 | ) 223 | 224 | def render(self, obj): 225 | from django.contrib.contenttypes.fields import GenericForeignKey 226 | ct = None 227 | field = obj._meta.get_field(self.attr) 228 | 229 | if not isinstance(field, GenericForeignKey): 230 | return self.default 231 | 232 | pk = getattr(obj, field.fk_field) 233 | if not pk: 234 | return self.default 235 | 236 | if self.cache_content_type: 237 | key = helper.EASY_CACHE_TEMPLATE_OBJ.format( 238 | 'content-type', 239 | getattr(obj, f"{field.ct_field}_id") 240 | ) 241 | ct = django_cache.get(key) 242 | if not ct: 243 | ct = getattr(obj, field.ct_field) 244 | django_cache.set(key, ct) 245 | 246 | if self.related_attr: 247 | if self.cache_content_type: 248 | related = ct.get_object_for_this_type(pk=pk) 249 | else: 250 | related = getattr(obj, self.attr) 251 | display = helper.call_or_get(related, self.related_attr, self.default) 252 | else: 253 | display = pk 254 | 255 | if not ct: 256 | ct = getattr(obj, field.ct_field) 257 | return '%s' % ( 258 | reverse( 259 | 'admin:%s_%s_change' % (ct.app_label, ct.model), 260 | args=(pk,) 261 | ), 262 | "%s | %s" % (display, ct.name) 263 | ) 264 | 265 | 266 | class LinkChangeListAdminField(BaseAdminField): 267 | 268 | def __init__( 269 | self, 270 | app: str, 271 | model: str, 272 | attr: str, 273 | params: Optional[Dict[str, str]] = None, 274 | params_static: Optional[Dict[str, str]] = None, 275 | short_description: Optional[str] = None, 276 | admin_order_field: Optional[str] = None, 277 | ) -> None: 278 | """ 279 | Admin field for displaying link to change list filtered by some parameters in the URL. 280 | 281 | Args: 282 | app (str): The Django app label. 283 | model (str): The Django model name. 284 | attr (str): The attribute to display. 285 | params (Optional[Dict[str, str]]): The parameters to include in the URL. 286 | params_static (Optional[Dict[str, str]]): The static parameters to include in the URL. 287 | short_description (Optional[str]): The short description of the field. 288 | admin_order_field (Optional[str]): The field to order by when clicked in the admin. 289 | """ 290 | self.app = app 291 | self.model = model 292 | self.attr = attr 293 | self.params = params or {} 294 | self.params_static = params_static or {} 295 | super(LinkChangeListAdminField, self).__init__(short_description or model, admin_order_field, True) 296 | 297 | def render(self, obj): 298 | text = helper.call_or_get(obj, self.attr) 299 | p_params = {} 300 | for key in self.params.keys(): 301 | p_params[key] = helper.call_or_get(obj, self.params[key]) 302 | 303 | p_params.update(self.params_static) 304 | 305 | return '%s' % ( 306 | reverse('admin:%s_%s_changelist' % (self.app, self.model)) + '?' + urlencode(p_params), 307 | conditional_escape(text) 308 | ) 309 | 310 | 311 | class ExternalLinkAdminField(BaseAdminField): 312 | # todo : test with this one 313 | 314 | def __init__(self, attr, text, link, args, short_description, **kwargs): 315 | self.text = conditional_escape(text) 316 | self.attr = attr 317 | self.link = link 318 | if args: 319 | self.args = args if isinstance(args, (list, tuple)) else (args,) 320 | else: 321 | self.args = None 322 | super(ExternalLinkAdminField, self).__init__(short_description, kwargs.get('admin_order_field'), True) 323 | 324 | def render(self, obj): 325 | if self.attr == 'self': 326 | if not self.link: 327 | return obj.get_absolute_url() 328 | else: 329 | assert self.link 330 | if self.args: 331 | p_args = [helper.deep_getattribute(obj, arg) for arg in self.args] 332 | else: 333 | p_args = None 334 | return reverse(self.link, args=p_args) 335 | 336 | if not self.link: 337 | return helper.deep_getattribute(obj, self.attr).get_absolute_url() 338 | 339 | assert self.link 340 | ref = helper.deep_getattribute(obj, self.attr) 341 | 342 | if self.args: 343 | p_args = [helper.deep_getattribute(ref, arg) for arg in self.args] 344 | else: 345 | p_args = None 346 | 347 | return '%s' % (reverse(self.link, args=p_args), self.text) 348 | 349 | 350 | class TemplateAdminField(BaseAdminField): 351 | 352 | def __init__( 353 | self, 354 | template: str, 355 | context: Optional[Dict[str, Any]] = None, 356 | short_description: Optional[str] = 'without_name', 357 | admin_order_field: Optional[str] = None, 358 | ) -> None: 359 | """ 360 | Admin field for rendering a template. 361 | 362 | Args: 363 | template (str): The name of the template to render. 364 | context (Optional[Dict[str, Any]]): The context to pass to the template. Defaults to None. 365 | short_description (Optional[str]): The short description of the field. Defaults to 'without_name'. 366 | admin_order_field (Optional[str]): The field to order by when clicked in the admin. 367 | """ 368 | self.context = context or {} 369 | self.template = template 370 | super(TemplateAdminField, self).__init__(short_description, admin_order_field, True) 371 | 372 | def render(self, obj): 373 | context = self.context.copy() 374 | context.update({'obj': obj}) 375 | return render_to_string(self.template, context) 376 | 377 | 378 | class ImageAdminField(BaseAdminField): 379 | 380 | def __init__( 381 | self, 382 | attr: str, 383 | params: Optional[Dict[str, str]] = None, 384 | short_description: Optional[str] = None, 385 | admin_order_field: Optional[str] = None 386 | ) -> None: 387 | """ 388 | Admin field for rendering an image. 389 | 390 | Args: 391 | attr (str): The attribute containing the image path. 392 | params (Optional[Dict[str, str]]): The additional parameters to include in the image tag. Defaults to None. 393 | short_description (Optional[str]): The short description of the field. Defaults to None. 394 | admin_order_field (Optional[str]): The field to order by when clicked in the admin. Defaults to None. 395 | """ 396 | self.attr = attr 397 | self.params = params or {} 398 | super().__init__(short_description or attr, admin_order_field, True) 399 | 400 | def render(self, obj): 401 | src = helper.call_or_get(obj, self.attr) 402 | 403 | if isinstance(src, ModelImageField): 404 | src = settings.MEDIA_URL + src 405 | 406 | p_params = {} 407 | for key in self.params.keys(): 408 | p_params[key] = helper.call_or_get(obj, self.params[key]) 409 | 410 | p_params['src'] = src 411 | 412 | return '' % ( 413 | flatatt(p_params) 414 | ) 415 | 416 | 417 | class FilterAdminField(SimpleAdminField): 418 | 419 | def __init__( 420 | self, 421 | attr: str, 422 | django_filter: str, 423 | load: Optional[str] = None, 424 | extra: Optional[Union[str, List[str]]] = None, 425 | short_description: Optional[str] = None, 426 | admin_order_field: Optional[str] = None, 427 | allow_tags: bool = False, 428 | default: Optional[str] = None, 429 | ) -> None: 430 | """ 431 | Admin field that applies a Django filter to the field's value. 432 | 433 | Args: 434 | attr (str): The attribute to render. 435 | django_filter (str): The Django template filter to apply. 436 | load (Optional[str]): library to be loaded like load in templatetag. 437 | extra (Optional[Union[str, List[str]]]): The extra arguments to pass to the filter. 438 | short_description (Optional[str]): The short description to use. 439 | admin_order_field (Optional[str]): The field to order by when clicked in the admin. 440 | allow_tags (bool): Whether to allow HTML tags in the rendered field. 441 | default (Optional[str]): The default value to use. 442 | """ 443 | self.filter = django_filter 444 | self.load = load 445 | self.extra = extra 446 | super().__init__(attr, short_description, admin_order_field, allow_tags, default) 447 | 448 | def render(self, obj): 449 | value = super(FilterAdminField, self).render(obj) 450 | filter_method = helper.get_django_filter(self.filter, self.load) 451 | args = (self.extra) if self.extra else [] 452 | return filter_method(value, *args) 453 | 454 | 455 | class CacheAdminField(SimpleAdminField): 456 | 457 | def __init__( 458 | self, 459 | attr: str, 460 | django_filter: str, 461 | load: Optional[str] = None, 462 | extra: Optional[Union[str, List[str]]] = None, 463 | short_description: Optional[str] = None, 464 | admin_order_field: Optional[str] = None, 465 | allow_tags: bool = False, 466 | default: Optional[str] = None, 467 | ) -> None: 468 | """ 469 | Admin field that formats the value using a string format. 470 | Args: 471 | attr (str): The attribute to render. 472 | django_filter (str): The Django filter to use. 473 | load (Optional[str]): The load method to use. 474 | extra (Optional[Union[str, List[str]]]): The extra arguments to use. 475 | short_description (Optional[str]): The short description to use. 476 | admin_order_field (Optional[str]): The admin order field to use. 477 | allow_tags (bool): Whether to allow HTML tags in the rendered field. 478 | default (Optional[str]): The default value to use. 479 | """ 480 | self.filter = django_filter 481 | self.load = load 482 | self.extra = extra 483 | super().__init__(attr, short_description, admin_order_field, allow_tags, default) 484 | 485 | def render(self, obj): 486 | value = super(CacheAdminField, self).render(obj) 487 | filter_method = helper.get_django_filter(self.filter, self.load) 488 | args = (self.extra) if self.extra else [] 489 | return filter_method(value, *args) 490 | 491 | 492 | class FormatAdminField(BaseAdminField): 493 | 494 | def __init__( 495 | self, 496 | format_string: str, 497 | short_description: str, 498 | admin_order_field: Optional[str] = None, 499 | allow_tags: bool = False, 500 | ) -> None: 501 | """ 502 | Admin field that formats the value using a string format. 503 | Args: 504 | format_string (str): The string format to use when rendering the field. 505 | short_description (str): The short description of the field. 506 | admin_order_field (str, optional): The field to order by when clicked in the admin. 507 | allow_tags (bool, optional): Whether to allow HTML tags in the rendered field. 508 | """ 509 | self.format_string = format_string 510 | super().__init__(short_description, admin_order_field, allow_tags) 511 | 512 | def render(self, obj): 513 | 514 | return self.format_string.format(o=obj) 515 | -------------------------------------------------------------------------------- /easy/admin/mixin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import django.http 4 | from django.contrib import messages 5 | from django.http import HttpRequest 6 | from django.http.response import HttpResponseRedirect 7 | from django.urls import re_path, reverse 8 | 9 | HttpRequest: django.http.HttpRequest 10 | 11 | 12 | class MixinEasyViews(object): 13 | 14 | def _get_info(self): 15 | return self.model._meta.app_label, self.model._meta.model_name 16 | 17 | def get_urls(self): 18 | urls = super(MixinEasyViews, self).get_urls() 19 | 20 | easy_urls = [ 21 | re_path(r'^(?P.+)/easy/(?P.+)/$', self.admin_site.admin_view(self.easy_object_view), 22 | name='%s_%s_easy' % self._get_info()), 23 | 24 | re_path(r'^easy/(?P.+)/$', self.admin_site.admin_view(self.easy_list_view), 25 | name='%s_%s_easy' % self._get_info()), 26 | ] 27 | 28 | return easy_urls + urls 29 | 30 | def easy_object_view(self, request: "HttpRequest", pk: int, action: str) -> "HttpResponseRedirect": 31 | """ 32 | Executes the easy object view based on the action. 33 | 34 | Args: 35 | request (HttpRequest): The current request. 36 | pk (int): The primary key of the object. 37 | action (str): The action to perform. 38 | 39 | Returns: 40 | HttpResponseRedirect: The redirect response. 41 | """ 42 | method_name = 'easy_view_%s' % action 43 | 44 | view = getattr(self, method_name, None) 45 | if view: 46 | return view(request, pk) 47 | 48 | self.message_user(request, 'Easy view %s not found' % method_name, messages.ERROR) 49 | 50 | redirect = reverse('admin:%s_%s_change' % self._get_info(), args=(pk,)) 51 | 52 | return HttpResponseRedirect(redirect) 53 | 54 | def easy_list_view(self, request: "HttpRequest", action: str) -> "HttpResponseRedirect": 55 | """ 56 | Executes the easy list view based on the action. 57 | 58 | Args: 59 | request (HttpRequest): The current request. 60 | action (str): The action to perform. 61 | 62 | Returns: 63 | HttpResponseRedirect: The redirect response. 64 | """ 65 | method_name = 'easy_view_%s' % action 66 | 67 | view = getattr(self, method_name, None) 68 | if view: 69 | return view(request) 70 | 71 | self.message_user(request, 'Easy view %s not found' % method_name, messages.ERROR) 72 | 73 | redirect = reverse('admin:%s_%s_changelist' % self._get_info()) 74 | 75 | return HttpResponseRedirect(redirect) 76 | -------------------------------------------------------------------------------- /easy/helper.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Callable, Union, Any 3 | 4 | import django 5 | 6 | EASY_CACHE_TEMPLATE_METHOD = 'easy.{}.{}.{}' 7 | EASY_CACHE_TEMPLATE_OBJ = 'easy.{}.{}' 8 | 9 | 10 | Model: django.db.models.Model 11 | 12 | class Nothing(object): 13 | def __str__(self): 14 | return 'Error' 15 | 16 | def __unicode__(self): 17 | return u'Error' 18 | 19 | 20 | def deep_getattribute(obj: object, attr: str) -> Union[object, Callable]: 21 | """ 22 | Retrieves the value of a nested attribute from an object. 23 | 24 | Args: 25 | obj (object): The object to retrieve the attribute from. 26 | attr (str): The attribute to retrieve, in the form of a dot-separated string. 27 | 28 | Returns: 29 | object: The value of the attribute, or a Nothing instance if the attribute does not exist. 30 | """ 31 | attrs = attr.split(".") 32 | for i in attrs: 33 | obj = getattr(obj, i, Nothing()) 34 | return obj 35 | 36 | def get_django_filter(django_filter: str, load: str = 'django') -> Callable: 37 | """ 38 | Retrieves a Django filter method from the specified templatetag library. 39 | 40 | Args: 41 | django_filter (str): The name of the Django filter method. 42 | load (str, optional): The name of the templatetag library to load. Defaults to 'django'. 43 | 44 | Returns: 45 | Callable: The Django filter method. 46 | 47 | Raises: 48 | Exception: If the specified Django filter or templatetag library does not exist. 49 | """ 50 | from django.template.backends.django import get_installed_libraries 51 | from django.template.library import import_library 52 | libraries = get_installed_libraries() 53 | if load and not load == 'django': 54 | library_path = libraries.get(load) 55 | if not library_path: 56 | raise Exception('templatetag "{}" is not registered'.format(load)) 57 | else: 58 | library_path = 'django.template.defaultfilters' 59 | 60 | library = import_library(library_path) 61 | filter_method = library.filters.get(django_filter) 62 | if not filter_method: 63 | raise Exception(f'filter "{django_filter}" not exist on {load} templatetag package') 64 | 65 | return filter_method 66 | 67 | 68 | def call_or_get(obj: object, attr: Union[str, Callable[[object], Any]], default: Any = None) -> Any: 69 | """ 70 | Calls the given attribute if it is a callable, otherwise retrieves its value. 71 | 72 | Args: 73 | obj (object): The object to call the attribute on. 74 | attr (Union[str, Callable[[object], Any]]): The attribute to call or retrieve. 75 | default (Any, optional): The default value to return if the attribute's value is None or an instance of Nothing. 76 | 77 | Returns: 78 | Any: The result of calling the attribute if it is a callable, otherwise its value. If the attribute's value is None or an instance of Nothing, returns the default value if it is provided, otherwise returns None. 79 | """ 80 | ret = Nothing() 81 | 82 | if callable(attr): 83 | ret = attr(obj) 84 | 85 | if isinstance(ret, Nothing): 86 | value = deep_getattribute(obj, attr) 87 | if callable(value): 88 | ret = value() 89 | else: 90 | ret = value 91 | 92 | if (not ret or isinstance(ret, Nothing)) and default is not None: 93 | ret = default 94 | 95 | return ret 96 | 97 | def cache_method_key(model: Model, method_name: str) -> str: 98 | """ 99 | Generates a cache key for a method of a model instance. 100 | 101 | Args: 102 | model (Model): The model instance. 103 | method_name (str): The name of the method. 104 | 105 | Returns: 106 | str: The cache key. 107 | """ 108 | return EASY_CACHE_TEMPLATE_METHOD.format( 109 | model._meta.app_label, 110 | model._meta.model_name, 111 | method_name, 112 | model.pk 113 | ) 114 | 115 | 116 | def cache_object_key(model: Model) -> str: 117 | """ 118 | Generates a cache key for a model instance. 119 | 120 | Args: 121 | model (Model): The model instance. 122 | 123 | Returns: 124 | str: The cache key. 125 | """ 126 | return EASY_CACHE_TEMPLATE_OBJ.format( 127 | model._meta.app_label, 128 | model._meta.model_name, 129 | model.pk 130 | ) 131 | -------------------------------------------------------------------------------- /easy/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ebertti/django-admin-easy/ae967deb6bf8aaea346440eeac59136b5672e546/easy/models.py -------------------------------------------------------------------------------- /easy/tests.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import django 3 | 4 | from django.contrib.admin import AdminSite 5 | from django.contrib.auth.models import User 6 | from django.contrib.contenttypes.models import ContentType 7 | from django.contrib.sessions.backends.db import SessionStore 8 | from django.http.request import HttpRequest, QueryDict 9 | from django import test 10 | from django.utils.http import urlencode 11 | from django.utils.safestring import SafeData 12 | from model_bakery import baker 13 | 14 | import easy 15 | from easy.helper import Nothing 16 | from test_app.admin import PollAdmin 17 | from test_app.models import Question, Poll, Tag 18 | 19 | from django.utils.timezone import datetime 20 | 21 | class TestSimpleAdminField(test.TestCase): 22 | 23 | def test_simple(self): 24 | question = baker.make( 25 | Question 26 | ) 27 | 28 | custom_field = easy.SimpleAdminField('poll') 29 | ret = custom_field(question) 30 | 31 | self.assertEqual(ret, question.poll) 32 | self.assertEqual(custom_field.admin_order_field, 'poll') 33 | self.assertEqual(custom_field.short_description, 'poll') 34 | 35 | def test_simple_lambda(self): 36 | question = baker.make( 37 | Question 38 | ) 39 | 40 | custom_field = easy.SimpleAdminField(lambda obj: obj.poll, 'shorty') 41 | ret = custom_field(question) 42 | 43 | self.assertEqual(ret, question.poll) 44 | self.assertFalse(hasattr(custom_field, 'admin_order_field')) 45 | self.assertEqual(custom_field.short_description, 'shorty') 46 | 47 | def test_simple_default(self): 48 | question = baker.make( 49 | Question 50 | ) 51 | 52 | custom_field = easy.SimpleAdminField('not', default='default') 53 | ret = custom_field(question) 54 | 55 | self.assertEqual(ret, 'default') 56 | self.assertEqual(custom_field.short_description, 'not') 57 | 58 | 59 | class TestBooleanAdminField(test.TestCase): 60 | def test_boolean(self): 61 | question = baker.make( 62 | Question, 63 | question_text='Eba!' 64 | ) 65 | 66 | custom_field = easy.BooleanAdminField(lambda x: x.question_text == "Eba!", 'Eba?') 67 | ret = custom_field(question) 68 | 69 | self.assertEqual(ret, True) 70 | self.assertTrue(custom_field.boolean) 71 | 72 | 73 | class TestForeignKeyAdminField(test.TestCase): 74 | def test_foreignkey(self): 75 | question = baker.make( 76 | Question, 77 | question_text='Eba!' 78 | ) 79 | 80 | custom_field = easy.ForeignKeyAdminField('poll') 81 | ret = custom_field(question) 82 | 83 | expected = u'Poll object (1)' 84 | 85 | self.assertEqual(expected, ret) 86 | self.assertTrue(custom_field.allow_tags) 87 | 88 | def test_foreignkey_display(self): 89 | question = baker.make( 90 | Question, 91 | question_text='Eba!' 92 | ) 93 | 94 | custom_field = easy.ForeignKeyAdminField('poll', 'poll_id') 95 | ret = custom_field(question) 96 | expected = u'1' 97 | 98 | self.assertEqual(expected, ret) 99 | self.assertTrue(custom_field.allow_tags) 100 | 101 | def test_foreignkey_display_sub_property(self): 102 | question = baker.make( 103 | Question, 104 | question_text='Eba!' 105 | ) 106 | 107 | custom_field = easy.ForeignKeyAdminField('poll', 'poll.id') 108 | ret = custom_field(question) 109 | 110 | expected = u'1' 111 | 112 | self.assertEqual(expected, ret) 113 | self.assertTrue(custom_field.allow_tags) 114 | 115 | 116 | class TestGenericForeignKeyAdminField(test.TestCase): 117 | 118 | def test_generic_field(self): 119 | user = baker.make( 120 | User 121 | ) 122 | 123 | tag = baker.make( 124 | Tag, 125 | name='Eba!', 126 | generic=user 127 | ) 128 | 129 | custom_field = easy.GenericForeignKeyAdminField('generic') 130 | ret = custom_field.render(tag) 131 | expected = u'1 | usuário' 132 | 133 | self.assertEqual(expected, ret) 134 | self.assertTrue(custom_field.allow_tags) 135 | 136 | def test_generic_field_with_cache(self): 137 | ct = ContentType.objects.get_for_model(User) 138 | 139 | tags = baker.make( 140 | Tag, 141 | _quantity=10, 142 | object_id = 1, 143 | content_type_id = ct.pk 144 | ) 145 | 146 | with self.assertNumQueries(1): 147 | custom_field = easy.GenericForeignKeyAdminField('generic', cache_content_type=True) 148 | for tag in tags: 149 | ret = custom_field.render(tag) 150 | 151 | def test_generic_field_with_related_attr(self): 152 | ct = ContentType.objects.get_for_model(User) 153 | 154 | user = baker.make( 155 | User, 156 | username='eric' 157 | ) 158 | 159 | tag = baker.make( 160 | Tag, 161 | generic=user 162 | ) 163 | 164 | custom_field = easy.GenericForeignKeyAdminField('generic', cache_content_type=True, related_attr='username') 165 | ret = custom_field.render(tag) 166 | expected = u'eric' 167 | 168 | 169 | class TestRawIdAdminField(test.TestCase): 170 | def test_foreignkey(self): 171 | question = baker.make( 172 | Question, 173 | question_text='Eba!' 174 | ) 175 | 176 | custom_field = easy.RawIdAdminField('poll') 177 | ret = custom_field(question) 178 | 179 | expected = u'1' 180 | 181 | self.assertEqual(expected, ret) 182 | self.assertTrue(custom_field.allow_tags) 183 | 184 | 185 | class TestTemplateAdminField(test.TestCase): 186 | 187 | def test_template(self): 188 | question = baker.make( 189 | Question, 190 | question_text='Eba!' 191 | ) 192 | 193 | custom_field = easy.TemplateAdminField('test.html', {'a': '1'}) 194 | ret = custom_field(question) 195 | 196 | expected = u'
Eba! - 1
' 197 | 198 | self.assertEqual(expected, ret) 199 | self.assertTrue(custom_field.allow_tags) 200 | 201 | 202 | class TestLinkChangeListAdminField(test.TestCase): 203 | 204 | def test_link(self): 205 | poll = baker.make( 206 | Poll, 207 | ) 208 | 209 | custom_field = easy.LinkChangeListAdminField('test_app', 'question', 'question_set.count', 210 | {'pool': 'id'}, {'static': 1}) 211 | ret = custom_field(poll) 212 | 213 | q = urlencode({'pool': poll.id, 'static': 1}) 214 | 215 | expected = u'0' 216 | 217 | self.assertEqual(expected, ret) 218 | self.assertTrue(custom_field.allow_tags) 219 | 220 | 221 | class TestImageField(test.TestCase): 222 | 223 | def test_image_field(self): 224 | question = baker.make( 225 | Question, 226 | image='asd.jpg', 227 | question_text='bla' 228 | ) 229 | 230 | custom_field = easy.ImageAdminField('image', {'title': 'question_text'}) 231 | ret = custom_field(question) 232 | 233 | expected = u'' 234 | 235 | self.assertEqual(expected, ret) 236 | self.assertTrue(custom_field.allow_tags) 237 | 238 | class TestFilterField(test.TestCase): 239 | 240 | def test_image_field(self): 241 | question = baker.make( 242 | Question, 243 | question_text='Django admin easy is helpful?' 244 | ) 245 | 246 | custom_field = easy.FilterAdminField('question_text', 'upper') 247 | ret = custom_field(question) 248 | 249 | self.assertEqual(ret, 'DJANGO ADMIN EASY IS HELPFUL?') 250 | 251 | 252 | class TestFormatField(test.TestCase): 253 | def test_format_field(self): 254 | question = baker.make( 255 | Question, 256 | pub_date=datetime(2016, 11, 22), 257 | question_text='Django admin easy is helpful?', 258 | ) 259 | 260 | custom_field = easy.FormatAdminField('{o.question_text} | {o.pub_date:%Y-%m-%d}', 'column') 261 | ret = custom_field(question) 262 | 263 | self.assertEqual(ret, 'Django admin easy is helpful? | 2016-11-22') 264 | 265 | 266 | class TestNothing(test.TestCase): 267 | 268 | def test_nothing(self): 269 | nothing = Nothing() 270 | 271 | self.assertEqual(nothing.__str__(), 'Error') 272 | self.assertEqual(nothing.__unicode__(), u'Error') 273 | 274 | 275 | class TestSmartDecorator(test.TestCase): 276 | 277 | def test_decorator(self): 278 | 279 | @easy.smart(short_description='test', admin_order_field='test_field', allow_tags=True, boolean=True) 280 | def field(self, obj): 281 | return obj 282 | 283 | self.assertEqual(field .short_description, 'test') 284 | self.assertEqual(field.admin_order_field, 'test_field') 285 | self.assertEqual(field.allow_tags, True) 286 | self.assertEqual(field.boolean, True) 287 | self.assertEqual(field(object(), 1), 1) 288 | 289 | 290 | class TestShortDecorator(test.TestCase): 291 | 292 | def test_decorator(self): 293 | 294 | @easy.short(desc='test', order='test_field', tags=True, bool=True, empty='-') 295 | def field(self, obj): 296 | return obj 297 | 298 | self.assertEqual(field.short_description, 'test') 299 | self.assertEqual(field.admin_order_field, 'test_field') 300 | self.assertEqual(field.allow_tags, True) 301 | self.assertEqual(field.boolean, True) 302 | self.assertEqual(field.empty_value_display, '-') 303 | self.assertEqual(field(object(), 1), '1') 304 | 305 | def test_with_no_default_keys(self): 306 | @easy.short(asd='test', ds='test_field') 307 | def field(self, obj): 308 | return obj 309 | 310 | self.assertEqual(field.asd, 'test') 311 | self.assertEqual(field.ds, 'test_field') 312 | 313 | class TestWithTagDecorator(test.TestCase): 314 | 315 | def test_decorator_empty(self): 316 | 317 | @easy.with_tags() 318 | def field(self, obj): 319 | return obj 320 | 321 | r = field(self, 'asd') 322 | self.assertIsInstance(field(object(), 'asd'), SafeData) 323 | 324 | 325 | class TestDjangoUtilsDecorator(test.TestCase): 326 | 327 | def test_decorators(self): 328 | 329 | @easy.utils('html.escape') 330 | @easy.utils('html.conditional_escape') 331 | @easy.utils('html.strip_tags') 332 | @easy.utils('text.slugify') 333 | @easy.utils('translation.gettext') 334 | @easy.utils('translation.gettext_noop') 335 | def field(self, obj): 336 | return obj 337 | 338 | self.assertEqual(field(object(), 'asd'), 'asd') 339 | 340 | def test_function_not_exist(self): 341 | 342 | with self.assertRaises(Exception): 343 | @easy.utils('anything') 344 | def field(self, obj): 345 | return obj 346 | 347 | class TestDjangoFilterDecorator(test.TestCase): 348 | 349 | def test_decorators(self): 350 | 351 | @easy.filter('localize', 'l10n') 352 | def field(self, obj): 353 | return 10 354 | 355 | self.assertEqual(field(object(), 'asd'), '10') 356 | 357 | def test_decorators_from_detaultags(self): 358 | 359 | @easy.filter('capfirst') 360 | def field(self, obj): 361 | return 'ezequiel bertti' 362 | 363 | self.assertEqual(field(object(), 'asd'), 'Ezequiel bertti') 364 | 365 | def test_decorators_with_args(self): 366 | 367 | @easy.filter('date', 'django', 'y-m-d') 368 | def field(self, obj): 369 | return datetime(2016, 6, 25) 370 | 371 | self.assertEqual(field(object(), 'asd'), '16-06-25') 372 | 373 | def test_templatetag_not_exist(self): 374 | 375 | with self.assertRaises(Exception): 376 | @easy.filter('asd') 377 | def field(self, obj): 378 | return obj 379 | 380 | def test_filter_not_exist(self): 381 | with self.assertRaises(Exception): 382 | @easy.filter('asd', 'localize') 383 | def field(self, obj): 384 | return obj 385 | 386 | 387 | class TestCacheDecorator(test.TestCase): 388 | 389 | @easy.cache(10) 390 | def field(self, obj): 391 | return uuid.uuid1() 392 | 393 | @easy.cache(10) 394 | def field2(self, obj): 395 | return uuid.uuid1() 396 | 397 | def setUp(self): 398 | self.pool = baker.make( 399 | Poll, 400 | ) 401 | 402 | self.value = self.field(self.pool) 403 | 404 | def test_decorators(self): 405 | value2 = self.field(self.pool) 406 | 407 | self.assertEqual(self.value, value2) 408 | 409 | def test_delete_cache(self): 410 | easy.clear_cache(self.pool) 411 | value2 = self.field(self.pool) 412 | 413 | self.assertNotEqual(self.value, value2) 414 | 415 | def test_another_field(self): 416 | 417 | value1 = self.field(self.pool) 418 | value2 = self.field2(self.pool) 419 | 420 | self.assertNotEqual(value1, value2) 421 | 422 | 423 | class TestMultiDecorator(test.TestCase): 424 | 425 | def test_multidecorator(self): 426 | 427 | @easy.utils('safestring.mark_safe') 428 | @easy.filter('date', 'django', 'y-m-d') 429 | def field(self, obj): 430 | return datetime(2016, 6, 25) 431 | 432 | v = field(1, 1) 433 | self.assertEqual(v, '16-06-25') 434 | self.assertIsInstance(v, SafeData) 435 | 436 | 437 | class TestActionDecorator(test.TestCase): 438 | 439 | def test_decorator(self): 440 | @easy.action('description') 441 | def field(self, obj): 442 | return obj 443 | 444 | self.assertEqual(field(self, 1), 1) 445 | self.assertEqual(field.short_description, 'description') 446 | 447 | def test_decorator_permission(self): 448 | @easy.action('description', 'change') 449 | def field(self, obj): 450 | return obj 451 | 452 | self.assertEqual(field(self, 1), 1) 453 | self.assertEqual(field.short_description, 'description') 454 | self.assertEqual(field.allowed_permissions, ('change',)) 455 | 456 | 457 | def test_decorator_permission_array(self): 458 | @easy.action('description', ['change']) 459 | def field(self, obj): 460 | return obj 461 | 462 | self.assertEqual(field(self, 1), 1) 463 | self.assertEqual(field.short_description, 'description') 464 | self.assertEqual(field.allowed_permissions, ['change',]) 465 | 466 | 467 | class TestEasyView(test.TestCase): 468 | 469 | @classmethod 470 | def setUpClass(cls): 471 | cls.admin = PollAdmin(Poll, AdminSite()) 472 | super(TestEasyView, cls).setUpClass() 473 | 474 | def test_register_view(self): 475 | views = self.admin.get_urls() 476 | 477 | if django.VERSION < (2, 0) or django.VERSION > (3, 2): 478 | self.assertEqual(len(views), 8) 479 | else: 480 | self.assertEqual(len(views), 9) 481 | 482 | def test_exist_view(self): 483 | request = HttpRequest() 484 | response1 = self.admin.easy_list_view(request, 'test') 485 | response2 = self.admin.easy_object_view(request, 1, 'test') 486 | 487 | self.assertEqual(response1.status_code, 200) 488 | self.assertEqual(response2.status_code, 200) 489 | 490 | def test_not_exist_view(self): 491 | from django.contrib.messages.storage import default_storage 492 | 493 | request = HttpRequest() 494 | request.session = SessionStore('asd') 495 | request._messages = default_storage(request) 496 | 497 | response1 = self.admin.easy_list_view(request, 'not') 498 | response2 = self.admin.easy_object_view(request, 1, 'not') 499 | 500 | self.assertEqual(response1.status_code, 302) 501 | self.assertEqual(response2.status_code, 302) 502 | self.assertEqual(len(request._messages._queued_messages), 2) 503 | 504 | 505 | class TestUtilActionRedirect(test.TestCase): 506 | 507 | def test_response_normal(self): 508 | from django.contrib.messages.storage import default_storage 509 | 510 | request = HttpRequest() 511 | request.GET = QueryDict('test=asd') 512 | request.session = SessionStore('asd') 513 | request._messages = default_storage(request) 514 | 515 | response = easy.action_response(request, 'Some message') 516 | 517 | self.assertEqual(len(request._messages._queued_messages), 1) 518 | self.assertEqual(response.status_code, 302) 519 | self.assertEqual(response['Location'], './?test=asd') 520 | 521 | def test_response_without_querystring(self): 522 | from django.contrib.messages.storage import default_storage 523 | 524 | request = HttpRequest() 525 | request.GET = QueryDict('test=asd') 526 | request.session = SessionStore('asd') 527 | request._messages = default_storage(request) 528 | 529 | response = easy.action_response(request, 'Some message', keep_querystring=False) 530 | 531 | self.assertEqual(len(request._messages._queued_messages), 1) 532 | self.assertEqual(response.status_code, 302) 533 | self.assertEqual(response['Location'], '.') 534 | 535 | def test_response_whitout_message(self): 536 | from django.contrib.messages.storage import default_storage 537 | 538 | request = HttpRequest() 539 | request.GET = QueryDict('test=asd') 540 | request.session = SessionStore('asd') 541 | request._messages = default_storage(request) 542 | 543 | response = easy.action_response(request) 544 | self.assertEqual(len(request._messages._queued_messages), 0) 545 | 546 | self.assertEqual(response.status_code, 302) 547 | self.assertEqual(response['Location'], './?test=asd') 548 | 549 | def test_response_whitout_message_and_querystring(self): 550 | from django.contrib.messages.storage import default_storage 551 | 552 | request = HttpRequest() 553 | request.GET = QueryDict('test=asd') 554 | request.session = SessionStore('asd') 555 | request._messages = default_storage(request) 556 | 557 | response = easy.action_response(request, keep_querystring=False) 558 | 559 | self.assertEqual(len(request._messages._queued_messages), 0) 560 | self.assertEqual(response.status_code, 302) 561 | self.assertEqual(response['Location'], '.') 562 | 563 | def test_response_whitout_GET(self): 564 | from django.contrib.messages.storage import default_storage 565 | 566 | request = HttpRequest() 567 | request.session = SessionStore('asd') 568 | request._messages = default_storage(request) 569 | 570 | response = easy.action_response(request) 571 | 572 | self.assertEqual(len(request._messages._queued_messages), 0) 573 | self.assertEqual(response.status_code, 302) 574 | self.assertEqual(response['Location'], '.') 575 | -------------------------------------------------------------------------------- /easy/util.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Optional 4 | 5 | import django.http 6 | from django.shortcuts import redirect 7 | from django.contrib import messages 8 | 9 | HttpRequest: django.http.HttpRequest 10 | HttpResponseRedirect: django.http.HttpResponseRedirect 11 | 12 | def action_response( 13 | request: HttpRequest, 14 | message: Optional[str] = None, 15 | level: int = messages.INFO, 16 | keep_querystring: bool = True, 17 | ) -> HttpResponseRedirect: 18 | """ 19 | Redirects the user to the current page with an optional message. 20 | 21 | Args: 22 | request: The current request. 23 | message: The message to display to the user. 24 | level: The level of the message. 25 | keep_querystring: Whether to keep the query string in the redirect URL. 26 | 27 | Returns: 28 | An HttpResponseRedirect object. 29 | """ 30 | redirect_url = "." 31 | if keep_querystring and request.GET: 32 | redirect_url = "./?" + request.GET.urlencode() 33 | if message: 34 | messages.add_message(request, level, message, fail_silently=True) 35 | return redirect(redirect_url) 36 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | model-bakery 2 | Sphinx 3 | Pillow 4 | tox 5 | coverage 6 | django-debug-toolbar 7 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import ( 3 | absolute_import, division, print_function, unicode_literals 4 | ) 5 | 6 | import os 7 | import sys 8 | import django 9 | 10 | from django.conf import settings 11 | from django.test.utils import get_runner 12 | 13 | 14 | def runtests(): 15 | os.environ['DJANGO_SETTINGS_MODULE'] = 'test_project.settings' 16 | django.setup() 17 | TestRunner = get_runner(settings) 18 | test_runner = TestRunner() 19 | failures = test_runner.run_tests(["tests"]) 20 | sys.exit(bool(failures)) 21 | 22 | if __name__ == '__main__': 23 | runtests() -------------------------------------------------------------------------------- /screenshot/more.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ebertti/django-admin-easy/ae967deb6bf8aaea346440eeac59136b5672e546/screenshot/more.png -------------------------------------------------------------------------------- /screenshot/related.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ebertti/django-admin-easy/ae967deb6bf8aaea346440eeac59136b5672e546/screenshot/related.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | from setuptools import setup, find_packages 3 | 4 | setup( 5 | name='django-admin-easy', 6 | version='0.8.0', 7 | url='http://github.com/ebertti/django-admin-easy/', 8 | author='Ezequiel Bertti', 9 | author_email='ebertti@gmail.com', 10 | install_requires=['django',], 11 | packages=find_packages(exclude=('test_app', 'test_project')), 12 | include_package_data=True, 13 | license='MIT License', 14 | platforms=['OS Independent'], 15 | description="Collection of admin fields and decorators to help to create computed or custom fields more friendly and easy way", 16 | long_description=(open('README.rst').read()), 17 | keywords='admin field django easy simple', 18 | test_suite="runtests.runtests", 19 | classifiers=[ 20 | 'Development Status :: 5 - Production/Stable', 21 | 'Framework :: Django', 22 | 'Intended Audience :: Developers', 23 | 'License :: OSI Approved :: MIT License', 24 | 'Natural Language :: English', 25 | 'Natural Language :: Portuguese (Brazilian)', 26 | 'Operating System :: OS Independent', 27 | 'Programming Language :: Python :: 3', 28 | 'Programming Language :: Python :: 3.7', 29 | 'Programming Language :: Python :: 3.8', 30 | 'Programming Language :: Python :: 3.9', 31 | 'Programming Language :: Python :: 3.10', 32 | 'Programming Language :: Python :: 3.11', 33 | 'Programming Language :: Python :: 3.12', 34 | ], 35 | zip_safe=False, 36 | ) 37 | -------------------------------------------------------------------------------- /sweep.yaml: -------------------------------------------------------------------------------- 1 | # Sweep AI turns bugs & feature requests into code changes (https://sweep.dev) 2 | # For details on our config file, check out our docs at https://docs.sweep.dev/usage/config 3 | 4 | # This setting contains a list of rules that Sweep will check for. If any of these rules are broken in a new commit, Sweep will create an pull request to fix the broken rule. 5 | rules: 6 | - "All new business logic should have corresponding unit tests." 7 | - "Refactor large functions to be more modular." 8 | - "Add docstrings to all functions and file headers." 9 | 10 | # This is the branch that Sweep will develop from and make pull requests to. Most people use 'main' or 'master' but some users also use 'dev' or 'staging'. 11 | branch: 'main' 12 | 13 | # By default Sweep will read the logs and outputs from your existing Github Actions. To disable this, set this to false. 14 | gha_enabled: True 15 | 16 | # This is the description of your project. It will be used by sweep when creating PRs. You can tell Sweep what's unique about your project, what frameworks you use, or anything else you want. 17 | # 18 | # Example: 19 | # 20 | # description: sweepai/sweep is a python project. The main api endpoints are in sweepai/api.py. Write code that adheres to PEP8. 21 | description: '' 22 | 23 | # This sets whether to create pull requests as drafts. If this is set to True, then all pull requests will be created as drafts and GitHub Actions will not be triggered. 24 | draft: False 25 | 26 | # This is a list of directories that Sweep will not be able to edit. 27 | blocked_dirs: [] 28 | -------------------------------------------------------------------------------- /test_app/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 -------------------------------------------------------------------------------- /test_app/admin.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from django.contrib import admin 3 | from django.http.response import HttpResponse 4 | import easy 5 | from test_app.models import Choice, Question, Poll, Tag 6 | 7 | 8 | class ChoiceInline(admin.StackedInline): 9 | model = Choice 10 | extra = 3 11 | 12 | 13 | class QuestionAdmin(admin.ModelAdmin): 14 | list_display = ('id', 'poll_link', 'bool_sample', 'question_text', 'pub_date',) 15 | list_filter = ('pub_date',) 16 | search_fields = ('question_text',) 17 | fieldsets = ( 18 | (None, { 19 | 'fields': ('poll', 'question_text',) 20 | }),( 21 | 'Date information', 22 | {'classes': ('collapse',), 23 | 'fields': ('pub_date',), 24 | } 25 | ), 26 | ) 27 | inlines = (ChoiceInline,) 28 | 29 | poll_link = easy.ForeignKeyAdminField('poll') 30 | bool_sample = easy.BooleanAdminField(lambda x: x.id == 1, 'First') 31 | 32 | 33 | class PollAdmin(easy.MixinEasyViews, admin.ModelAdmin): 34 | list_display = ('name', 'count_question') 35 | 36 | count_question = easy.LinkChangeListAdminField('test_app', 'question', 'question_set.count', {'poll': 'id'}, 'Count') 37 | 38 | def easy_view_test(self, request, *args): 39 | 40 | return HttpResponse('test is ok with %s' % (args or 'list')) 41 | 42 | class TagAdmin(admin.ModelAdmin): 43 | list_display = ('name', 'generic_link') 44 | 45 | generic_link = easy.GenericForeignKeyAdminField('generic', cache_content_type=True) 46 | 47 | admin.site.register(Poll, PollAdmin) 48 | admin.site.register(Question, QuestionAdmin) 49 | admin.site.register(Tag, TagAdmin) 50 | -------------------------------------------------------------------------------- /test_app/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.1 on 2018-09-15 21:24 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='Choice', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('choice_text', models.CharField(max_length=200)), 20 | ('votes', models.IntegerField(default=0)), 21 | ], 22 | ), 23 | migrations.CreateModel( 24 | name='Poll', 25 | fields=[ 26 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 27 | ('name', models.CharField(max_length=200)), 28 | ], 29 | ), 30 | migrations.CreateModel( 31 | name='Question', 32 | fields=[ 33 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 34 | ('question_text', models.CharField(max_length=200)), 35 | ('pub_date', models.DateTimeField(verbose_name='date published')), 36 | ('image', models.ImageField(blank=True, null=True, upload_to='media')), 37 | ('poll', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='test_app.Poll')), 38 | ], 39 | ), 40 | migrations.AddField( 41 | model_name='choice', 42 | name='question', 43 | field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='test_app.Question'), 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /test_app/migrations/0002_auto_20240827_1315.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2024-08-27 16:15 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('contenttypes', '0002_remove_content_type_name'), 11 | ('test_app', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='choice', 17 | name='id', 18 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 19 | ), 20 | migrations.AlterField( 21 | model_name='poll', 22 | name='id', 23 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 24 | ), 25 | migrations.AlterField( 26 | model_name='question', 27 | name='id', 28 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 29 | ), 30 | migrations.CreateModel( 31 | name='Tag', 32 | fields=[ 33 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 34 | ('name', models.CharField(max_length=50)), 35 | ('object_id', models.BigIntegerField()), 36 | ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), 37 | ], 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /test_app/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ebertti/django-admin-easy/ae967deb6bf8aaea346440eeac59136b5672e546/test_app/migrations/__init__.py -------------------------------------------------------------------------------- /test_app/models.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from django.db import models 3 | from django.conf import settings 4 | from django.contrib.contenttypes.fields import GenericForeignKey 5 | from django.contrib.contenttypes.models import ContentType 6 | 7 | class Poll(models.Model): 8 | name = models.CharField(max_length=200) 9 | 10 | 11 | class Question(models.Model): 12 | question_text = models.CharField(max_length=200) 13 | pub_date = models.DateTimeField('date published') 14 | image = models.ImageField(upload_to='media', blank=True, null=True) 15 | poll = models.ForeignKey(Poll, on_delete=models.PROTECT) 16 | 17 | 18 | class Choice(models.Model): 19 | question = models.ForeignKey(Question, on_delete=models.PROTECT) 20 | choice_text = models.CharField(max_length=200) 21 | votes = models.IntegerField(default=0) 22 | 23 | class Tag(models.Model): 24 | name = models.CharField(max_length=50) 25 | 26 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 27 | object_id = models.BigIntegerField() 28 | generic = GenericForeignKey('content_type', 'object_id') -------------------------------------------------------------------------------- /test_app/templates/test.html: -------------------------------------------------------------------------------- 1 |
{{ obj.question_text }} - {{ a }}
-------------------------------------------------------------------------------- /test_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ebertti/django-admin-easy/ae967deb6bf8aaea346440eeac59136b5672e546/test_project/__init__.py -------------------------------------------------------------------------------- /test_project/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for here project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /test_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for here project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.2/ref/settings/ 11 | """ 12 | import os 13 | 14 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 15 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 16 | 17 | # Quick-start development settings - unsuitable for production 18 | # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ 19 | 20 | # SECURITY WARNING: keep the secret key used in production secret! 21 | SECRET_KEY = 'django-insecure-i(hx4sij4qt0r#8278u75k)=illfu8oxhy6p$^#g*rohs%c%vx' 22 | 23 | # SECURITY WARNING: don't run with debug turned on in production! 24 | DEBUG = True 25 | 26 | ALLOWED_HOSTS = [] 27 | 28 | 29 | # Application definition 30 | 31 | INSTALLED_APPS = ( 32 | 'django.contrib.admin', 33 | 'django.contrib.auth', 34 | 'django.contrib.contenttypes', 35 | 'django.contrib.sessions', 36 | 'django.contrib.messages', 37 | 'django.contrib.staticfiles', 38 | 39 | # 'debug_toolbar', 40 | 41 | 'easy', 42 | 'test_app', 43 | ) 44 | 45 | MIDDLEWARE = ( 46 | 'django.middleware.security.SecurityMiddleware', 47 | 'django.contrib.sessions.middleware.SessionMiddleware', 48 | 'django.middleware.common.CommonMiddleware', 49 | 'django.middleware.csrf.CsrfViewMiddleware', 50 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 51 | 'django.contrib.messages.middleware.MessageMiddleware', 52 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 53 | 54 | # 'debug_toolbar.middleware.DebugToolbarMiddleware', 55 | ) 56 | 57 | INTERNAL_IPS = ('127.0.0.1',) 58 | 59 | ROOT_URLCONF = 'test_project.urls' 60 | 61 | TEMPLATES = [ 62 | { 63 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 64 | 'DIRS': [ 65 | os.path.join(BASE_DIR, 'templates'), 66 | ], 67 | 'APP_DIRS': True, 68 | 'OPTIONS': { 69 | 'context_processors': [ 70 | 'django.template.context_processors.debug', 71 | 'django.template.context_processors.request', 72 | 'django.contrib.auth.context_processors.auth', 73 | 'django.contrib.messages.context_processors.messages', 74 | ], 75 | }, 76 | }, 77 | ] 78 | 79 | WSGI_APPLICATION = 'test_project.wsgi.application' 80 | 81 | 82 | # Database 83 | # https://docs.djangoproject.com/en/4.2/ref/settings/#databases 84 | 85 | DATABASES = { 86 | 'default': { 87 | 'ENGINE': 'django.db.backends.sqlite3', 88 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 89 | } 90 | } 91 | 92 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 93 | 94 | CACHES = { 95 | 'default': { 96 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 97 | 'LOCATION': 'unique-snowflake', 98 | } 99 | } 100 | 101 | # Password validation 102 | # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators 103 | 104 | AUTH_PASSWORD_VALIDATORS = [ 105 | { 106 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 107 | }, 108 | { 109 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 110 | }, 111 | { 112 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 113 | }, 114 | { 115 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 116 | }, 117 | ] 118 | 119 | 120 | # Internationalization 121 | # https://docs.djangoproject.com/en/4.2/topics/i18n/ 122 | 123 | LANGUAGE_CODE = 'pt-br' 124 | TIME_ZONE = 'America/Sao_Paulo' 125 | USE_I18N = True 126 | USE_TZ = True 127 | 128 | 129 | # Static files (CSS, JavaScript, Images) 130 | # https://docs.djangoproject.com/en/4.2/howto/static-files/ 131 | 132 | STATIC_URL = 'static/' 133 | STATIC_PATH = os.path.join(BASE_DIR, '/static') 134 | 135 | 136 | # Default primary key field type 137 | # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field 138 | -------------------------------------------------------------------------------- /test_project/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import re_path 3 | 4 | # from debug_toolbar.toolbar import debug_toolbar_urls 5 | 6 | 7 | urlpatterns = [ 8 | re_path(r'^admin/', admin.site.urls), 9 | ] # + debug_toolbar_urls() 10 | -------------------------------------------------------------------------------- /test_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for here project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py37-{21,22,30,31,32} 4 | py38-{22,30,31,32,40,41,42} 5 | py39-{22,30,31,32,40,41,42} 6 | py310-{22,30,31,32,40,41,42,50,51} 7 | py311-{42,50,51} 8 | py312-{42,50,51} 9 | 10 | 11 | [gh-actions] 12 | python = 13 | 3.8: py38 14 | 3.9: py39 15 | 3.10: py310 16 | 3.11: py311 17 | 3.12: py312 18 | 19 | [testenv] 20 | deps = 21 | wheel 22 | pillow 23 | model-bakery 24 | 20: Django >= 2.0, < 2.1 25 | 21: Django >= 2.1, < 2.2 26 | 22: Django >= 2.2, < 2.3 27 | 30: Django >= 3.0, < 3.1 28 | 31: Django >= 3.1, < 3.2 29 | 32: Django >= 3.2, < 3.3 30 | 40: Django >= 4.0, < 4.1 31 | 41: Django >= 4.1, < 4.2 32 | 42: Django >= 4.2, < 4.3 33 | 50: Django >= 5.0, < 5.1 34 | 51: Django >= 5.1, < 5.2 35 | 36 | commands = python manage.py test easy 37 | --------------------------------------------------------------------------------